From 6eb221f58267f14231c80d7af131e01090690375 Mon Sep 17 00:00:00 2001 From: Simon Brandhof Date: Fri, 11 Nov 2016 11:48:23 +0100 Subject: [PATCH] SONAR-8349 add integration tests --- .../src/test/java/it/Category3Suite.java | 4 +- .../test/java/it/webhook/ExternalServer.java | 97 ++++++ .../test/java/it/webhook/PayloadRequest.java | 51 +++ .../test/java/it/webhook/WebhooksTest.java | 290 ++++++++++++++++++ 4 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 it/it-tests/src/test/java/it/webhook/ExternalServer.java create mode 100644 it/it-tests/src/test/java/it/webhook/PayloadRequest.java create mode 100644 it/it-tests/src/test/java/it/webhook/WebhooksTest.java diff --git a/it/it-tests/src/test/java/it/Category3Suite.java b/it/it-tests/src/test/java/it/Category3Suite.java index 99f74cc3909..198153235d7 100644 --- a/it/it-tests/src/test/java/it/Category3Suite.java +++ b/it/it-tests/src/test/java/it/Category3Suite.java @@ -36,6 +36,7 @@ import it.analysis.TempFolderTest; import it.measure.DecimalScaleMetricTest; import it.organization.OrganizationIt; import it.root.RootIt; +import it.webhook.WebhooksTest; import org.junit.ClassRule; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -64,7 +65,8 @@ import static util.ItUtils.xooPlugin; // organization OrganizationIt.class, // root users - RootIt.class + RootIt.class, + WebhooksTest.class }) public class Category3Suite { diff --git a/it/it-tests/src/test/java/it/webhook/ExternalServer.java b/it/it-tests/src/test/java/it/webhook/ExternalServer.java new file mode 100644 index 00000000000..20296319581 --- /dev/null +++ b/it/it-tests/src/test/java/it/webhook/ExternalServer.java @@ -0,0 +1,97 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact 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 it.webhook; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.junit.rules.ExternalResource; + +/** + * This web server listens to requests sent by webhooks + */ +class ExternalServer extends ExternalResource { + private final Server jetty; + private final List payloads = new ArrayList<>(); + + ExternalServer() { + jetty = new Server(0); + jetty.setHandler(new AbstractHandler() { + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + + if ("POST".equalsIgnoreCase(request.getMethod())) { + String json = request.getReader().lines().collect(Collectors.joining(System.lineSeparator())); + Map httpHeaders = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String key = headerNames.nextElement(); + httpHeaders.put(key, request.getHeader(key)); + } + payloads.add(new PayloadRequest(target, httpHeaders, json)); + } + + response.setStatus(target.equals("/fail") ? 500 : 200); + baseRequest.setHandled(true); + } + }); + } + + @Override + protected void before() throws Throwable { + jetty.start(); + } + + @Override + protected void after() { + try { + jetty.stop(); + } catch (Exception e) { + throw new IllegalStateException("Cannot stop Jetty"); + } + } + + List getPayloadRequests() { + return payloads; + } + + List getPayloadRequestsOnPath(String path) { + return payloads.stream().filter(p -> p.getPath().equals(path)).collect(Collectors.toList()); + } + + String urlFor(String path) { + return jetty.getURI().resolve(path).toString(); + } + + void clear() { + payloads.clear(); + } +} diff --git a/it/it-tests/src/test/java/it/webhook/PayloadRequest.java b/it/it-tests/src/test/java/it/webhook/PayloadRequest.java new file mode 100644 index 00000000000..e0a1e9c5e06 --- /dev/null +++ b/it/it-tests/src/test/java/it/webhook/PayloadRequest.java @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact 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 it.webhook; + +import java.util.Map; + +import static java.util.Objects.requireNonNull; + +/** + * Request received by {@link ExternalServer} + */ +class PayloadRequest { + private final String path; + private final Map httpHeaders; + private final String json; + + PayloadRequest(String path, Map httpHeaders, String json) { + this.path = requireNonNull(path); + this.httpHeaders = requireNonNull(httpHeaders); + this.json = requireNonNull(json); + } + + Map getHttpHeaders() { + return httpHeaders; + } + + String getJson() { + return json; + } + + String getPath() { + return path; + } +} diff --git a/it/it-tests/src/test/java/it/webhook/WebhooksTest.java b/it/it-tests/src/test/java/it/webhook/WebhooksTest.java new file mode 100644 index 00000000000..e55390f017d --- /dev/null +++ b/it/it-tests/src/test/java/it/webhook/WebhooksTest.java @@ -0,0 +1,290 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact 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 it.webhook; + +import com.sonar.orchestrator.Orchestrator; +import it.Category3Suite; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.sonarqube.ws.Webhooks; +import org.sonarqube.ws.client.HttpException; +import org.sonarqube.ws.client.WsClient; +import org.sonarqube.ws.client.project.DeleteRequest; +import org.sonarqube.ws.client.setting.ResetRequest; +import org.sonarqube.ws.client.setting.SetRequest; +import org.sonarqube.ws.client.webhook.DeliveriesRequest; +import util.ItUtils; + +import static java.util.Objects.requireNonNull; +import static java.util.stream.IntStream.range; +import static org.assertj.core.api.Assertions.assertThat; +import static util.ItUtils.jsonToMap; +import static util.ItUtils.runProjectAnalysis; + +public class WebhooksTest { + + private static final String PROJECT_KEY = "my-project"; + private static final String PROJECT_NAME = "My Project"; + private static final String GLOBAL_WEBHOOK_PROPERTY = "sonar.webhooks.global"; + private static final String PROJECT_WEBHOOK_PROPERTY = "sonar.webhooks.project"; + + @ClassRule + public static Orchestrator orchestrator = Category3Suite.ORCHESTRATOR; + + @ClassRule + public static ExternalServer externalServer = new ExternalServer(); + + private WsClient adminWs = ItUtils.newAdminWsClient(orchestrator); + + @Before + public void setUp() throws Exception { + externalServer.clear(); + } + + @Before + @After + public void reset() throws Exception { + disableGlobalWebhooks(); + try { + // delete project and related properties/webhook deliveries + adminWs.projects().delete(DeleteRequest.builder().setKey(PROJECT_KEY).build()); + } catch (HttpException e) { + // ignore because project may not exist + } + } + + @Test + public void call_multiple_global_and_project_webhooks_when_analysis_is_done() { + orchestrator.getServer().provisionProject(PROJECT_KEY, PROJECT_NAME); + enableGlobalWebhooks( + new Webhook("Jenkins", externalServer.urlFor("/jenkins")), + new Webhook("HipChat", externalServer.urlFor("/hipchat"))); + enableProjectWebhooks(PROJECT_KEY, + new Webhook("Burgr", externalServer.urlFor("/burgr"))); + + analyseProject(); + + // the same payload has been sent to three servers + assertThat(externalServer.getPayloadRequests()).hasSize(3); + PayloadRequest request = externalServer.getPayloadRequests().get(0); + for (int i = 1; i < 3; i++) { + PayloadRequest r = externalServer.getPayloadRequests().get(i); + assertThat(request.getJson()).isEqualTo(r.getJson()); + } + + // verify HTTP headers + assertThat(request.getHttpHeaders().get("X-SonarQube-Project")).isEqualTo(PROJECT_KEY); + + // verify content of payload + Map payload = jsonToMap(request.getJson()); + assertThat(payload.get("status")).isEqualTo("SUCCESS"); + assertThat(payload.get("analysedAt")).isNotNull(); + Map project = (Map) payload.get("project"); + assertThat(project.get("key")).isEqualTo(PROJECT_KEY); + assertThat(project.get("name")).isEqualTo(PROJECT_NAME); + Map gate = (Map) payload.get("qualityGate"); + assertThat(gate.get("name")).isEqualTo("SonarQube way"); + assertThat(gate.get("status")).isEqualTo("OK"); + assertThat(gate.get("conditions")).isNotNull(); + + // verify list of persisted deliveries (api/webhooks/deliveries) + List deliveries = getPersistedDeliveries(); + assertThat(deliveries).hasSize(3); + for (Webhooks.Delivery delivery : deliveries) { + assertThatPersistedDeliveryIsValid(delivery); + assertThat(delivery.getSuccess()).isTrue(); + assertThat(delivery.getHttpStatus()).isEqualTo(200); + assertThat(delivery.getName()).isIn("Jenkins", "HipChat", "Burgr"); + assertThat(delivery.hasErrorStacktrace()).isFalse(); + // payload is available only in api/webhooks/delivery to avoid loading multiple DB CLOBs + assertThat(delivery.hasPayload()).isFalse(); + } + + // verify detail of persisted delivery (api/webhooks/delivery) + Webhooks.Delivery detail = getDetailOfPersistedDelivery(deliveries.get(0)); + assertThatPersistedDeliveryIsValid(detail); + assertThat(detail.getPayload()).isEqualTo(request.getJson()); + } + + @Test + public void persist_delivery_as_failed_if_external_server_returns_an_error_code() { + enableGlobalWebhooks( + new Webhook("Fail", externalServer.urlFor("/fail")), + new Webhook("HipChat", externalServer.urlFor("/hipchat"))); + + analyseProject(); + + // all webhooks are called, even if one returns an error code + assertThat(externalServer.getPayloadRequests()).hasSize(2); + + // verify persisted deliveries + Webhooks.Delivery failedDelivery = getPersistedDeliveryByName("Fail"); + assertThatPersistedDeliveryIsValid(failedDelivery); + assertThat(failedDelivery.getSuccess()).isFalse(); + assertThat(failedDelivery.getHttpStatus()).isEqualTo(500); + + Webhooks.Delivery successfulDelivery = getPersistedDeliveryByName("HipChat"); + assertThatPersistedDeliveryIsValid(successfulDelivery); + assertThat(successfulDelivery.getSuccess()).isTrue(); + assertThat(successfulDelivery.getHttpStatus()).isEqualTo(200); + } + + /** + * Restrict calls to ten webhooks per type (global or project) + */ + @Test + public void do_not_become_a_denial_of_service_attacker() { + orchestrator.getServer().provisionProject(PROJECT_KEY, PROJECT_NAME); + + List globalWebhooks = range(0, 15).mapToObj(i -> new Webhook("G" + i, externalServer.urlFor("/global"))).collect(Collectors.toList()); + enableGlobalWebhooks(globalWebhooks.toArray(new Webhook[globalWebhooks.size()])); + List projectWebhooks = range(0, 15).mapToObj(i -> new Webhook("P" + i, externalServer.urlFor("/project"))).collect(Collectors.toList()); + enableProjectWebhooks(PROJECT_KEY, projectWebhooks.toArray(new Webhook[projectWebhooks.size()])); + + analyseProject(); + + // only the first ten global webhooks and ten project webhooks are called + assertThat(externalServer.getPayloadRequests()).hasSize(10 + 10); + assertThat(externalServer.getPayloadRequestsOnPath("/global")).hasSize(10); + assertThat(externalServer.getPayloadRequestsOnPath("/project")).hasSize(10); + + // verify persisted deliveries + assertThat(getPersistedDeliveries()).hasSize(10 + 10); + } + + @Test + public void persist_delivery_as_failed_if_webhook_url_is_malformed() { + enableGlobalWebhooks(new Webhook("Jenkins", "this_is_not_an_url")); + + analyseProject(); + + assertThat(externalServer.getPayloadRequests()).isEmpty(); + + // verify persisted deliveries + Webhooks.Delivery delivery = getPersistedDeliveryByName("Jenkins"); + Webhooks.Delivery detail = getDetailOfPersistedDelivery(delivery); + + assertThat(detail.getSuccess()).isFalse(); + assertThat(detail.hasHttpStatus()).isFalse(); + assertThat(detail.hasDurationMs()).isFalse(); + assertThat(detail.getPayload()).isNotEmpty(); + assertThat(detail.getErrorStacktrace()) + .contains("java.lang.IllegalArgumentException") + .contains("unexpected url") + .contains("this_is_not_an_url"); + } + + @Test + public void ignore_webhook_if_url_is_missing() { + // property sets, as used to define webhooks, do + // not allow to validate values yet + enableGlobalWebhooks(new Webhook("Jenkins", null)); + + analyseProject(); + + assertThat(externalServer.getPayloadRequests()).isEmpty(); + assertThat(getPersistedDeliveries()).isEmpty(); + } + + private void analyseProject() { + runProjectAnalysis(orchestrator, "shared/xoo-sample", + "sonar.projectKey", PROJECT_KEY, + "sonar.projectName", PROJECT_NAME); + } + + private List getPersistedDeliveries() { + DeliveriesRequest deliveriesReq = DeliveriesRequest.builder().setComponentKey(PROJECT_KEY).build(); + return adminWs.webhooks().deliveries(deliveriesReq).getDeliveriesList(); + } + + private Webhooks.Delivery getPersistedDeliveryByName(String webhookName) { + List deliveries = getPersistedDeliveries(); + return deliveries.stream().filter(d -> d.getName().equals(webhookName)).findFirst().get(); + } + + private Webhooks.Delivery getDetailOfPersistedDelivery(Webhooks.Delivery delivery) { + Webhooks.Delivery detail = adminWs.webhooks().delivery(delivery.getId()).getDelivery(); + return requireNonNull(detail); + } + + private void assertThatPersistedDeliveryIsValid(Webhooks.Delivery delivery) { + assertThat(delivery.getId()).isNotEmpty(); + assertThat(delivery.getName()).isNotEmpty(); + assertThat(delivery.hasSuccess()).isTrue(); + assertThat(delivery.getHttpStatus()).isGreaterThanOrEqualTo(200); + assertThat(delivery.getDurationMs()).isGreaterThanOrEqualTo(0); + assertThat(delivery.getAt()).isNotEmpty(); + assertThat(delivery.getComponentKey()).isEqualTo(PROJECT_KEY); + assertThat(delivery.getUrl()).startsWith(externalServer.urlFor("/")); + } + + private void enableGlobalWebhooks(Webhook... webhooks) { + enableWebhooks(null, GLOBAL_WEBHOOK_PROPERTY, webhooks); + } + + private void enableProjectWebhooks(String projectKey, Webhook... webhooks) { + enableWebhooks(projectKey, PROJECT_WEBHOOK_PROPERTY, webhooks); + } + + private void enableWebhooks(@Nullable String projectKey, String property, Webhook... webhooks) { + List webhookIds = new ArrayList<>(); + for (int i = 0; i < webhooks.length; i++) { + Webhook webhook = webhooks[i]; + String id = String.valueOf(i + 1); + webhookIds.add(id); + setProperty(projectKey, property + "." + id + ".name", webhook.name); + setProperty(projectKey, property + "." + id + ".url", webhook.url); + } + setProperty(projectKey, property, StringUtils.join(webhookIds, ",")); + } + + private void disableGlobalWebhooks() { + setProperty(null, GLOBAL_WEBHOOK_PROPERTY, null); + } + + private void setProperty(@Nullable String componentKey, String key, @Nullable String value) { + if (value == null) { + ResetRequest req = ResetRequest.builder().setKeys(key).setComponentKey(componentKey).build(); + adminWs.settingsService().reset(req); + } else { + SetRequest req = SetRequest.builder().setKey(key).setValue(value).setComponentKey(componentKey).build(); + adminWs.settingsService().set(req); + } + } + + private static class Webhook { + private final String name; + private final String url; + + Webhook(@Nullable String name, @Nullable String url) { + this.name = name; + this.url = url; + } + } + +} -- 2.39.5