From 52677847895557485c032c4ff61f35e4877f95ff Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 27 Jul 2022 10:32:33 +0200 Subject: [PATCH] SONAR-18484 Add api/v2/system/liveness endpoint --- .../sonar-web/public/WEB-INF/app-content.xml | 17 ++ ...DefaultAdminCredentialsVerifierFilter.java | 5 +- .../authentication/ResetPasswordFilter.java | 5 +- .../org/sonar/server/user/SystemPasscode.java | 9 +- .../sonar/server/user/SystemPasscodeImpl.java | 11 +- .../server/user/SystemPasscodeImplTest.java | 43 ++-- .../plugins/PluginsRiskConsentFilter.java | 2 +- server/sonar-webserver-webapi-v2/build.gradle | 22 ++ .../org/sonar/server/v2/LogComponent.java | 48 +++++ .../org/sonar/server/v2/WebApiEndpoints.java | 29 +++ .../RestResponseEntityExceptionHandler.java | 37 ++++ .../server/v2/config/CommonWebConfig.java | 36 ++++ .../v2/config/PlatformLevel4WebConfig.java | 50 +++++ .../server/v2/config/SafeModeWebConfig.java | 47 +++++ .../controller/DefautLivenessController.java | 59 ++++++ .../v2/controller/LivenessController.java | 52 +++++ .../DefautLivenessControllerTest.java | 100 +++++++++ server/sonar-webserver-webapi/build.gradle | 8 +- server/sonar-webserver/build.gradle | 1 + .../sonar/server/platform/PlatformImpl.java | 20 ++ .../server/platform/web/ApiV2Servlet.java | 130 ++++++++++++ .../web/SonarLintConnectionFilter.java | 1 + .../server/platform/web/WebPagesFilter.java | 1 + .../server/platform/web/WebServiceFilter.java | 6 +- .../server/platform/web/ApiV2ServletTest.java | 194 ++++++++++++++++++ settings.gradle | 1 + sonar-application/build.gradle | 2 +- 27 files changed, 902 insertions(+), 34 deletions(-) create mode 100644 server/sonar-web/public/WEB-INF/app-content.xml create mode 100644 server/sonar-webserver-webapi-v2/build.gradle create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/LogComponent.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java create mode 100644 server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java create mode 100644 server/sonar-webserver/src/main/java/org/sonar/server/platform/web/ApiV2Servlet.java create mode 100644 server/sonar-webserver/src/test/java/org/sonar/server/platform/web/ApiV2ServletTest.java diff --git a/server/sonar-web/public/WEB-INF/app-content.xml b/server/sonar-web/public/WEB-INF/app-content.xml new file mode 100644 index 00000000000..6593bb29d75 --- /dev/null +++ b/server/sonar-web/public/WEB-INF/app-content.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierFilter.java b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierFilter.java index e6fe4e594ed..8c26397300f 100644 --- a/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierFilter.java +++ b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierFilter.java @@ -19,7 +19,6 @@ */ package org.sonar.server.authentication; -import com.google.common.collect.ImmutableSet; import java.io.IOException; import java.util.Set; import javax.servlet.FilterChain; @@ -43,10 +42,10 @@ public class DefaultAdminCredentialsVerifierFilter extends ServletFilter { // is why this is not defined in org.sonar.process.ProcessProperties. private static final String SONAR_FORCE_REDIRECT_DEFAULT_ADMIN_CREDENTIALS = "sonar.forceRedirectOnDefaultAdminCredentials"; - private static final Set SKIPPED_URLS = ImmutableSet.of( + private static final Set SKIPPED_URLS = Set.of( RESET_PASSWORD_PATH, CHANGE_ADMIN_PASSWORD_PATH, - "/batch/*", "/api/*"); + "/batch/*", "/api/*", "/api/v2/*"); private final Configuration config; private final DefaultAdminCredentialsVerifier defaultAdminCredentialsVerifier; diff --git a/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/ResetPasswordFilter.java b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/ResetPasswordFilter.java index 7c19bf0354e..311c93d5b0e 100644 --- a/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/ResetPasswordFilter.java +++ b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/ResetPasswordFilter.java @@ -19,7 +19,6 @@ */ package org.sonar.server.authentication; -import com.google.common.collect.ImmutableSet; import java.io.IOException; import java.util.Set; import javax.servlet.FilterChain; @@ -38,9 +37,9 @@ import static org.sonar.server.authentication.AuthenticationRedirection.redirect public class ResetPasswordFilter extends ServletFilter { private static final String RESET_PASSWORD_PATH = "/account/reset_password"; - private static final Set SKIPPED_URLS = ImmutableSet.of( + private static final Set SKIPPED_URLS = Set.of( RESET_PASSWORD_PATH, - "/batch/*", "/api/*"); + "/batch/*", "/api/*", "/api/v2/*"); private final ThreadLocalUserSession userSession; diff --git a/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscode.java b/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscode.java index eb4f4855dee..b01240a79c4 100644 --- a/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscode.java +++ b/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscode.java @@ -19,6 +19,7 @@ */ package org.sonar.server.user; +import javax.annotation.Nullable; import org.sonar.api.server.ws.Request; /** @@ -33,8 +34,14 @@ public interface SystemPasscode { /** * Whether the system passcode is provided by the HTTP request or not. - * Returns {@code false} if passcode is not configured. + * Returns {@code false} if passcode is not configured or not valid. */ boolean isValid(Request request); + /** + * Check if the passcode passed as argument is valid. + * Returns {@code false} if passcode is not configured or not valid. + */ + boolean isValidPasscode(@Nullable String passcode); + } diff --git a/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscodeImpl.java b/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscodeImpl.java index 047f8e2a8b2..b04bede3b3f 100644 --- a/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscodeImpl.java +++ b/server/sonar-webserver-auth/src/main/java/org/sonar/server/user/SystemPasscodeImpl.java @@ -19,7 +19,9 @@ */ package org.sonar.server.user; +import java.util.Objects; import java.util.Optional; +import javax.annotation.Nullable; import org.apache.commons.lang.StringUtils; import org.sonar.api.Startable; import org.sonar.api.config.Configuration; @@ -46,8 +48,13 @@ public class SystemPasscodeImpl implements SystemPasscode, Startable { if (configuredPasscode == null) { return false; } - return request.header(PASSCODE_HTTP_HEADER) - .map(s -> configuredPasscode.equals(s)) + return isValidPasscode(request.header(PASSCODE_HTTP_HEADER).orElse(null)); + } + + @Override + public boolean isValidPasscode(@Nullable String passcode) { + return Optional.ofNullable(passcode) + .map(s -> Objects.equals(configuredPasscode, s)) .orElse(false); } diff --git a/server/sonar-webserver-auth/src/test/java/org/sonar/server/user/SystemPasscodeImplTest.java b/server/sonar-webserver-auth/src/test/java/org/sonar/server/user/SystemPasscodeImplTest.java index f4d5bddbd1d..ee874c6692b 100644 --- a/server/sonar-webserver-auth/src/test/java/org/sonar/server/user/SystemPasscodeImplTest.java +++ b/server/sonar-webserver-auth/src/test/java/org/sonar/server/user/SystemPasscodeImplTest.java @@ -19,9 +19,13 @@ */ package org.sonar.server.user; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; import org.junit.After; import org.junit.Rule; import org.junit.Test; +import org.junit.runner.RunWith; import org.sonar.api.config.internal.MapSettings; import org.sonar.api.impl.ws.SimpleGetRequest; import org.sonar.api.utils.log.LogTester; @@ -29,6 +33,7 @@ import org.sonar.api.utils.log.LoggerLevel; import static org.assertj.core.api.Assertions.assertThat; +@RunWith(DataProviderRunner.class) public class SystemPasscodeImplTest { @Rule @@ -65,36 +70,34 @@ public class SystemPasscodeImplTest { assertThat(logTester.logs(LoggerLevel.INFO)).contains("System authentication by passcode is disabled"); } - @Test - public void isValid_is_true_if_request_header_matches_configured_passcode() { - verifyIsValid(true, "foo", "foo"); + @DataProvider + public static Object[][] passcodeConfigurationAndUserInput() { + return new Object[][] { + {"toto", "toto", true}, + {"toto", "tata", false}, + {"toto", "Toto", false}, + {"toto", "toTo", false}, + {null, null, false}, + {null, "toto", false}, + {"toto", null, false}, + }; } - @Test - public void isValid_is_false_if_request_header_matches_configured_passcode_with_different_case() { - verifyIsValid(false, "foo", "FOO"); - } @Test - public void isValid_is_false_if_request_header_does_not_match_configured_passcode() { - verifyIsValid(false, "foo", "bar"); - } - - @Test - public void isValid_is_false_if_request_header_is_defined_but_passcode_is_not_configured() { - verifyIsValid(false, null, "foo"); + @UseDataProvider("passcodeConfigurationAndUserInput") + public void isValidPasscode_worksCorrectly(String configuredPasscode, String userPasscode, boolean expectedResult) { + configurePasscode(configuredPasscode); + assertThat(underTest.isValidPasscode(userPasscode)).isEqualTo(expectedResult); } @Test - public void isValid_is_false_if_request_header_is_empty() { - verifyIsValid(false, "foo", ""); - } - - private void verifyIsValid(boolean expectedResult, String configuredPasscode, String header) { + @UseDataProvider("passcodeConfigurationAndUserInput") + public void isValid_worksCorrectly(String configuredPasscode, String userPasscode, boolean expectedResult) { configurePasscode(configuredPasscode); SimpleGetRequest request = new SimpleGetRequest(); - request.setHeader("X-Sonar-Passcode", header); + request.setHeader("X-Sonar-Passcode", userPasscode); assertThat(underTest.isValid(request)).isEqualTo(expectedResult); } diff --git a/server/sonar-webserver-core/src/main/java/org/sonar/server/plugins/PluginsRiskConsentFilter.java b/server/sonar-webserver-core/src/main/java/org/sonar/server/plugins/PluginsRiskConsentFilter.java index 7b81cea09f7..355f060d6c6 100644 --- a/server/sonar-webserver-core/src/main/java/org/sonar/server/plugins/PluginsRiskConsentFilter.java +++ b/server/sonar-webserver-core/src/main/java/org/sonar/server/plugins/PluginsRiskConsentFilter.java @@ -47,7 +47,7 @@ public class PluginsRiskConsentFilter extends ServletFilter { PLUGINS_RISK_CONSENT_PATH, "/account/reset_password", "/admin/change_admin_password", - "/batch/*", "/api/*"); + "/batch/*", "/api/*", "/api/v2/*"); private final ThreadLocalUserSession userSession; private final Configuration config; diff --git a/server/sonar-webserver-webapi-v2/build.gradle b/server/sonar-webserver-webapi-v2/build.gradle new file mode 100644 index 00000000000..0c41cadea53 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/build.gradle @@ -0,0 +1,22 @@ +sonarqube { + properties { + property 'sonar.projectName', "${projectTitle} :: WebServer :: WebAPIV2" + } +} + +dependencies { + // please keep the list grouped by configuration and ordered by name + api 'org.springframework:spring-webmvc:5.3.23' + + api project(':server:sonar-db-dao') + // We are not suppose to have a v1 dependency. The ideal would be to have another common module between webapi and webapi-v2 but that needs a lot of refactoring. + api project(':server:sonar-webserver-webapi') + + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.springframework:spring-test:5.3.23' + + testImplementation testFixtures(project(':server:sonar-server-common')) + + testImplementation project(':sonar-testing-harness') +} + diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/LogComponent.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/LogComponent.java new file mode 100644 index 00000000000..0fdaf56214c --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/LogComponent.java @@ -0,0 +1,48 @@ +/* + * 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.v2; + +import java.util.List; +import java.util.stream.Collectors; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.handler.AbstractHandlerMethodMapping; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +@Component +public class LogComponent implements ApplicationListener { + + private static final Logger LOGGER = Loggers.get(LogComponent.class); + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + ApplicationContext applicationContext = event.getApplicationContext(); + List isUseless = applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values().stream() + .map(AbstractHandlerMethodMapping::getHandlerMethods) + .map(d->{ + d.forEach((e, c)-> LOGGER.info("Registered endpoint: "+e.getName()+" "+e.getDirectPaths()+" "+e)); + return 1; + }) + .collect(Collectors.toList()); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java new file mode 100644 index 00000000000..dd6fd559af0 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java @@ -0,0 +1,29 @@ +/* + * 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.v2; + +public class WebApiEndpoints { + + public static final String LIVENESS_ENDPOINT = "/system/liveness"; + + private WebApiEndpoints() { + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java new file mode 100644 index 00000000000..170914c5ec2 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java @@ -0,0 +1,37 @@ +/* + * 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.v2.common; + +import java.util.Optional; +import org.sonar.server.exceptions.ServerException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class RestResponseEntityExceptionHandler { + + @ExceptionHandler(ServerException.class) + protected ResponseEntity handleServerException(ServerException serverException) { + return new ResponseEntity<>(serverException.getMessage(), Optional.ofNullable(HttpStatus.resolve(serverException.httpCode())).orElse(HttpStatus.INTERNAL_SERVER_ERROR)); + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java new file mode 100644 index 00000000000..23a1fdc678a --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java @@ -0,0 +1,36 @@ +/* + * 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.v2.config; + +import org.sonar.server.v2.common.RestResponseEntityExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@Configuration +@EnableWebMvc +public class CommonWebConfig { + + @Bean + public RestResponseEntityExceptionHandler restResponseEntityExceptionHandler() { + return new RestResponseEntityExceptionHandler(); + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java new file mode 100644 index 00000000000..45153c377cc --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java @@ -0,0 +1,50 @@ +/* + * 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.v2.config; + +import org.sonar.server.health.CeStatusNodeCheck; +import org.sonar.server.health.DbConnectionNodeCheck; +import org.sonar.server.health.EsStatusNodeCheck; +import org.sonar.server.health.WebServerStatusNodeCheck; +import org.sonar.server.platform.ws.LivenessChecker; +import org.sonar.server.platform.ws.LivenessCheckerImpl; +import org.sonar.server.user.SystemPasscode; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.controller.DefautLivenessController; +import org.sonar.server.v2.controller.LivenessController; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Import(CommonWebConfig.class) +public class PlatformLevel4WebConfig { + + @Bean + public LivenessChecker livenessChecker(DbConnectionNodeCheck dbConnectionNodeCheck, WebServerStatusNodeCheck webServerStatusNodeCheck, CeStatusNodeCheck ceStatusNodeCheck, + EsStatusNodeCheck esStatusNodeCheck) { + return new LivenessCheckerImpl(dbConnectionNodeCheck, webServerStatusNodeCheck, ceStatusNodeCheck, esStatusNodeCheck); + } + + @Bean + public LivenessController livenessController(LivenessChecker livenessChecker, UserSession userSession, SystemPasscode systemPasscode) { + return new DefautLivenessController(livenessChecker, systemPasscode, userSession); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java new file mode 100644 index 00000000000..9fb7f717c6a --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java @@ -0,0 +1,47 @@ +/* + * 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.v2.config; + +import org.sonar.server.health.DbConnectionNodeCheck; +import org.sonar.server.platform.ws.LivenessChecker; +import org.sonar.server.platform.ws.SafeModeLivenessCheckerImpl; +import org.sonar.server.user.SystemPasscode; +import org.sonar.server.v2.controller.DefautLivenessController; +import org.sonar.server.v2.controller.LivenessController; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@Configuration +@EnableWebMvc +@Import(CommonWebConfig.class) +public class SafeModeWebConfig { + + @Bean + public LivenessChecker livenessChecker(DbConnectionNodeCheck dbConnectionNodeCheck) { + return new SafeModeLivenessCheckerImpl(dbConnectionNodeCheck); + } + + @Bean + public LivenessController livenessController(LivenessChecker livenessChecker, SystemPasscode systemPasscode) { + return new DefautLivenessController(livenessChecker, systemPasscode, null); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java new file mode 100644 index 00000000000..8641f7f8811 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java @@ -0,0 +1,59 @@ +/* + * 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.v2.controller; + +import javax.annotation.Nullable; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.platform.ws.LivenessChecker; +import org.sonar.server.user.SystemPasscode; +import org.sonar.server.user.UserSession; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class DefautLivenessController implements LivenessController { + + private final LivenessChecker livenessChecker; + private final UserSession userSession; + private final SystemPasscode systemPasscode; + + public DefautLivenessController(LivenessChecker livenessChecker, SystemPasscode systemPasscode, @Nullable UserSession userSession) { + this.livenessChecker = livenessChecker; + this.userSession = userSession; + this.systemPasscode = systemPasscode; + } + + @Override + public void livenessCheck(String requestPassCode) { + if (systemPasscode.isValidPasscode(requestPassCode) || isSystemAdmin()) { + if (livenessChecker.liveness()) { + return; + } + throw new IllegalStateException("Liveness check failed"); + } + throw new ForbiddenException("Insufficient privileges"); + } + + private boolean isSystemAdmin() { + if (userSession == null) { + return false; + } + return userSession.isSystemAdministrator(); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java new file mode 100644 index 00000000000..3b4daf00620 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java @@ -0,0 +1,52 @@ +/* + * 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.v2.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; + +import static org.sonar.server.v2.WebApiEndpoints.LIVENESS_ENDPOINT; + +@RequestMapping(LIVENESS_ENDPOINT) +public interface LivenessController { + + @GetMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Provide liveness of SonarQube, meant to be used as a liveness probe on Kubernetes", description = """ + Require 'Administer System' permission or authentication with passcode. + + When SonarQube is fully started, liveness check for database connectivity, Compute Engine status, and, except for DataCenter Edition, if ElasticSearch is Green or Yellow. + + When SonarQube is on Safe Mode (for example when a database migration is running), liveness check only for database connectivity + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "This SonarQube node is alive"), + @ApiResponse(description = "This SonarQube node is not alive and should be rescheduled"), + }) + void livenessCheck( + @Parameter(description = "Passcode can be provided, see SonarQube documentation") @RequestHeader(value = "X-Sonar-Passcode", required = false) String requestPassCode); +} diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java new file mode 100644 index 00000000000..c7c4762466b --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java @@ -0,0 +1,100 @@ +/* + * 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.v2.controller; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.server.platform.ws.LivenessChecker; +import org.sonar.server.user.SystemPasscode; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.common.RestResponseEntityExceptionHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.when; +import static org.sonar.server.v2.WebApiEndpoints.LIVENESS_ENDPOINT; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(MockitoJUnitRunner.class) +public class DefautLivenessControllerTest { + + private static final String PASSCODE = "1234"; + @Mock + private LivenessChecker livenessChecker; + @Mock + private UserSession userSession; + @Mock + private SystemPasscode systemPasscode; + @InjectMocks + private DefautLivenessController defautLivenessController; + + MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = MockMvcBuilders.standaloneSetup(defautLivenessController) + .setControllerAdvice(RestResponseEntityExceptionHandler.class) + .build(); + } + + @Test + public void livenessCheck_should_returnForbiddenWithNoCredentials() throws Exception { + mockMvc.perform(get(LIVENESS_ENDPOINT)) + .andExpect(status().isForbidden()); + } + + @Test + public void livenessCheck_should_returnForbiddenWithWrongPasscodeAndNoAdminCredentials() throws Exception { + when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(false); + when(userSession.isSystemAdministrator()).thenReturn(false); + mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + .andExpect(status().isForbidden()); + } + + @Test + public void livenessCheck_should_returnNoContentWithSystemPasscode() throws Exception { + when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true); + when(livenessChecker.liveness()).thenReturn(true); + mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + .andExpect(status().isNoContent()); + } + + @Test + public void livenessCheck_should_returnNoContentWithWhenUserIsAdmin() throws Exception { + when(userSession.isSystemAdministrator()).thenReturn(true); + when(livenessChecker.liveness()).thenReturn(true); + mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + .andExpect(status().isNoContent()); + } + + @Test + public void livenessCheck_should_returnServerErrorWhenLivenessCheckFails() throws Exception { + when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true); + when(livenessChecker.liveness()).thenReturn(false); + mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + .andExpect(status().isInternalServerError()); + } + +} diff --git a/server/sonar-webserver-webapi/build.gradle b/server/sonar-webserver-webapi/build.gradle index b8ee1eebe89..b891af08f70 100644 --- a/server/sonar-webserver-webapi/build.gradle +++ b/server/sonar-webserver-webapi/build.gradle @@ -13,6 +13,12 @@ dependencies { api 'io.prometheus:simpleclient_common' api 'io.prometheus:simpleclient_servlet' + api 'org.springframework:spring-webmvc:5.3.23' + api 'org.springframework:spring-web:5.3.23' + api 'org.springframework:spring-context:5.3.23' + api 'org.springframework:spring-core:5.3.23' + testImplementation 'org.springframework:spring-test:5.3.23' + api project(':server:sonar-ce-common') api project(':server:sonar-ce-task') api project(':server:sonar-db-dao') @@ -38,6 +44,7 @@ dependencies { testImplementation 'com.squareup.okhttp3:mockwebserver' testImplementation 'javax.servlet:javax.servlet-api' testImplementation 'org.mockito:mockito-core' + testImplementation 'org.springframework:spring-test:5.3.23' testImplementation testFixtures(project(':server:sonar-server-common')) testImplementation testFixtures(project(':server:sonar-webserver-auth')) testImplementation testFixtures(project(':server:sonar-webserver-es')) @@ -45,4 +52,3 @@ dependencies { testImplementation project(':sonar-testing-harness') testFixturesApi testFixtures(project(':server:sonar-db-dao')) } - diff --git a/server/sonar-webserver/build.gradle b/server/sonar-webserver/build.gradle index 0c24dec1822..f5cb9fe1c87 100644 --- a/server/sonar-webserver/build.gradle +++ b/server/sonar-webserver/build.gradle @@ -21,6 +21,7 @@ dependencies { api project(':server:sonar-process') api project(':server:sonar-webserver-core') api project(':server:sonar-webserver-webapi') + api project(':server:sonar-webserver-webapi-v2') api project(':server:sonar-webserver-pushapi') api project(':server:sonar-webserver-monitoring') diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/PlatformImpl.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/PlatformImpl.java index 15755a9866e..a89de3719d8 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/PlatformImpl.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/PlatformImpl.java @@ -25,11 +25,13 @@ import java.util.List; import java.util.Properties; import javax.annotation.Nullable; import javax.servlet.ServletContext; +import javax.servlet.ServletRegistration; import org.sonar.api.utils.log.Logger; import org.sonar.api.utils.log.Loggers; import org.sonar.api.utils.log.Profiler; import org.sonar.core.platform.ExtensionContainer; import org.sonar.core.platform.SpringComponentContainer; +import org.sonar.server.platform.web.ApiV2Servlet; import org.sonar.server.app.ProcessCommandWrapper; import org.sonar.server.platform.db.migration.version.DatabaseVersion; import org.sonar.server.platform.platformlevel.PlatformLevel; @@ -39,6 +41,7 @@ import org.sonar.server.platform.platformlevel.PlatformLevel3; import org.sonar.server.platform.platformlevel.PlatformLevel4; import org.sonar.server.platform.platformlevel.PlatformLevelSafeMode; import org.sonar.server.platform.platformlevel.PlatformLevelStartup; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; import static org.sonar.process.ProcessId.WEB_SERVER; @@ -60,10 +63,12 @@ public class PlatformImpl implements Platform { private PlatformLevel level3 = null; private PlatformLevel level4 = null; private PlatformLevel currentLevel = null; + private AnnotationConfigWebApplicationContext springMvcContext = null; private boolean dbConnected = false; private boolean started = false; private final List level4AddedComponents = new ArrayList<>(); private final Profiler profiler = Profiler.createIfTrace(Loggers.get(PlatformImpl.class)); + private ApiV2Servlet servlet; public static PlatformImpl getInstance() { return INSTANCE; @@ -89,6 +94,10 @@ public class PlatformImpl implements Platform { boolean dbRequiredMigration = dbRequiresMigration(); startSafeModeContainer(); currentLevel = levelSafeMode; + if (!started) { + registerSpringMvcServlet(); + this.servlet.initDispatcherSafeMode(levelSafeMode); + } started = true; // if AutoDbMigration kicked in or no DB migration was required, startup can be resumed in another thread @@ -96,6 +105,7 @@ public class PlatformImpl implements Platform { LOGGER.info("Database needs to be migrated. Please refer to https://docs.sonarqube.org/latest/setup/upgrading"); } else { this.autoStarter = createAutoStarter(); + this.autoStarter.execute(new AutoStarterRunnable(autoStarter) { @Override public void doRun() { @@ -104,7 +114,9 @@ public class PlatformImpl implements Platform { } runIfNotAborted(PlatformImpl.this::startLevel34Containers); + runIfNotAborted(()->servlet.initDispatcherLevel4(level4)); runIfNotAborted(PlatformImpl.this::executeStartupTasks); + // switch current container last to avoid giving access to a partially initialized container runIfNotAborted(() -> { currentLevel = level4; @@ -118,6 +130,13 @@ public class PlatformImpl implements Platform { } } + private void registerSpringMvcServlet() { + servlet = new ApiV2Servlet(); + ServletRegistration.Dynamic app = this.servletContext.addServlet("app", servlet); + app.addMapping("/api/v2/*"); + app.setLoadOnStartup(1); + } + private AutoStarter createAutoStarter() { ProcessCommandWrapper processCommandWrapper = getContainer().getComponentByType(ProcessCommandWrapper.class); return new AsynchronousAutoStarter(processCommandWrapper); @@ -177,6 +196,7 @@ public class PlatformImpl implements Platform { level4 = start(new PlatformLevel4(level3, level4AddedComponents)); } + private void executeStartupTasks() { new PlatformLevelStartup(level4) .configure() diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/ApiV2Servlet.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/ApiV2Servlet.java new file mode 100644 index 00000000000..9603c2d70a8 --- /dev/null +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/ApiV2Servlet.java @@ -0,0 +1,130 @@ +/* + * 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.platform.web; + +import com.google.common.annotations.VisibleForTesting; +import java.io.IOException; +import java.util.function.Function; +import javax.servlet.Servlet; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletResponse; +import org.sonar.server.platform.platformlevel.PlatformLevel; +import org.sonar.server.v2.config.PlatformLevel4WebConfig; +import org.sonar.server.v2.config.SafeModeWebConfig; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; + +public class ApiV2Servlet implements Servlet { + public static final String SERVLET_NAME = "WebAPI V2 Servlet"; + private Function servletProvider; + private ServletConfig config = null; + private DispatcherServlet dispatcherLevel4 = null; + private DispatcherServlet dispatcherSafeMode = null; + + public ApiV2Servlet() { + this.servletProvider = DispatcherServlet::new; + } + + @VisibleForTesting + void setServletProvider(Function servletProvider) { + this.servletProvider = servletProvider; + } + + @Override + public String getServletInfo() { + return SERVLET_NAME; + } + + @Override + public ServletConfig getServletConfig() { + return config; + } + + @Override + public void init(ServletConfig config) throws ServletException { + this.config = config; + if (dispatcherLevel4 != null) { + dispatcherLevel4.init(config); + } + if (dispatcherSafeMode != null) { + dispatcherSafeMode.init(config); + } + } + + public void initDispatcherSafeMode(PlatformLevel platformLevel) { + this.dispatcherSafeMode = initDispatcherServlet(platformLevel, SafeModeWebConfig.class); + } + + public void initDispatcherLevel4(PlatformLevel platformLevel) { + dispatcherLevel4 = initDispatcherServlet(platformLevel, PlatformLevel4WebConfig.class); + destroyDispatcherSafeMode(); + } + + private DispatcherServlet initDispatcherServlet(PlatformLevel platformLevel, Class configClass) { + AnnotationConfigWebApplicationContext springMvcContext = new AnnotationConfigWebApplicationContext(); + springMvcContext.setParent(platformLevel.getContainer().context()); + springMvcContext.register(configClass); + DispatcherServlet dispatcher = servletProvider.apply(springMvcContext); + try { + if (config != null) { + dispatcher.init(config); + } + } catch (ServletException e) { + throw new RuntimeException(e); + } + return dispatcher; + } + + @Override + public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException { + if (dispatcherSafeMode != null) { + dispatcherSafeMode.service(req, res); + } else if (dispatcherLevel4 != null) { + dispatcherLevel4.service(req, res); + } else { + HttpServletResponse httpResponse = (HttpServletResponse) res; + httpResponse.sendError(HttpServletResponse.SC_NOT_FOUND); + } + } + + @Override + public void destroy() { + destroyDispatcherSafeMode(); + destroyLevel4(); + } + + private void destroyDispatcherSafeMode() { + if (dispatcherSafeMode != null) { + DispatcherServlet dispatcherToDestroy = dispatcherSafeMode; + dispatcherSafeMode = null; + dispatcherToDestroy.destroy(); + } + } + + private void destroyLevel4() { + if (dispatcherLevel4 != null) { + dispatcherLevel4.destroy(); + } + } +} diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/SonarLintConnectionFilter.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/SonarLintConnectionFilter.java index 17e01a263ad..e5ba6d2aca9 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/SonarLintConnectionFilter.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/SonarLintConnectionFilter.java @@ -38,6 +38,7 @@ import static java.util.concurrent.TimeUnit.HOURS; public class SonarLintConnectionFilter extends ServletFilter { private static final UrlPattern URL_PATTERN = UrlPattern.builder() .includes("/api/*") + .excludes("/api/v2/*") .build(); private final DbClient dbClient; private final ThreadLocalUserSession userSession; diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java index 54ea6cacd73..5e32a354f14 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java @@ -51,6 +51,7 @@ public class WebPagesFilter implements Filter { private static final ServletFilter.UrlPattern URL_PATTERN = ServletFilter.UrlPattern .builder() .excludes(staticResourcePatterns()) + .excludes("/api/v2/*") .build(); private WebPagesCache webPagesCache; diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebServiceFilter.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebServiceFilter.java index 4660820851d..5d04f9e0381 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebServiceFilter.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/WebServiceFilter.java @@ -19,6 +19,7 @@ */ package org.sonar.server.platform.web; +import java.util.HashSet; import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; @@ -34,6 +35,7 @@ import org.sonar.server.ws.ServletRequest; import org.sonar.server.ws.ServletResponse; import org.sonar.server.ws.WebServiceEngine; +import static java.util.stream.Collectors.toCollection; import static java.util.stream.Stream.concat; import static org.sonar.server.platform.web.WebServiceReroutingFilter.MOVED_WEB_SERVICES; @@ -63,8 +65,8 @@ public class WebServiceFilter extends ServletFilter { webServiceEngine.controllers().stream() .flatMap(controller -> controller.actions().stream()) .filter(action -> action.handler() instanceof ServletFilterHandler) - .map(toPath())) - .collect(MoreCollectors.toSet()); + .map(toPath())).collect(toCollection(HashSet::new)); + excludeUrls.add("/api/v2/*"); } @Override diff --git a/server/sonar-webserver/src/test/java/org/sonar/server/platform/web/ApiV2ServletTest.java b/server/sonar-webserver/src/test/java/org/sonar/server/platform/web/ApiV2ServletTest.java new file mode 100644 index 00000000000..14f64c3599c --- /dev/null +++ b/server/sonar-webserver/src/test/java/org/sonar/server/platform/web/ApiV2ServletTest.java @@ -0,0 +1,194 @@ +/* + * 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.platform.web; + +import java.io.IOException; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletResponse; +import org.junit.Test; +import org.sonar.core.platform.SpringComponentContainer; +import org.sonar.server.platform.platformlevel.PlatformLevel; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; + +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ApiV2ServletTest { + + @Test + public void getServletConfig_shouldReturnServletConfig() throws ServletException { + ApiV2Servlet underTest = new ApiV2Servlet(); + ServletConfig mockServletConfig = mock(ServletConfig.class); + underTest.init(mockServletConfig); + + assertThat(underTest.getServletConfig()).isEqualTo(mockServletConfig); + } + + @Test + public void getServletInfo_shouldReturnOwnDefinedServletName() { + ApiV2Servlet underTest = new ApiV2Servlet(); + + assertThat(underTest.getServletInfo()) + .isEqualTo(ApiV2Servlet.SERVLET_NAME); + } + + @Test + public void init_shouldInitDispatcherSafeModeConfig_whenDispatcherHadBeenInitWithoutConfig() throws ServletException { + ApiV2Servlet underTest = new ApiV2Servlet(); + DispatcherServlet mockDispatcherServletSafeMode = mock(DispatcherServlet.class); + underTest.setServletProvider(context -> mockDispatcherServletSafeMode); + PlatformLevel mockPlatformLevel = getMockPlatformLevel(); + underTest.initDispatcherSafeMode(mockPlatformLevel); + + ServletConfig mockServletConfig = mock(ServletConfig.class); + underTest.init(mockServletConfig); + + verify(mockDispatcherServletSafeMode, times(1)).init(mockServletConfig); + } + + @Test + public void init_shouldInitDispatcherLevel4Config_whenDispatcherHadBeenInitWithoutConfig() throws ServletException { + PlatformLevel mockPlatformLevel = getMockPlatformLevel(); + ApiV2Servlet underTest = new ApiV2Servlet(); + + DispatcherServlet mockDispatcherServletLevel4 = mock(DispatcherServlet.class); + underTest.setServletProvider(context -> mockDispatcherServletLevel4); + underTest.initDispatcherLevel4(mockPlatformLevel); + + ServletConfig mockServletConfig = mock(ServletConfig.class); + + underTest.init(mockServletConfig); + + verify(mockDispatcherServletLevel4, times(1)).init(mockServletConfig); + } + + @Test + public void service_shouldDispatchOnRightDispatcher() throws ServletException, IOException { + PlatformLevel mockPlatformLevel = getMockPlatformLevel(); + ApiV2Servlet underTest = new ApiV2Servlet(); + DispatcherServlet mockDispatcherServletSafeMode = mock(DispatcherServlet.class); + DispatcherServlet mockDispatcherServletLevel4 = mock(DispatcherServlet.class); + + underTest.setServletProvider(context -> mockDispatcherServletSafeMode); + underTest.init(mock(ServletConfig.class)); + underTest.initDispatcherSafeMode(mockPlatformLevel); + ServletRequest mockRequest1 = mock(ServletRequest.class); + ServletResponse mockResponse1 = mock(ServletResponse.class); + underTest.service(mockRequest1, mockResponse1); + verify(mockDispatcherServletSafeMode, times(1)).service(mockRequest1, mockResponse1); + + underTest.setServletProvider(context -> mockDispatcherServletLevel4); + underTest.initDispatcherLevel4(mockPlatformLevel); + ServletRequest mockRequest2 = mock(ServletRequest.class); + ServletResponse mockResponse2 = mock(ServletResponse.class); + underTest.service(mockRequest2, mockResponse2); + verify(mockDispatcherServletLevel4, times(1)).service(mockRequest2, mockResponse2); + } + + @Test + public void service_shouldReturnNotFound_whenDispatchersAreNotAvailable() throws ServletException, IOException { + ApiV2Servlet underTest = new ApiV2Servlet(); + HttpServletResponse mockResponse = mock(HttpServletResponse.class); + + underTest.service(mock(ServletRequest.class), mockResponse); + + verify(mockResponse, times(1)).sendError(SC_NOT_FOUND); + } + + @Test + public void initDispatcherServlet_shouldThrowRuntimeException_whenDispatcherInitFails() throws ServletException { + PlatformLevel mockPlatformLevel = getMockPlatformLevel(); + ApiV2Servlet underTest = new ApiV2Servlet(); + ServletConfig mockServletConfig = mock(ServletConfig.class); + String exceptionMessage = "Exception message"; + + DispatcherServlet mockDispatcherServletSafeMode = mock(DispatcherServlet.class); + doThrow(new ServletException(exceptionMessage)).when(mockDispatcherServletSafeMode).init(mockServletConfig); + + underTest.setServletProvider(context -> mockDispatcherServletSafeMode); + underTest.init(mockServletConfig); + + assertThatThrownBy(() -> underTest.initDispatcherSafeMode(mockPlatformLevel)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining(exceptionMessage); + } + + @Test + public void initDispatcherServlet_initLevel4ShouldDestroySafeMode() { + PlatformLevel mockPlatformLevel = getMockPlatformLevel(); + ApiV2Servlet underTest = new ApiV2Servlet(); + + DispatcherServlet mockDispatcherServletSafeMode = mock(DispatcherServlet.class); + underTest.setServletProvider(context -> mockDispatcherServletSafeMode); + underTest.initDispatcherSafeMode(mockPlatformLevel); + + underTest.setServletProvider(context -> mock(DispatcherServlet.class)); + + underTest.initDispatcherLevel4(mockPlatformLevel); + + verify(mockDispatcherServletSafeMode, times(1)).destroy(); + } + + @Test + public void destroy_shouldDestroyDispatcherLevel4() { + PlatformLevel mockPlatformLevel = getMockPlatformLevel(); + ApiV2Servlet underTest = new ApiV2Servlet(); + DispatcherServlet mockDispatcherServletLevel4 = mock(DispatcherServlet.class); + + underTest.setServletProvider(context -> mockDispatcherServletLevel4); + underTest.initDispatcherLevel4(mockPlatformLevel); + + underTest.destroy(); + + verify(mockDispatcherServletLevel4, times(1)).destroy(); + } + + @Test + public void destroy_shouldDestroyDispatcherSafeMode() { + PlatformLevel mockPlatformLevel = getMockPlatformLevel(); + ApiV2Servlet underTest = new ApiV2Servlet(); + DispatcherServlet mockDispatcherServletSafeMode = mock(DispatcherServlet.class); + + underTest.setServletProvider(context -> mockDispatcherServletSafeMode); + underTest.initDispatcherSafeMode(mockPlatformLevel); + + underTest.destroy(); + + verify(mockDispatcherServletSafeMode, times(1)).destroy(); + } + + private static PlatformLevel getMockPlatformLevel() { + SpringComponentContainer mockSpringComponentContainer = mock(SpringComponentContainer.class); + when(mockSpringComponentContainer.context()).thenReturn(new AnnotationConfigApplicationContext()); + PlatformLevel mockPlatformLevel = mock(PlatformLevel.class); + when(mockPlatformLevel.getContainer()).thenReturn(mockSpringComponentContainer); + return mockPlatformLevel; + } +} diff --git a/settings.gradle b/settings.gradle index 5421562cd92..5732248f08b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -38,6 +38,7 @@ include 'server:sonar-webserver-auth' include 'server:sonar-webserver-core' include 'server:sonar-webserver-es' include 'server:sonar-webserver-webapi' +include 'server:sonar-webserver-webapi-v2' include 'server:sonar-webserver-pushapi' include 'server:sonar-webserver-ws' include 'server:sonar-alm-client' diff --git a/sonar-application/build.gradle b/sonar-application/build.gradle index 54e2ac87de7..94defd62f61 100644 --- a/sonar-application/build.gradle +++ b/sonar-application/build.gradle @@ -43,7 +43,7 @@ jar.enabled = false shadowJar { archiveBaseName = 'sonar-application' archiveClassifier = null - mergeServiceFiles() + mergeServiceFiles('META-INF/spring.*') manifest { attributes('Main-Class': 'org.sonar.application.App') } -- 2.39.5