/* * SonarQube * Copyright (C) 2009-2025 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 java.io.IOException; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; 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.slf4j.event.Level; import org.sonar.alm.client.ConstantTimeoutConfiguration; import org.sonar.alm.client.TimeoutConfiguration; import org.sonar.api.testfixtures.log.LogTester; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; 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 { logTester.setLevel(Level.DEBUG); server.start(); TimeoutConfiguration timeoutConfiguration = new ConstantTimeoutConfiguration(10_000); underTest = new AzureDevOpsHttpClient(timeoutConfiguration); } @After public void stopServer() throws IOException { server.shutdown(); } @Test public void check_pat() throws InterruptedException { enqueueResponse(200, """ { "count": 1, "value": [ { "id": "3311cd05-3f00-4a5e-b47f-df94a9982b6e", "name": "Project", "description": "Project Description", "url": "https://ado.sonarqube.com/DefaultCollection/_apis/projects/3311cd05-3f00-4a5e-b47f-df94a9982b6e", "state": "wellFormed", "revision": 63, "visibility": "private" }]}\ """); underTest.checkPAT(server.url("").toString(), "token"); RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS); String azureDevOpsUrlCall = request.getRequestUrl().toString(); assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "_apis/projects?api-version=3.0"); assertThat(request.getMethod()).isEqualTo("GET"); assertThat(logTester.logs(Level.DEBUG)) .contains("--> GET " + server.url("").toString() + "_apis/projects?api-version=3.0"); } @Test public void check_invalid_pat() { enqueueResponse(401); String serverUrl = server.url("").toString(); assertThatThrownBy(() -> underTest.checkPAT(serverUrl, "invalid-token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid personal access token"); } @Test public void check_pat_with_server_error() { enqueueResponse(500); String serverUrl = server.url("").toString(); assertThatThrownBy(() -> underTest.checkPAT(serverUrl, "token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unable to contact Azure DevOps server"); } @Test public void get_projects() throws InterruptedException { enqueueResponse(200, """ { "count": 2, "value": [ { "id": "3311cd05-3f00-4a5e-b47f-df94a9982b6e", "name": "Project 1", "description": "Project Description", "url": "https://ado.sonarqube.com/DefaultCollection/_apis/projects/3311cd05-3f00-4a5e-b47f-df94a9982b6e", "state": "wellFormed", "revision": 63, "visibility": "private" },\ { "id": "3be0f34d-c931-4ff8-8d37-18a83663bd3c", "name": "Project 2", "url": "https://ado.sonarqube.com/DefaultCollection/_apis/projects/3be0f34d-c931-4ff8-8d37-18a83663bd3c", "state": "wellFormed", "revision": 52, "visibility": "private" }]}"""); 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?api-version=3.0"); assertThat(request.getMethod()).isEqualTo("GET"); assertThat(logTester.logs(Level.DEBUG)) .contains("--> GET " + server.url("") + "_apis/projects?api-version=3.0"); 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); assertThat(logTester.logs(Level.ERROR)).hasSize(1); assertThat(logTester.logs(Level.ERROR).iterator().next()) .contains("Response from Azure for request [" + server.url("") + "_apis/projects?api-version=3.0] could not be parsed:"); } @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"); assertThat(logTester.logs(Level.ERROR)).hasSize(1); assertThat(logTester.logs(Level.ERROR).iterator().next()) .contains("Unable to contact Azure DevOps server for request [" + server.url("") + "_apis/projects?api-version=3.0]: Invalid personal access token"); } @Test public void get_projects_with_invalid_url() { enqueueResponse(404); assertThatThrownBy(() -> underTest.getProjects(server.url("").toString(), "invalid-token")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid Azure URL"); assertThat(logTester.logs(Level.ERROR)).hasSize(1); assertThat(logTester.logs(Level.ERROR).iterator().next()) .contains("Unable to contact Azure DevOps server for request [" + server.url("") + "_apis/projects?api-version=3.0]: URL Not Found"); } @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"); assertThat(logTester.logs(Level.ERROR)).hasSize(1); assertThat(logTester.logs(Level.ERROR).iterator().next()) .contains("Azure API call to [" + server.url("") + "_apis/projects?api-version=3.0] failed with 500 http code. Azure response content :"); } @Test public void get_repos_with_project_name() throws InterruptedException { enqueueResponse(200, """ { "value": [ { "id": "741248a4-285e-4a6d-af52-1a49d8070638", "name": "Repository 1", "url": "https://ado.sonarqube.com/repositories/", "project": { "id": "c88ddb32-ced8-420d-ab34-764133038b34", "name": "projectName", "url": "https://ado.sonarqube.com/DefaultCollection/_apis/projects/c88ddb32-ced8-420d-ab34-764133038b34", "state": "wellFormed", "revision": 29, "visibility": "private", "lastUpdateTime": "2020-11-11T09:38:03.3Z" }, "size": 0 } ], "count": 1 }"""); 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?api-version=3.0"); assertThat(request.getMethod()).isEqualTo("GET"); assertThat(logTester.logs(Level.DEBUG)) .contains("--> GET " + server.url("").toString() + "projectName/_apis/git/repositories?api-version=3.0"); 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?api-version=3.0"); 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", "name": "Repo-Name-1", "url": "https://ado.sonarqube.com/DefaultCollection/Repo-Id-1", "project": { "id": "84ea9d51-0c8a-44ad-be92-b2af7fe2c299", "name": "Project-Name", "description": "Project's description"\s }, "defaultBranch": "refs/heads/default-branch", "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?api-version=3.0"); assertThat(request.getMethod()).isEqualTo("GET"); assertThat(logTester.logs(Level.DEBUG)) .contains("--> GET " + server.url("").toString() + "Project-Name/_apis/git/repositories/Repo-Name-1?api-version=3.0"); 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"); assertThat(repo.getDefaultBranchName()).isEqualTo("default-branch"); } @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(); } }