// 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'
+ testImplementation 'org.skyscreamer:jsonassert:1.5.1'
testImplementation testFixtures(project(':server:sonar-server-common'))
package org.sonar.server.v2;
public class WebApiEndpoints {
+ private static final String SYSTEM_ENDPOINTS = "/system/";
- public static final String LIVENESS_ENDPOINT = "/system/liveness";
+ public static final String LIVENESS_ENDPOINT = SYSTEM_ENDPOINTS + "/liveness";
+
+ public static final String HEALTH_ENDPOINT = SYSTEM_ENDPOINTS + "/health";
private WebApiEndpoints() {
}
import org.sonar.server.health.CeStatusNodeCheck;
import org.sonar.server.health.DbConnectionNodeCheck;
import org.sonar.server.health.EsStatusNodeCheck;
+import org.sonar.server.health.HealthChecker;
import org.sonar.server.health.WebServerStatusNodeCheck;
+import org.sonar.server.platform.NodeInformation;
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.HealthController;
import org.sonar.server.v2.controller.LivenessController;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
public LivenessController livenessController(LivenessChecker livenessChecker, UserSession userSession, SystemPasscode systemPasscode) {
return new DefautLivenessController(livenessChecker, systemPasscode, userSession);
}
+
+ @Bean
+ public HealthController healthController(HealthChecker healthChecker, SystemPasscode systemPasscode, NodeInformation nodeInformation,
+ UserSession userSession) {
+ return new HealthController(healthChecker, systemPasscode, nodeInformation, userSession);
+ }
}
package org.sonar.server.v2.config;
import org.sonar.server.health.DbConnectionNodeCheck;
+import org.sonar.server.health.HealthChecker;
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.HealthController;
import org.sonar.server.v2.controller.LivenessController;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
public LivenessController livenessController(LivenessChecker livenessChecker, SystemPasscode systemPasscode) {
return new DefautLivenessController(livenessChecker, systemPasscode, null);
}
+
+ @Bean
+ public HealthController healthController(HealthChecker healthChecker, SystemPasscode systemPasscode) {
+ return new HealthController(healthChecker, systemPasscode);
+ }
}
--- /dev/null
+/*
+ * 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.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.ServerException;
+import org.sonar.server.health.Health;
+import org.sonar.server.health.HealthChecker;
+import org.sonar.server.platform.NodeInformation;
+import org.sonar.server.user.SystemPasscode;
+import org.sonar.server.user.UserSession;
+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.RestController;
+
+import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED;
+
+/*
+This controller does not support the cluster mode.
+This is not the final implementation, as we have to first define what are endpoint contracts.
+*/
+@RestController
+@RequestMapping("/system/health")
+public class HealthController {
+
+ private final HealthChecker healthChecker;
+ private final SystemPasscode systemPasscode;
+ private final NodeInformation nodeInformation;
+ private final UserSession userSession;
+
+ public HealthController(HealthChecker healthChecker, SystemPasscode systemPasscode, NodeInformation nodeInformation,
+ UserSession userSession) {
+ this.healthChecker = healthChecker;
+ this.systemPasscode = systemPasscode;
+ this.nodeInformation = nodeInformation;
+ this.userSession = userSession;
+ }
+
+ public HealthController(HealthChecker healthChecker, SystemPasscode systemPasscode) {
+ this(healthChecker, systemPasscode, null, null);
+ }
+
+ @GetMapping
+ public Health getHealth(@RequestHeader(value = "X-Sonar-Passcode", required = false) String requestPassCode) {
+ if (systemPasscode.isValidPasscode(requestPassCode) || isSystemAdmin()) {
+ return getHealth();
+ }
+ throw new ForbiddenException("Insufficient privileges");
+ }
+
+ private Health getHealth() {
+ if (nodeInformation == null || nodeInformation.isStandalone()) {
+ return healthChecker.checkNode();
+ }
+ throw new ServerException(HTTP_NOT_IMPLEMENTED, "Unsupported in cluster mode");
+ }
+
+ private boolean isSystemAdmin() {
+ if (userSession == null) {
+ return false;
+ }
+ return userSession.isSystemAdministrator();
+ }
+}
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.when;
+import static org.sonar.server.user.SystemPasscodeImpl.PASSCODE_HTTP_HEADER;
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;
@InjectMocks
private DefautLivenessController defautLivenessController;
- MockMvc mockMvc;
+ private MockMvc mockMvc;
@Before
public void setUp() {
- this.mockMvc = MockMvcBuilders.standaloneSetup(defautLivenessController)
+ mockMvc = MockMvcBuilders.standaloneSetup(defautLivenessController)
.setControllerAdvice(RestResponseEntityExceptionHandler.class)
.build();
}
+ @Test
+ public void livenessCheck_inSafeModeWithoutUserSessionAndPasscode_returnsForbidden() throws Exception {
+ LivenessController safeModeLivenessController = new DefautLivenessController(livenessChecker, systemPasscode, null);
+ MockMvc mockMvcSafeMode = MockMvcBuilders.standaloneSetup(safeModeLivenessController)
+ .setControllerAdvice(RestResponseEntityExceptionHandler.class)
+ .build();
+ mockMvcSafeMode.perform(get(LIVENESS_ENDPOINT))
+ .andExpect(status().isForbidden());
+ }
+
@Test
public void livenessCheck_should_returnForbiddenWithNoCredentials() throws Exception {
mockMvc.perform(get(LIVENESS_ENDPOINT))
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))
+ mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
.andExpect(status().isForbidden());
}
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))
+ mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
.andExpect(status().isNoContent());
}
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))
+ mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
.andExpect(status().isNoContent());
}
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))
+ mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
.andExpect(status().isInternalServerError());
}
--- /dev/null
+/*
+ * 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.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.sonar.server.exceptions.UnauthorizedException;
+import org.sonar.server.health.Health;
+import org.sonar.server.health.HealthChecker;
+import org.sonar.server.platform.NodeInformation;
+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.user.SystemPasscodeImpl.PASSCODE_HTTP_HEADER;
+import static org.sonar.server.v2.WebApiEndpoints.HEALTH_ENDPOINT;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@RunWith(MockitoJUnitRunner.class)
+public class HealthControllerTest {
+
+ private static final String PASSCODE = "1234";
+ private static final Health HEALTH_RESULT = Health.builder().
+ setStatus(Health.Status.YELLOW)
+ .addCause("One cause")
+ .build();
+ @Mock
+ private HealthChecker healthChecker;
+ @Mock
+ private UserSession userSession;
+ @Mock
+ private SystemPasscode systemPasscode;
+ @Mock
+ private NodeInformation nodeInformation;
+
+ private HealthController level4HealthController;
+
+ private HealthController safeModeHealthController;
+
+ private MockMvc level4mockMvc;
+
+ private MockMvc safeModeMockMvc;
+
+ @Before
+ public void setUp() {
+ level4HealthController = new HealthController(healthChecker, systemPasscode, nodeInformation, userSession);
+ level4mockMvc = MockMvcBuilders.standaloneSetup(level4HealthController)
+ .setControllerAdvice(RestResponseEntityExceptionHandler.class)
+ .build();
+
+ safeModeHealthController = new HealthController(healthChecker, systemPasscode);
+ safeModeMockMvc = MockMvcBuilders.standaloneSetup(safeModeHealthController)
+ .setControllerAdvice(RestResponseEntityExceptionHandler.class)
+ .build();
+
+ when(healthChecker.checkNode()).thenReturn(HEALTH_RESULT);
+ }
+
+ @Test
+ public void getHealth_inSafeModeWithoutUserSessionAndPasscode_returnsForbidden() throws Exception {
+ safeModeMockMvc.perform(get(HEALTH_ENDPOINT))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ public void getHealth_inSafeModeWithValidPasscode_succeeds() throws Exception {
+ when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true);
+
+ safeModeMockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void getHealth_should_returnForbiddenWithNoCredentials() throws Exception {
+ level4mockMvc.perform(get(HEALTH_ENDPOINT))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ public void getHealth_should_returnForbiddenWithWrongPasscodeAndNoAdminCredentials() throws Exception {
+ when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(false);
+ when(userSession.isSystemAdministrator()).thenReturn(false);
+ level4mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ public void getHealth_withValidPasscodeAndStandaloneNode_returnHealth() throws Exception {
+ when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true);
+ when(nodeInformation.isStandalone()).thenReturn(true);
+ level4mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
+ .andExpect(status().isOk())
+ .andExpect(content().json("""
+ {
+ "status":"YELLOW",
+ "causes":[
+ "One cause"
+ ]
+ }"""));
+ }
+
+ @Test
+ public void getHealth_asSysadminAndStandaloneNode_returnHealth() throws Exception {
+ when(userSession.isSystemAdministrator()).thenReturn(true);
+ when(nodeInformation.isStandalone()).thenReturn(true);
+ level4mockMvc.perform(get(HEALTH_ENDPOINT))
+ .andExpect(status().isOk())
+ .andExpect(content().json("""
+ {
+ "status":"YELLOW",
+ "causes":[
+ "One cause"
+ ]
+ }"""));
+ }
+
+ @Test
+ public void getHealth_whenUnauthorizedExceptionThrown_returnHttpUnauthorized() throws Exception {
+ when(userSession.isSystemAdministrator()).thenThrow(new UnauthorizedException("unauthorized"));
+ level4mockMvc.perform(get(HEALTH_ENDPOINT))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ public void getHealth_should_returnServerErrorForCluster() throws Exception {
+ when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true);
+ when(nodeInformation.isStandalone()).thenReturn(false);
+ level4mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE))
+ .andExpect(status().isNotImplemented())
+ .andExpect(content().string("Unsupported in cluster mode"));
+ }
+}