From c364b859dd4b1610dd7537b77f1152e620c5525a Mon Sep 17 00:00:00 2001 From: Zipeng WU Date: Tue, 26 Jan 2021 18:22:53 +0100 Subject: [PATCH] SONAR-14371 move Azure Devops client and WS actions to CE --- server/sonar-alm-client/build.gradle | 1 + .../client/azure/AzureDevOpsHttpClient.java | 158 ++++++++ .../alm/client/azure/GsonAzureError.java | 41 ++ .../alm/client/azure/GsonAzureProject.java | 48 +++ .../client/azure/GsonAzureProjectList.java | 56 +++ .../sonar/alm/client/azure/GsonAzureRepo.java | 63 +++ .../alm/client/azure/GsonAzureRepoList.java | 49 +++ .../azure/AzureDevOpsHttpClientTest.java | 273 +++++++++++++ .../ws/AlmIntegrationsWSModule.java | 6 + .../ws/azure/ImportAzureProjectAction.java | 163 ++++++++ .../ws/azure/ListAzureProjectsAction.java | 115 ++++++ .../ws/azure/SearchAzureReposAction.java | 235 +++++++++++ .../almintegration/ws/azure/package-info.java | 23 ++ .../azure/ImportAzureProjectActionTest.java | 238 ++++++++++++ .../ws/azure/ListAzureProjectsActionTest.java | 187 +++++++++ .../ws/azure/SearchAzureReposActionTest.java | 367 ++++++++++++++++++ .../platformlevel/PlatformLevel4.java | 2 + 17 files changed, 2025 insertions(+) create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/AzureDevOpsHttpClient.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureError.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProject.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProjectList.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepo.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepoList.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/azure/AzureDevOpsHttpClientTest.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectAction.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsAction.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposAction.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/package-info.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsActionTest.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposActionTest.java diff --git a/server/sonar-alm-client/build.gradle b/server/sonar-alm-client/build.gradle index afe80fc3278..f546848d630 100644 --- a/server/sonar-alm-client/build.gradle +++ b/server/sonar-alm-client/build.gradle @@ -6,6 +6,7 @@ dependencies { compile 'com.google.code.gson:gson' compile 'com.google.guava:guava' compile 'com.squareup.okhttp3:okhttp' + compile 'commons-codec:commons-codec' testCompile project(':sonar-plugin-api-impl') diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/AzureDevOpsHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/AzureDevOpsHttpClient.java new file mode 100644 index 00000000000..c4f2f4f0c57 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/AzureDevOpsHttpClient.java @@ -0,0 +1,158 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.azure; + +import com.google.common.base.Strings; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.util.function.Function; +import javax.annotation.Nullable; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.apache.commons.codec.binary.Base64; +import org.sonar.alm.client.TimeoutConfiguration; +import org.sonar.api.server.ServerSide; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.sonarqube.ws.client.OkHttpClientBuilder; + +import static org.sonar.api.internal.apachecommons.lang.StringUtils.isBlank; +import static org.sonar.api.internal.apachecommons.lang.StringUtils.substringBeforeLast; + +@ServerSide +public class AzureDevOpsHttpClient { + private static final Logger LOG = Loggers.get(AzureDevOpsHttpClient.class); + + protected static final String GET = "GET"; + protected static final String UNABLE_TO_CONTACT_AZURE_SERVER = "Unable to contact Azure DevOps server"; + + protected final OkHttpClient client; + + public AzureDevOpsHttpClient(TimeoutConfiguration timeoutConfiguration) { + client = new OkHttpClientBuilder() + .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout()) + .setReadTimeoutMs(timeoutConfiguration.getReadTimeout()) + .build(); + } + + public GsonAzureProjectList getProjects(String serverUrl, String token) { + String url = String.format("%s/_apis/projects", getTrimmedUrl(serverUrl)); + LOG.debug(String.format("get projects : [%s]", url)); + return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureProjectList.class)); + } + + + public GsonAzureRepoList getRepos(String serverUrl, String token, @Nullable String projectName) { + String url; + if (projectName != null && !projectName.isEmpty()) { + url = String.format("%s/%s/_apis/git/repositories", getTrimmedUrl(serverUrl), projectName); + } else { + url = String.format("%s/_apis/git/repositories", getTrimmedUrl(serverUrl)); + } + LOG.debug(String.format("get repos : [%s]", url)); + return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureRepoList.class)); + } + + public GsonAzureRepo getRepo(String serverUrl, String token, String projectName, String repositoryName) { + String url = String.format("%s/%s/_apis/git/repositories/%s", getTrimmedUrl(serverUrl), projectName, repositoryName); + LOG.debug(String.format("get repo : [%s]", url)); + return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureRepo.class)); + } + + protected G doGet(String token, String url, Function handler) { + Request request = prepareRequestWithToken(token, GET, url, null); + return doCall(request, handler); + } + + protected G doCall(Request request, Function handler) { + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response); + return handler.apply(response); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException(UNABLE_TO_CONTACT_AZURE_SERVER + ", got an unexpected response", e); + } catch (IOException e) { + throw new IllegalArgumentException(UNABLE_TO_CONTACT_AZURE_SERVER, e); + } + } + + protected static Request prepareRequestWithToken(String token, String method, String url, @Nullable RequestBody body) { + return new Request.Builder() + .method(method, body) + .url(url) + .addHeader("Authorization", encodeToken("accessToken:" + token)) + .build(); + } + + protected static void checkResponseIsSuccessful(Response response) throws IOException { + if (!response.isSuccessful()) { + LOG.debug(UNABLE_TO_CONTACT_AZURE_SERVER + ": {} {}", response.request().url().toString(), response.code()); + if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) { + throw new IllegalArgumentException("Invalid personal access token"); + } + ResponseBody responseBody = response.body(); + String body = responseBody == null ? "" : responseBody.string(); + String errorMessage = generateErrorMessage(body, UNABLE_TO_CONTACT_AZURE_SERVER); + LOG.info(String.format("Azure API call to [%s] failed with %s http code. Azure response content : [%s]", response.request().url().toString(), response.code(), body)); + throw new IllegalArgumentException(errorMessage); + } + } + + protected static String generateErrorMessage(String body, String defaultMessage) { + GsonAzureError gsonAzureError = null; + try { + gsonAzureError = buildGson().fromJson(body, GsonAzureError.class); + } catch (JsonSyntaxException e) { + // not a json payload, ignore the error + } + if (gsonAzureError != null && !Strings.isNullOrEmpty(gsonAzureError.message())) { + return defaultMessage + " : " + gsonAzureError.message(); + } else { + return defaultMessage; + } + } + + protected static String getTrimmedUrl(String rawUrl) { + if (isBlank(rawUrl)) { + return rawUrl; + } + if (rawUrl.endsWith("/")) { + return substringBeforeLast(rawUrl, "/"); + } + return rawUrl; + } + + protected static String encodeToken(String token) { + return String.format("BASIC %s", Base64.encodeBase64String(token.getBytes(StandardCharsets.UTF_8))); + } + + protected static Gson buildGson() { + return new GsonBuilder() + .create(); + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureError.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureError.java new file mode 100644 index 00000000000..e4e91799e24 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureError.java @@ -0,0 +1,41 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.azure; + +import com.google.gson.annotations.SerializedName; +import javax.annotation.Nullable; + +public class GsonAzureError { + @SerializedName("message") + private final String message; + + public GsonAzureError(@Nullable String message) { + this.message = message; + } + + public GsonAzureError() { + // http://stackoverflow.com/a/18645370/229031 + this(null); + } + + public String message() { + return message; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProject.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProject.java new file mode 100644 index 00000000000..c206f02d61d --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProject.java @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.azure; + +import com.google.gson.annotations.SerializedName; + +public class GsonAzureProject { + + @SerializedName("name") + private String name; + + @SerializedName("description") + private String description; + + public GsonAzureProject() { + // http://stackoverflow.com/a/18645370/229031 + } + + public GsonAzureProject(String name, String description) { + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProjectList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProjectList.java new file mode 100644 index 00000000000..e594bbbf90a --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProjectList.java @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.azure; + +import com.google.gson.annotations.SerializedName; + +import java.util.ArrayList; +import java.util.List; + +public class GsonAzureProjectList { + + @SerializedName("value") + private List values; + + public GsonAzureProjectList() { + // http://stackoverflow.com/a/18645370/229031 + this(new ArrayList<>()); + } + + public GsonAzureProjectList(List values) { + this.values = values; + } + + public List getValues() { + return values; + } + + public GsonAzureProjectList setValues(List values) { + this.values = values; + return this; + } + + @Override + public String toString() { + return "{" + + "values=" + values + + '}'; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepo.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepo.java new file mode 100644 index 00000000000..60327fc5c63 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepo.java @@ -0,0 +1,63 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.azure; + +import com.google.gson.annotations.SerializedName; + +public class GsonAzureRepo { + @SerializedName("id") + private String id; + + @SerializedName("name") + private String name; + + @SerializedName("url") + private String url; + + @SerializedName("project") + private GsonAzureProject project; + + public GsonAzureRepo() { + // http://stackoverflow.com/a/18645370/229031 + } + + public GsonAzureRepo(String id, String name, String url, GsonAzureProject project) { + this.id = id; + this.name = name; + this.url = url; + this.project = project; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getUrl() { + return url; + } + + public GsonAzureProject getProject() { + return project; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepoList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepoList.java new file mode 100644 index 00000000000..eaeffc8a5c6 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepoList.java @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.azure; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import java.util.ArrayList; +import java.util.List; + +public class GsonAzureRepoList { + + @SerializedName("value") + private List values; + + public GsonAzureRepoList() { + // http://stackoverflow.com/a/18645370/229031 + this(new ArrayList<>()); + } + + public GsonAzureRepoList(List values) { + this.values = values; + } + + static GsonAzureRepoList parse(String json) { + return new Gson().fromJson(json, GsonAzureRepoList.class); + } + + public List getValues() { + return values; + } + +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/azure/AzureDevOpsHttpClientTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/azure/AzureDevOpsHttpClientTest.java new file mode 100644 index 00000000000..c296f79b5af --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/azure/AzureDevOpsHttpClientTest.java @@ -0,0 +1,273 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.azure; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.ConstantTimeoutConfiguration; +import org.sonar.alm.client.TimeoutConfiguration; +import org.sonar.api.utils.log.LogTester; +import org.sonar.api.utils.log.LoggerLevel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class AzureDevOpsHttpClientTest { + public static final String UNABLE_TO_CONTACT_AZURE = "Unable to contact Azure DevOps server, got an unexpected response"; + @Rule + public LogTester logTester = new LogTester(); + + private static final String NON_JSON_PAYLOAD = "non json payload"; + private final MockWebServer server = new MockWebServer(); + private AzureDevOpsHttpClient underTest; + + @Before + public void prepare() throws IOException { + server.start(); + + TimeoutConfiguration timeoutConfiguration = new ConstantTimeoutConfiguration(10_000); + underTest = new AzureDevOpsHttpClient(timeoutConfiguration); + } + + @After + public void stopServer() throws IOException { + server.shutdown(); + } + + @Test + public void get_projects() throws InterruptedException { + enqueueResponse(200, " { \"count\": 2,\n" + + " \"value\": [\n" + + " {\n" + + " \"id\": \"3311cd05-3f00-4a5e-b47f-df94a9982b6e\",\n" + + " \"name\": \"Project 1\",\n" + + " \"description\": \"Project Description\",\n" + + " \"url\": \"https://ado.sonarqube.com/DefaultCollection/_apis/projects/3311cd05-3f00-4a5e-b47f-df94a9982b6e\",\n" + + " \"state\": \"wellFormed\",\n" + + " \"revision\": 63,\n" + + " \"visibility\": \"private\"\n" + + " }," + + "{\n" + + " \"id\": \"3be0f34d-c931-4ff8-8d37-18a83663bd3c\",\n" + + " \"name\": \"Project 2\",\n" + + " \"url\": \"https://ado.sonarqube.com/DefaultCollection/_apis/projects/3be0f34d-c931-4ff8-8d37-18a83663bd3c\",\n" + + " \"state\": \"wellFormed\",\n" + + " \"revision\": 52,\n" + + " \"visibility\": \"private\"\n" + + " }]}"); + + GsonAzureProjectList projects = underTest.getProjects(server.url("").toString(), "token"); + + RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS); + String azureDevOpsUrlCall = request.getRequestUrl().toString(); + assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "_apis/projects"); + assertThat(request.getMethod()).isEqualTo("GET"); + + assertThat(logTester.logs(LoggerLevel.DEBUG)).hasSize(1); + assertThat(logTester.logs(LoggerLevel.DEBUG)) + .contains("get projects : [" + server.url("").toString() + "_apis/projects]"); + assertThat(projects.getValues()).hasSize(2); + assertThat(projects.getValues()) + .extracting(GsonAzureProject::getName, GsonAzureProject::getDescription) + .containsExactly(tuple("Project 1", "Project Description"), tuple("Project 2", null)); + } + + @Test + public void get_projects_non_json_payload() { + enqueueResponse(200, NON_JSON_PAYLOAD); + + assertThatThrownBy(() -> underTest.getProjects(server.url("").toString(), "token")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UNABLE_TO_CONTACT_AZURE); + } + + @Test + public void get_projects_with_invalid_pat() { + enqueueResponse(401); + + assertThatThrownBy(() -> underTest.getProjects(server.url("").toString(), "invalid-token")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid personal access token"); + } + + @Test + public void get_projects_with_server_error() { + enqueueResponse(500); + + assertThatThrownBy(() -> underTest.getProjects(server.url("").toString(), "token")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unable to contact Azure DevOps server"); + } + + @Test + public void get_repos_with_project_name() throws InterruptedException { + enqueueResponse(200, "{\n" + + " \"value\": [\n" + + " {\n" + + " \"id\": \"741248a4-285e-4a6d-af52-1a49d8070638\",\n" + + " \"name\": \"Repository 1\",\n" + + " \"url\": \"https://ado.sonarqube.com/repositories/\",\n" + + " \"project\": {\n" + + " \"id\": \"c88ddb32-ced8-420d-ab34-764133038b34\",\n" + + " \"name\": \"projectName\",\n" + + " \"url\": \"https://ado.sonarqube.com/DefaultCollection/_apis/projects/c88ddb32-ced8-420d-ab34-764133038b34\",\n" + + " \"state\": \"wellFormed\",\n" + + " \"revision\": 29,\n" + + " \"visibility\": \"private\",\n" + + " \"lastUpdateTime\": \"2020-11-11T09:38:03.3Z\"\n" + + " },\n" + + " \"size\": 0\n" + + " }\n" + + " ],\n" + + " \"count\": 1\n" + + "}"); + + GsonAzureRepoList repos = underTest.getRepos(server.url("").toString(), "token", "projectName"); + + RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS); + String azureDevOpsUrlCall = request.getRequestUrl().toString(); + assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "projectName/_apis/git/repositories"); + assertThat(request.getMethod()).isEqualTo("GET"); + + assertThat(logTester.logs(LoggerLevel.DEBUG)).hasSize(1); + assertThat(logTester.logs(LoggerLevel.DEBUG)) + .contains("get repos : [" + server.url("").toString() + "projectName/_apis/git/repositories]"); + assertThat(repos.getValues()).hasSize(1); + assertThat(repos.getValues()) + .extracting(GsonAzureRepo::getName, GsonAzureRepo::getUrl, r -> r.getProject().getName()) + .containsExactly(tuple("Repository 1", "https://ado.sonarqube.com/repositories/", "projectName")); + } + + @Test + public void get_repos_without_project_name() throws InterruptedException { + enqueueResponse(200, "{ \"value\": [], \"count\": 0 }"); + + GsonAzureRepoList repos = underTest.getRepos(server.url("").toString(), "token", null); + + RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS); + String azureDevOpsUrlCall = request.getRequestUrl().toString(); + assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "_apis/git/repositories"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(repos.getValues()).isEmpty(); + } + + @Test + public void get_repos_non_json_payload() { + enqueueResponse(200, NON_JSON_PAYLOAD); + + assertThatThrownBy(() -> underTest.getRepos(server.url("").toString(), "token", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UNABLE_TO_CONTACT_AZURE); + } + + @Test + public void get_repo() throws InterruptedException { + enqueueResponse(200, "{ " + + " \"id\": \"Repo-Id-1\",\n" + + " \"name\": \"Repo-Name-1\",\n" + + " \"url\": \"https://ado.sonarqube.com/DefaultCollection/Repo-Id-1\",\n" + + " \"project\": {\n" + + " \"id\": \"84ea9d51-0c8a-44ad-be92-b2af7fe2c299\",\n" + + " \"name\": \"Project-Name\",\n" + + " \"description\": \"Project's description\" \n" + + " },\n" + + " \"size\": 0" + + "}"); + + GsonAzureRepo repo = underTest.getRepo(server.url("").toString(), "token", "Project-Name", "Repo-Name-1"); + + RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS); + String azureDevOpsUrlCall = request.getRequestUrl().toString(); + assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "Project-Name/_apis/git/repositories/Repo-Name-1"); + assertThat(request.getMethod()).isEqualTo("GET"); + + assertThat(logTester.logs(LoggerLevel.DEBUG)).hasSize(1); + assertThat(logTester.logs(LoggerLevel.DEBUG)) + .contains("get repo : [" + server.url("").toString() + "Project-Name/_apis/git/repositories/Repo-Name-1]"); + assertThat(repo.getId()).isEqualTo("Repo-Id-1"); + assertThat(repo.getName()).isEqualTo("Repo-Name-1"); + assertThat(repo.getUrl()).isEqualTo("https://ado.sonarqube.com/DefaultCollection/Repo-Id-1"); + assertThat(repo.getProject().getName()).isEqualTo("Project-Name"); + } + + @Test + public void get_repo_non_json_payload() { + enqueueResponse(200, NON_JSON_PAYLOAD); + + assertThatThrownBy(() -> underTest.getRepo(server.url("").toString(), "token", "projectName", "repoName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UNABLE_TO_CONTACT_AZURE); + } + + @Test + public void get_repo_json_error_payload() { + enqueueResponse(400, + "{'message':'TF200016: The following project does not exist: projectName. Verify that the name of the project is correct and that the project exists on the specified Azure DevOps Server.'}"); + + assertThatThrownBy(() -> underTest.getRepo(server.url("").toString(), "token", "projectName", "repoName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Unable to contact Azure DevOps server : TF200016: The following project does not exist: projectName. Verify that the name of the project is correct and that the project exists on the specified Azure DevOps Server."); + } + + private void enqueueResponse(int responseCode) { + enqueueResponse(responseCode, ""); + } + + private void enqueueResponse(int responseCode, @Nullable String body) { + server.enqueue(new MockResponse() + .setHeader("Content-Type", "application/json;charset=UTF-8") + .setResponseCode(responseCode) + .setBody(body)); + } + + @Test + public void trim_url() { + assertThat(AzureDevOpsHttpClient.getTrimmedUrl("http://localhost:4564/")) + .isEqualTo("http://localhost:4564"); + } + + @Test + public void trim_url_without_ending_slash() { + assertThat(AzureDevOpsHttpClient.getTrimmedUrl("http://localhost:4564")) + .isEqualTo("http://localhost:4564"); + } + + @Test + public void trim_null_url() { + assertThat(AzureDevOpsHttpClient.getTrimmedUrl(null)) + .isNull(); + } + + @Test + public void trim_empty_url() { + assertThat(AzureDevOpsHttpClient.getTrimmedUrl("")) + .isEmpty(); + } +} 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 65b65cac982..0496dae204c 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 @@ -20,6 +20,9 @@ package org.sonar.server.almintegration.ws; import org.sonar.core.platform.Module; +import org.sonar.server.almintegration.ws.azure.ImportAzureProjectAction; +import org.sonar.server.almintegration.ws.azure.ListAzureProjectsAction; +import org.sonar.server.almintegration.ws.azure.SearchAzureReposAction; import org.sonar.server.almintegration.ws.bitbucketserver.ImportBitbucketServerProjectAction; import org.sonar.server.almintegration.ws.bitbucketserver.ListBitbucketServerProjectsAction; import org.sonar.server.almintegration.ws.bitbucketserver.SearchBitbucketServerReposAction; @@ -35,6 +38,9 @@ public class AlmIntegrationsWSModule extends Module { SearchBitbucketServerReposAction.class, ImportGitLabProjectAction.class, SearchGitlabReposAction.class, + ImportAzureProjectAction.class, + ListAzureProjectsAction.class, + SearchAzureReposAction.class, AlmIntegrationsWs.class); } } diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectAction.java new file mode 100644 index 00000000000..de7a2a6fc8b --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectAction.java @@ -0,0 +1,163 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws.azure; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.Optional; + +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.azure.GsonAzureRepo; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.component.ComponentDto; +import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction; +import org.sonar.server.almintegration.ws.ImportHelper; +import org.sonar.server.component.ComponentUpdater; +import org.sonar.server.project.ProjectDefaultVisibility; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.Projects.CreateWsResponse; + +import static java.util.Objects.requireNonNull; +import static org.sonar.api.resources.Qualifiers.PROJECT; +import static org.sonar.server.almintegration.ws.ImportHelper.PARAM_ALM_SETTING; +import static org.sonar.server.almintegration.ws.ImportHelper.toCreateResponse; +import static org.sonar.server.component.NewComponent.newComponentBuilder; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class ImportAzureProjectAction implements AlmIntegrationsWsAction { + + private static final String PARAM_REPOSITORY_NAME = "repositoryName"; + private static final String PARAM_PROJECT_NAME = "projectName"; + + private final DbClient dbClient; + private final UserSession userSession; + private final AzureDevOpsHttpClient azureDevOpsHttpClient; + private final ProjectDefaultVisibility projectDefaultVisibility; + private final ComponentUpdater componentUpdater; + private final ImportHelper importHelper; + + public ImportAzureProjectAction(DbClient dbClient, UserSession userSession, AzureDevOpsHttpClient azureDevOpsHttpClient, + ProjectDefaultVisibility projectDefaultVisibility, ComponentUpdater componentUpdater, + ImportHelper importHelper) { + this.dbClient = dbClient; + this.userSession = userSession; + this.azureDevOpsHttpClient = azureDevOpsHttpClient; + this.projectDefaultVisibility = projectDefaultVisibility; + this.componentUpdater = componentUpdater; + this.importHelper = importHelper; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("import_azure_project") + .setDescription("Create a SonarQube project with the information from the provided Azure DevOps project.
" + + "Autoconfigure pull request decoration mechanism.
" + + "Requires the 'Create Projects' permission") + .setPost(true) + .setInternal(true) + .setSince("8.6") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + + action.createParam(PARAM_PROJECT_NAME) + .setRequired(true) + .setMaximumLength(200) + .setDescription("Azure project name"); + + action.createParam(PARAM_REPOSITORY_NAME) + .setRequired(true) + .setMaximumLength(200) + .setDescription("Azure repository name"); + } + + @Override + public void handle(Request request, Response response) { + CreateWsResponse createResponse = doHandle(request); + writeProtobuf(createResponse, request, response); + } + + private CreateWsResponse doHandle(Request request) { + importHelper.checkProvisionProjectPermission(); + AlmSettingDto almSettingDto = importHelper.getAlmSetting(request); + String userUuid = importHelper.getUserUuid(); + try (DbSession dbSession = dbClient.openSession(false)) { + + Optional almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto); + String pat = almPatDto.map(AlmPatDto::getPersonalAccessToken) + .orElseThrow(() -> new IllegalArgumentException(String.format("personal access token for '%s' is missing", almSettingDto.getKey()))); + + String projectName = request.mandatoryParam(PARAM_PROJECT_NAME); + String repositoryName = request.mandatoryParam(PARAM_REPOSITORY_NAME); + + String url = requireNonNull(almSettingDto.getUrl(), "ALM url cannot be null"); + GsonAzureRepo repo = azureDevOpsHttpClient.getRepo(url, pat, projectName, repositoryName); + + ComponentDto componentDto = createProject(dbSession, repo); + populatePRSetting(dbSession, repo, componentDto, almSettingDto); + + return toCreateResponse(componentDto); + } + } + + private ComponentDto createProject(DbSession dbSession, GsonAzureRepo repo) { + boolean visibility = projectDefaultVisibility.get(dbSession).isPrivate(); + return componentUpdater.create(dbSession, newComponentBuilder() + .setKey(generateProjectKey(repo.getProject().getName(), repo.getName())) + .setName(repo.getName()) + .setPrivate(visibility) + .setQualifier(PROJECT) + .build(), + userSession.isLoggedIn() ? userSession.getUuid() : null); + } + + private void populatePRSetting(DbSession dbSession, GsonAzureRepo repo, ComponentDto componentDto, AlmSettingDto almSettingDto) { + ProjectAlmSettingDto projectAlmSettingDto = new ProjectAlmSettingDto() + .setAlmSettingUuid(almSettingDto.getUuid()) + .setAlmRepo(repo.getName()) + .setAlmSlug(repo.getProject().getName()) + .setProjectUuid(componentDto.uuid()) + .setMonorepo(false); + dbClient.projectAlmSettingDao().insertOrUpdate(dbSession, projectAlmSettingDto); + dbSession.commit(); + } + + @VisibleForTesting + String generateProjectKey(String projectName, String repoName) { + String sqProjectKey = projectName + "_" + repoName; + + if (sqProjectKey.length() > 250) { + sqProjectKey = sqProjectKey.substring(sqProjectKey.length() - 250); + } + + return sqProjectKey.replace(" ", "_"); + } + +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsAction.java new file mode 100644 index 00000000000..7bd8a3f9ac9 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsAction.java @@ -0,0 +1,115 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws.azure; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.azure.GsonAzureProject; +import org.sonar.alm.client.azure.GsonAzureProjectList; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.AlmIntegrations.AzureProject; +import org.sonarqube.ws.AlmIntegrations.ListAzureProjectsWsResponse; + +import static java.util.Comparator.comparing; +import static java.util.Objects.requireNonNull; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class ListAzureProjectsAction implements AlmIntegrationsWsAction { + + private static final String PARAM_ALM_SETTING = "almSetting"; + + private final DbClient dbClient; + private final UserSession userSession; + private final AzureDevOpsHttpClient azureDevOpsHttpClient; + + public ListAzureProjectsAction(DbClient dbClient, UserSession userSession, AzureDevOpsHttpClient azureDevOpsHttpClient) { + this.dbClient = dbClient; + this.userSession = userSession; + this.azureDevOpsHttpClient = azureDevOpsHttpClient; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("list_azure_projects") + .setDescription("List Azure projects
" + + "Requires the 'Create Projects' permission") + .setPost(false) + .setSince("8.6") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + } + + @Override + public void handle(Request request, Response response) { + + ListAzureProjectsWsResponse wsResponse = doHandle(request); + writeProtobuf(wsResponse, request, response); + } + + private ListAzureProjectsWsResponse doHandle(Request request) { + + try (DbSession dbSession = dbClient.openSession(false)) { + userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS); + + String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING); + String userUuid = requireNonNull(userSession.getUuid(), "User UUID is not null"); + AlmSettingDto almSettingDto = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey) + .orElseThrow(() -> new NotFoundException(String.format("ALM Setting '%s' not found", almSettingKey))); + Optional almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto); + String pat = almPatDto.map(AlmPatDto::getPersonalAccessToken).orElseThrow(() -> new IllegalArgumentException("No personal access token found")); + + String url = requireNonNull(almSettingDto.getUrl(), "URL cannot be null"); + GsonAzureProjectList projectList = azureDevOpsHttpClient.getProjects(url, pat); + + List values = projectList.getValues().stream() + .map(ListAzureProjectsAction::toAzureProject) + .sorted(comparing(AzureProject::getName, String::compareToIgnoreCase)) + .collect(Collectors.toList()); + ListAzureProjectsWsResponse.Builder builder = ListAzureProjectsWsResponse.newBuilder() + .addAllProjects(values); + return builder.build(); + } + } + + private static AzureProject toAzureProject(GsonAzureProject project) { + return AzureProject.newBuilder() + .setName(project.getName()) + .setDescription(Optional.ofNullable(project.getDescription()).orElse("")) + .build(); + } + +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposAction.java new file mode 100644 index 00000000000..33a3825c528 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposAction.java @@ -0,0 +1,235 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws.azure; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nullable; + +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.azure.GsonAzureRepo; +import org.sonar.alm.client.azure.GsonAzureRepoList; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.AlmIntegrations.AzureRepo; +import org.sonarqube.ws.AlmIntegrations.SearchAzureReposWsResponse; + +import static java.util.Comparator.comparing; +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.lang.StringUtils.containsIgnoreCase; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class SearchAzureReposAction implements AlmIntegrationsWsAction { + + private static final Logger LOG = Loggers.get(SearchAzureReposAction.class); + + private static final String PARAM_ALM_SETTING = "almSetting"; + private static final String PARAM_PROJECT_NAME = "projectName"; + private static final String PARAM_SEARCH_QUERY = "searchQuery"; + + private final DbClient dbClient; + private final UserSession userSession; + private final AzureDevOpsHttpClient azureDevOpsHttpClient; + + public SearchAzureReposAction(DbClient dbClient, UserSession userSession, + AzureDevOpsHttpClient azureDevOpsHttpClient) { + this.dbClient = dbClient; + this.userSession = userSession; + this.azureDevOpsHttpClient = azureDevOpsHttpClient; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("search_azure_repos") + .setDescription("Search the Azure repositories
" + + "Requires the 'Create Projects' permission") + .setPost(false) + .setSince("8.6") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + action.createParam(PARAM_PROJECT_NAME) + .setRequired(false) + .setMaximumLength(200) + .setDescription("Project name filter"); + action.createParam(PARAM_SEARCH_QUERY) + .setRequired(false) + .setMaximumLength(200) + .setDescription("Search query filter"); + } + + @Override + public void handle(Request request, Response response) { + + SearchAzureReposWsResponse wsResponse = doHandle(request); + writeProtobuf(wsResponse, request, response); + + } + + private SearchAzureReposWsResponse doHandle(Request request) { + + try (DbSession dbSession = dbClient.openSession(false)) { + userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS); + + String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING); + String userUuid = requireNonNull(userSession.getUuid(), "User UUID cannot be null"); + AlmSettingDto almSettingDto = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey) + .orElseThrow(() -> new NotFoundException(String.format("ALM Setting '%s' not found", almSettingKey))); + Optional almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto); + + String projectKey = request.param(PARAM_PROJECT_NAME); + String searchQuery = request.param(PARAM_SEARCH_QUERY); + String pat = almPatDto.map(AlmPatDto::getPersonalAccessToken).orElseThrow(() -> new IllegalArgumentException("No personal access token found")); + String url = requireNonNull(almSettingDto.getUrl(), "ALM url cannot be null"); + + GsonAzureRepoList gsonAzureRepoList = azureDevOpsHttpClient.getRepos(url, pat, projectKey); + + Map sqProjectsKeyByAzureKey = getSqProjectsKeyByCustomKey(dbSession, almSettingDto, gsonAzureRepoList); + + List repositories = gsonAzureRepoList.getValues() + .stream() + .filter(r -> isSearchOnlyByProjectName(searchQuery) || doesSearchCriteriaMatchProjectOrRepo(r, searchQuery)) + .map(repo -> toAzureRepo(repo, sqProjectsKeyByAzureKey)) + .sorted(comparing(AzureRepo::getName, String::compareToIgnoreCase)) + .collect(toList()); + + LOG.debug(repositories.toString()); + + return SearchAzureReposWsResponse.newBuilder() + .addAllRepositories(repositories) + .build(); + } + } + + private Map getSqProjectsKeyByCustomKey(DbSession dbSession, AlmSettingDto almSettingDto, + GsonAzureRepoList azureProjectList) { + Set projectNames = azureProjectList.getValues().stream().map(r -> r.getProject().getName()).collect(toSet()); + Set azureProjectsAndRepos = azureProjectList.getValues().stream().map(ProjectKeyName::from).collect(toSet()); + + List projectAlmSettingDtos = dbClient.projectAlmSettingDao() + .selectByAlmSettingAndSlugs(dbSession, almSettingDto, projectNames); + + Map filteredProjectsByUuid = projectAlmSettingDtos + .stream() + .filter(p -> azureProjectsAndRepos.contains(ProjectKeyName.from(p))) + .collect(toMap(ProjectAlmSettingDto::getProjectUuid, Function.identity())); + + Set projectUuids = filteredProjectsByUuid.values().stream().map(ProjectAlmSettingDto::getProjectUuid).collect(toSet()); + + return dbClient.projectDao().selectByUuids(dbSession, projectUuids) + .stream() + .collect(Collectors.toMap( + projectDto -> ProjectKeyName.from(filteredProjectsByUuid.get(projectDto.getUuid())), + p -> p, + resolveNameCollisionOperatorByNaturalOrder())); + } + + private static boolean isSearchOnlyByProjectName(@Nullable String criteria) { + return criteria == null || criteria.isEmpty(); + } + + private static boolean doesSearchCriteriaMatchProjectOrRepo(GsonAzureRepo repo, String criteria) { + boolean matchProject = containsIgnoreCase(repo.getProject().getName(), criteria); + boolean matchRepo = containsIgnoreCase(repo.getName(), criteria); + return matchProject || matchRepo; + } + + private static AzureRepo toAzureRepo(GsonAzureRepo azureRepo, Map sqProjectsKeyByAzureKey) { + AzureRepo.Builder builder = AzureRepo.newBuilder() + .setName(azureRepo.getName()) + .setProjectName(azureRepo.getProject().getName()); + + ProjectDto projectDto = sqProjectsKeyByAzureKey.get(ProjectKeyName.from(azureRepo)); + if (projectDto != null) { + builder.setSqProjectName(projectDto.getName()); + builder.setSqProjectKey(projectDto.getKey()); + } + + return builder.build(); + } + + private static BinaryOperator resolveNameCollisionOperatorByNaturalOrder() { + return (a, b) -> b.getKey().compareTo(a.getKey()) > 0 ? a : b; + } + + static class ProjectKeyName { + final String projectName; + final String repoName; + + ProjectKeyName(String projectName, String repoName) { + this.projectName = projectName; + this.repoName = repoName; + } + + public static ProjectKeyName from(ProjectAlmSettingDto project) { + return new ProjectKeyName(project.getAlmSlug(), project.getAlmRepo()); + } + + public static ProjectKeyName from(GsonAzureRepo gsonAzureRepo) { + return new ProjectKeyName(gsonAzureRepo.getProject().getName(), gsonAzureRepo.getName()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + ProjectKeyName that = (ProjectKeyName) o; + return Objects.equals(projectName, that.projectName) && + Objects.equals(repoName, that.repoName); + } + + @Override + public int hashCode() { + return Objects.hash(projectName, repoName); + } + } + +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/package-info.java new file mode 100644 index 00000000000..23ca978e3db --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.almintegration.ws.azure; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java new file mode 100644 index 00000000000..22b2439e197 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java @@ -0,0 +1,238 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws.azure; + +import java.util.Optional; +import java.util.stream.IntStream; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.azure.GsonAzureProject; +import org.sonar.alm.client.azure.GsonAzureRepo; +import org.sonar.api.server.ws.WebService; +import org.sonar.api.utils.System2; +import org.sonar.core.i18n.I18n; +import org.sonar.core.util.SequenceUuidFactory; +import org.sonar.db.DbTester; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.almintegration.ws.ImportHelper; +import org.sonar.server.component.ComponentUpdater; +import org.sonar.server.es.TestProjectIndexers; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.favorite.FavoriteUpdater; +import org.sonar.server.permission.PermissionTemplateService; +import org.sonar.server.project.ProjectDefaultVisibility; +import org.sonar.server.project.Visibility; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.WsActionTester; +import org.sonarqube.ws.Projects; + +import static java.util.stream.Collectors.joining; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.when; +import static org.sonar.db.alm.integration.pat.AlmPatsTesting.newAlmPatDto; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.db.permission.GlobalPermission.SCAN; + +public class ImportAzureProjectActionTest { + + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + @Rule + public DbTester db = DbTester.create(); + + private final AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class); + + private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), mock(I18n.class), System2.INSTANCE, + mock(PermissionTemplateService.class), new FavoriteUpdater(db.getDbClient()), new TestProjectIndexers(), new SequenceUuidFactory()); + + private final ImportHelper importHelper = new ImportHelper(db.getDbClient(), userSession); + private final ProjectDefaultVisibility projectDefaultVisibility = mock(ProjectDefaultVisibility.class); + private final ImportAzureProjectAction importAzureProjectAction = new ImportAzureProjectAction(db.getDbClient(), userSession, + azureDevOpsHttpClient, projectDefaultVisibility, componentUpdater, importHelper); + private final WsActionTester ws = new WsActionTester(importAzureProjectAction); + + @Before + public void before() { + when(projectDefaultVisibility.get(any())).thenReturn(Visibility.PRIVATE); + } + + @Test + public void import_project() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setPersonalAccessToken(almSetting.getPersonalAccessToken()); + dto.setUserUuid(user.getUuid()); + }); + GsonAzureRepo repo = getGsonAzureRepo(); + when(azureDevOpsHttpClient.getRepo(almSetting.getUrl(), almSetting.getPersonalAccessToken(), "project-name", "repo-name")) + .thenReturn(repo); + + Projects.CreateWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("projectName", "project-name") + .setParam("repositoryName", "repo-name") + .executeProtobuf(Projects.CreateWsResponse.class); + + Projects.CreateWsResponse.Project result = response.getProject(); + assertThat(result.getKey()).isEqualTo(repo.getProject().getName() + "_" + repo.getName()); + assertThat(result.getName()).isEqualTo(repo.getName()); + + Optional projectDto = db.getDbClient().projectDao().selectProjectByKey(db.getSession(), result.getKey()); + assertThat(projectDto).isPresent(); + Optional projectAlmSettingDto = db.getDbClient().projectAlmSettingDao().selectByProject(db.getSession(), projectDto.get()); + assertThat(projectAlmSettingDto.get().getAlmRepo()).isEqualTo("repo-name"); + assertThat(projectAlmSettingDto.get().getAlmSettingUuid()).isEqualTo(almSetting.getUuid()); + assertThat(projectAlmSettingDto.get().getAlmSlug()).isEqualTo("project-name"); + } + + @Test + public void fail_when_not_logged_in() { + TestRequest request = ws.newRequest() + .setParam("almSetting", "azure") + .setParam("projectName", "project-name") + .setParam("repositoryName", "repo-name"); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_missing_project_creator_permission() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(SCAN); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "azure") + .setParam("projectName", "project-name") + .setParam("repositoryName", "repo-name"); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Insufficient privileges"); + } + + @Test + public void check_pat_is_missing() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting(); + + TestRequest request = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("projectName", "project-name") + .setParam("repositoryName", "repo-name"); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("personal access token for '" + almSetting.getKey() + "' is missing"); + } + + @Test + public void fail_check_alm_setting_not_found() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmPatDto almPatDto = newAlmPatDto(); + db.getDbClient().almPatDao().insert(db.getSession(), almPatDto); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "testKey"); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(NotFoundException.class) + .hasMessage("ALM Setting 'testKey' not found"); + } + + @Test + public void fail_project_already_exists() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setPersonalAccessToken(almSetting.getPersonalAccessToken()); + dto.setUserUuid(user.getUuid()); + }); + GsonAzureRepo repo = getGsonAzureRepo(); + String projectKey = repo.getProject().getName() + "_" + repo.getName(); + db.components().insertPublicProject(p -> p.setDbKey(projectKey)); + + when(azureDevOpsHttpClient.getRepo(almSetting.getUrl(), almSetting.getPersonalAccessToken(), "project-name", "repo-name")).thenReturn(repo); + TestRequest request = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("projectName", "project-name") + .setParam("repositoryName", "repo-name"); + + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(BadRequestException.class) + .hasMessage("Could not create null, key already exists: " + projectKey); + } + + @Test + public void sanitize_project_and_repo_names_with_invalid_characters() { + assertThat(importAzureProjectAction.generateProjectKey("project name", "repo name")) + .isEqualTo("project_name_repo_name"); + } + + @Test + public void sanitize_long_project_and_repo_names() { + String projectName = IntStream.range(0, 260).mapToObj(i -> "a").collect(joining()); + + assertThat(importAzureProjectAction.generateProjectKey(projectName, "repo name")) + .hasSize(250); + } + + @Test + public void define() { + WebService.Action def = ws.getDef(); + + assertThat(def.since()).isEqualTo("8.6"); + assertThat(def.isPost()).isTrue(); + assertThat(def.params()) + .extracting(WebService.Param::key, WebService.Param::isRequired) + .containsExactlyInAnyOrder( + tuple("almSetting", true), + tuple("projectName", true), + tuple("repositoryName", true)); + } + + private GsonAzureRepo getGsonAzureRepo() { + return new GsonAzureRepo("repo-id", "repo-name", "repo-url", + new GsonAzureProject("project-name", "project-description")); + } + +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsActionTest.java new file mode 100644 index 00000000000..aca6f3e978c --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsActionTest.java @@ -0,0 +1,187 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws.azure; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.azure.GsonAzureProject; +import org.sonar.alm.client.azure.GsonAzureProjectList; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbTester; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.WsActionTester; +import org.sonarqube.ws.AlmIntegrations.AzureProject; +import org.sonarqube.ws.AlmIntegrations.ListAzureProjectsWsResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.db.alm.integration.pat.AlmPatsTesting.newAlmPatDto; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; + +public class ListAzureProjectsActionTest { + @Rule + public ExpectedException expectedException = ExpectedException.none(); + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + @Rule + public DbTester db = DbTester.create(); + + private final AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class); + private final WsActionTester ws = new WsActionTester(new ListAzureProjectsAction(db.getDbClient(), userSession, azureDevOpsHttpClient)); + + @Before + public void before() { + mockClient(ImmutableList.of(new GsonAzureProject("name", "description"), + new GsonAzureProject("name", null))); + } + + private void mockClient(List projects) { + GsonAzureProjectList projectList = new GsonAzureProjectList(); + projectList.setValues(projects); + when(azureDevOpsHttpClient.getProjects(anyString(), anyString())).thenReturn(projectList); + } + + @Test + public void list_projects() { + AlmSettingDto almSetting = insertAlmSetting(); + + ListAzureProjectsWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(ListAzureProjectsWsResponse.class); + + assertThat(response.getProjectsCount()).isEqualTo(2); + assertThat(response.getProjectsList()) + .extracting(AzureProject::getName, AzureProject::getDescription) + .containsExactly(tuple("name", "description"), tuple("name", "")); + } + + @Test + public void list_projects_alphabetically_sorted() { + mockClient(ImmutableList.of(new GsonAzureProject("BBB project", "BBB project description"), + new GsonAzureProject("AAA project 1", "AAA project description"), + new GsonAzureProject("zzz project", "zzz project description"), + new GsonAzureProject("aaa project", "aaa project description"))); + AlmSettingDto almSetting = insertAlmSetting(); + + ListAzureProjectsWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(ListAzureProjectsWsResponse.class); + + assertThat(response.getProjectsCount()).isEqualTo(4); + assertThat(response.getProjectsList()) + .extracting(AzureProject::getName, AzureProject::getDescription) + .containsExactly(tuple("aaa project", "aaa project description"), tuple("AAA project 1", "AAA project description"), + tuple("BBB project", "BBB project description"), tuple("zzz project", "zzz project description")); + } + + @Test + public void check_pat_is_missing() { + insertUser(); + AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting(); + + TestRequest request = ws.newRequest() + .setParam("almSetting", almSetting.getKey()); + + assertThatThrownBy(request::execute) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No personal access token found"); + } + + @Test + public void fail_check_alm_setting_not_found() { + insertUser(); + AlmPatDto almPatDto = newAlmPatDto(); + db.getDbClient().almPatDao().insert(db.getSession(), almPatDto); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "testKey"); + + assertThatThrownBy(request::execute) + .isInstanceOf(NotFoundException.class) + .hasMessage("ALM Setting 'testKey' not found"); + } + + @Test + public void fail_when_not_logged_in() { + TestRequest request = ws.newRequest() + .setParam("almSetting", "anyvalue"); + + assertThatThrownBy(request::execute) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_no_creation_project_permission() { + UserDto user = db.users().insertUser(); + userSession.logIn(user); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "anyvalue"); + + assertThatThrownBy(request::execute) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Insufficient privileges"); + } + + @Test + public void definition() { + WebService.Action def = ws.getDef(); + + assertThat(def.since()).isEqualTo("8.6"); + assertThat(def.isPost()).isFalse(); + assertThat(def.params()) + .extracting(WebService.Param::key, WebService.Param::isRequired) + .containsExactlyInAnyOrder(tuple("almSetting", true)); + } + + private UserDto insertUser() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + return user; + } + + private AlmSettingDto insertAlmSetting() { + UserDto user = insertUser(); + AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setUserUuid(user.getUuid()); + }); + return almSetting; + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposActionTest.java new file mode 100644 index 00000000000..ed736553046 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposActionTest.java @@ -0,0 +1,367 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws.azure; + +import com.google.common.collect.ImmutableList; +import org.jetbrains.annotations.NotNull; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.azure.GsonAzureProject; +import org.sonar.alm.client.azure.GsonAzureRepo; +import org.sonar.alm.client.azure.GsonAzureRepoList; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbTester; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.WsActionTester; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.when; +import static org.sonar.db.alm.integration.pat.AlmPatsTesting.newAlmPatDto; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonarqube.ws.AlmIntegrations.AzureRepo; +import static org.sonarqube.ws.AlmIntegrations.SearchAzureReposWsResponse; + +public class SearchAzureReposActionTest { + + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + @Rule + public DbTester db = DbTester.create(); + + private AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class); + private WsActionTester ws = new WsActionTester(new SearchAzureReposAction(db.getDbClient(), userSession, azureDevOpsHttpClient)); + + @Before + public void before() { + mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "repoName-1"), + getGsonAzureRepo("project-2", "repoName-2")))); + } + + @Test + public void define() { + WebService.Action def = ws.getDef(); + + assertThat(def.since()).isEqualTo("8.6"); + assertThat(def.isPost()).isFalse(); + assertThat(def.params()) + .extracting(WebService.Param::key, WebService.Param::isRequired) + .containsExactlyInAnyOrder( + tuple("almSetting", true), + tuple("projectName", false), + tuple("searchQuery", false)); + } + + @Test + public void search_repos() { + AlmSettingDto almSetting = insertAlmSetting(); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName) + .containsExactlyInAnyOrder( + tuple("repoName-1", "project-1"), tuple("repoName-2", "project-2")); + } + + @Test + public void search_repos_alphabetically_sorted() { + mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "Z-repo"), + getGsonAzureRepo("project-1", "A-repo-1"), getGsonAzureRepo("project-1", "a-repo"), + getGsonAzureRepo("project-1", "b-repo")))); + + AlmSettingDto almSetting = insertAlmSetting(); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName) + .containsExactly( + tuple("a-repo", "project-1"), tuple("A-repo-1", "project-1"), + tuple("b-repo", "project-1"), tuple("Z-repo", "project-1")); + } + + @Test + public void search_repos_with_project_already_set_up() { + AlmSettingDto almSetting = insertAlmSetting(); + + ProjectDto projectDto2 = insertProject(almSetting, "repoName-2", "project-2"); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesCount()).isEqualTo(2); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName, + AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName) + .containsExactlyInAnyOrder( + tuple("repoName-1", "project-1", "", ""), + tuple("repoName-2", "project-2", projectDto2.getKey(), projectDto2.getName())); + } + + @Test + public void search_repos_with_project_already_set_u_and_collision_is_handled() { + AlmSettingDto almSetting = insertAlmSetting(); + + ProjectDto projectDto2 = insertProject(almSetting, "repoName-2", "project-2"); + insertProject(almSetting, "repoName-2", "project-2"); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesCount()).isEqualTo(2); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName, + AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName) + .containsExactlyInAnyOrder( + tuple("repoName-1", "project-1", "", ""), + tuple("repoName-2", "project-2", projectDto2.getKey(), projectDto2.getName())); + } + + @Test + public void search_repos_with_projects_already_set_up_and_no_collision() { + mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "repoName-1"), + getGsonAzureRepo("project", "1-repoName-1")))); + AlmSettingDto almSetting = insertAlmSetting(); + + ProjectDto projectDto1 = insertProject(almSetting, "repoName-1", "project-1"); + ProjectDto projectDto2 = insertProject(almSetting, "1-repoName-1", "project"); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesCount()).isEqualTo(2); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName, + AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName) + .containsExactlyInAnyOrder( + tuple("repoName-1", "project-1", projectDto1.getKey(), projectDto1.getName()), + tuple("1-repoName-1", "project", projectDto2.getKey(), projectDto2.getName())); + } + + @Test + public void search_repos_with_same_name_and_different_project() { + mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "repoName-1"), + getGsonAzureRepo("project-2", "repoName-1")))); + AlmSettingDto almSetting = insertAlmSetting(); + + ProjectDto projectDto1 = insertProject(almSetting, "repoName-1", "project-1"); + ProjectDto projectDto2 = insertProject(almSetting, "repoName-1", "project-2"); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesCount()).isEqualTo(2); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName, + AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName) + .containsExactlyInAnyOrder( + tuple("repoName-1", "project-1", projectDto1.getKey(), projectDto1.getName()), + tuple("repoName-1", "project-2", projectDto2.getKey(), projectDto2.getName())); + } + + @Test + public void search_repos_with_project_name() { + AlmSettingDto almSetting = insertAlmSetting(); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("projectName", "project-1") + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName) + .containsExactlyInAnyOrder( + tuple("repoName-1", "project-1"), tuple("repoName-2", "project-2")); + } + + @Test + public void search_repos_with_project_name_and_empty_criteria() { + AlmSettingDto almSetting = insertAlmSetting(); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("projectName", "project-1") + .setParam("searchQuery", "") + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName) + .containsExactlyInAnyOrder( + tuple("repoName-1", "project-1"), tuple("repoName-2", "project-2")); + } + + @Test + public void search_and_filter_repos_with_repo_name() { + AlmSettingDto almSetting = insertAlmSetting(); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("searchQuery", "repoName-2") + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName) + .containsExactlyInAnyOrder(tuple("repoName-2", "project-2")); + } + + @Test + public void search_and_filter_repos_with_matching_repo_and_project_name() { + mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("big-project", "repo-1"), + getGsonAzureRepo("big-project", "repo-2"), + getGsonAzureRepo("big-project", "big-repo"), + getGsonAzureRepo("project", "big-repo"), + getGsonAzureRepo("project", "small-repo")))); + AlmSettingDto almSetting = insertAlmSetting(); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("searchQuery", "big") + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesList()) + .extracting(AzureRepo::getName, AzureRepo::getProjectName) + .containsExactlyInAnyOrder(tuple("repo-1", "big-project"), tuple("repo-2", "big-project"), + tuple("big-repo", "big-project"), tuple("big-repo", "project")); + } + + @Test + public void return_empty_list_when_there_are_no_azure_repos() { + when(azureDevOpsHttpClient.getRepos(any(), any(), any())).thenReturn(new GsonAzureRepoList(emptyList())); + + AlmSettingDto almSetting = insertAlmSetting(); + + SearchAzureReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchAzureReposWsResponse.class); + + assertThat(response.getRepositoriesList()).isEmpty(); + } + + @Test + public void check_pat_is_missing() { + insertUser(); + AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting(); + + TestRequest request = ws.newRequest() + .setParam("almSetting", almSetting.getKey()); + + assertThatThrownBy(request::execute) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No personal access token found"); + } + + @Test + public void fail_check_pat_alm_setting_not_found() { + insertUser(); + AlmPatDto almPatDto = newAlmPatDto(); + db.getDbClient().almPatDao().insert(db.getSession(), almPatDto); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "testKey"); + + assertThatThrownBy(request::execute) + .isInstanceOf(NotFoundException.class) + .hasMessage("ALM Setting 'testKey' not found"); + } + + @Test + public void fail_when_not_logged_in() { + TestRequest request = ws.newRequest() + .setParam("almSetting", "anyvalue"); + + assertThatThrownBy(request::execute) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_no_creation_project_permission() { + UserDto user = db.users().insertUser(); + userSession.logIn(user); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "anyvalue"); + + assertThatThrownBy(request::execute) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Insufficient privileges"); + } + + private ProjectDto insertProject(AlmSettingDto almSetting, String repoName, String projectName) { + ProjectDto projectDto1 = db.components().insertPrivateProjectDto(); + db.almSettings().insertAzureProjectAlmSetting(almSetting, projectDto1, projectAlmSettingDto -> projectAlmSettingDto.setAlmRepo(repoName), + projectAlmSettingDto -> projectAlmSettingDto.setAlmSlug(projectName)); + return projectDto1; + } + + private void mockClient(GsonAzureRepoList repoList) { + when(azureDevOpsHttpClient.getRepos(any(), any(), any())).thenReturn(repoList); + } + + private AlmSettingDto insertAlmSetting() { + UserDto user = insertUser(); + AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setUserUuid(user.getUuid()); + dto.setPersonalAccessToken(almSetting.getPersonalAccessToken()); + }); + return almSetting; + } + + @NotNull + private UserDto insertUser() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + return user; + } + + private GsonAzureRepo getGsonAzureRepo(String projectName, String repoName) { + GsonAzureProject project = new GsonAzureProject(projectName, "the best project ever"); + GsonAzureRepo gsonAzureRepo = new GsonAzureRepo("repo-id", repoName, "url", project); + return gsonAzureRepo; + } +} 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 d6761042cc3..b7deaf622b6 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 @@ -21,6 +21,7 @@ package org.sonar.server.platform.platformlevel; import java.util.List; import org.sonar.alm.client.TimeoutConfigurationImpl; +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient; import org.sonar.alm.client.gitlab.GitlabHttpClient; import org.sonar.api.profiles.AnnotationProfileParser; @@ -494,6 +495,7 @@ public class PlatformLevel4 extends PlatformLevel { ImportHelper.class, BitbucketServerRestClient.class, GitlabHttpClient.class, + AzureDevOpsHttpClient.class, AlmIntegrationsWSModule.class, // Branch -- 2.39.5