3 * Copyright (C) 2009-2024 SonarSource SA
4 * mailto:info AT sonarsource DOT com
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU Lesser General Public
8 * License as published by the Free Software Foundation; either
9 * version 3 of the License, or (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 * Lesser General Public License for more details.
16 * You should have received a copy of the GNU Lesser General Public License
17 * along with this program; if not, write to the Free Software Foundation,
18 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 package org.sonar.alm.client.github;
22 import com.tngtech.java.junit.dataprovider.DataProvider;
23 import com.tngtech.java.junit.dataprovider.DataProviderRunner;
24 import com.tngtech.java.junit.dataprovider.UseDataProvider;
25 import java.io.IOException;
26 import java.net.SocketTimeoutException;
27 import java.util.concurrent.Callable;
28 import okhttp3.mockwebserver.MockResponse;
29 import okhttp3.mockwebserver.MockWebServer;
30 import okhttp3.mockwebserver.RecordedRequest;
31 import okhttp3.mockwebserver.SocketPolicy;
32 import org.junit.Before;
33 import org.junit.ClassRule;
34 import org.junit.Rule;
35 import org.junit.Test;
36 import org.junit.runner.RunWith;
37 import org.slf4j.event.Level;
38 import org.sonar.alm.client.ConstantTimeoutConfiguration;
39 import org.sonar.alm.client.DevopsPlatformHeaders;
40 import org.sonar.alm.client.GenericApplicationHttpClient;
41 import org.sonar.alm.client.TimeoutConfiguration;
42 import org.sonar.alm.client.ApplicationHttpClient.GetResponse;
43 import org.sonar.alm.client.ApplicationHttpClient.Response;
44 import org.sonar.auth.github.security.AccessToken;
45 import org.sonar.auth.github.security.UserAccessToken;
46 import org.sonar.api.testfixtures.log.LogTester;
47 import org.sonar.api.utils.log.LoggerLevel;
49 import static java.lang.String.format;
50 import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
51 import static org.assertj.core.api.Assertions.assertThat;
52 import static org.assertj.core.api.Assertions.assertThatThrownBy;
53 import static org.junit.Assert.fail;
54 import static org.sonar.alm.client.ApplicationHttpClient.RateLimit;
56 @RunWith(DataProviderRunner.class)
57 public class GenericApplicationHttpClientTest {
58 private static final String GH_API_VERSION_HEADER = "X-GitHub-Api-Version";
59 private static final String GH_API_VERSION = "2022-11-28";
62 public MockWebServer server = new MockWebServer();
65 public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
67 private GenericApplicationHttpClient underTest;
69 private final AccessToken accessToken = new UserAccessToken(randomAlphabetic(10));
70 private final String randomEndPoint = "/" + randomAlphabetic(10);
71 private final String randomBody = randomAlphabetic(40);
72 private String appUrl;
76 this.appUrl = format("http://%s:%s", server.getHostName(), server.getPort());
77 this.underTest = new TestApplicationHttpClient(new GithubHeaders(), new ConstantTimeoutConfiguration(500));
81 private static class TestApplicationHttpClient extends GenericApplicationHttpClient {
82 public TestApplicationHttpClient(DevopsPlatformHeaders devopsPlatformHeaders, TimeoutConfiguration timeoutConfiguration) {
83 super(devopsPlatformHeaders, timeoutConfiguration);
88 public void get_fails_if_endpoint_does_not_start_with_slash() throws IOException {
89 assertThatThrownBy(() -> underTest.get(appUrl, accessToken, "api/foo/bar"))
90 .hasMessage("endpoint must start with '/' or 'http'")
91 .isInstanceOf(IllegalArgumentException.class);
95 public void get_fails_if_endpoint_does_not_start_with_http() throws IOException {
96 assertThatThrownBy(() -> underTest.get(appUrl, accessToken, "ttp://api/foo/bar"))
97 .isInstanceOf(IllegalArgumentException.class)
98 .hasMessage("endpoint must start with '/' or 'http'");
102 public void get_fails_if_github_endpoint_is_invalid() throws IOException {
103 assertThatThrownBy(() -> underTest.get("invalidUrl", accessToken, "/endpoint"))
104 .isInstanceOf(IllegalArgumentException.class)
105 .hasMessage("invalidUrl/endpoint is not a valid url");
109 public void getSilent_no_log_if_code_is_not_200() throws IOException {
110 server.enqueue(new MockResponse().setResponseCode(403));
112 GetResponse response = underTest.getSilent(appUrl, accessToken, randomEndPoint);
114 assertThat(logTester.logs()).isEmpty();
115 assertThat(response.getContent()).isEmpty();
120 public void get_log_if_code_is_not_200() throws IOException {
121 server.enqueue(new MockResponse().setResponseCode(403));
123 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
125 assertThat(logTester.logs(Level.WARN)).isNotEmpty();
126 assertThat(response.getContent()).isEmpty();
131 public void get_adds_authentication_header_with_Bearer_type_and_Accept_header() throws IOException, InterruptedException {
132 server.enqueue(new MockResponse());
134 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
136 assertThat(response).isNotNull();
137 RecordedRequest recordedRequest = server.takeRequest();
138 assertThat(recordedRequest.getMethod()).isEqualTo("GET");
139 assertThat(recordedRequest.getPath()).isEqualTo(randomEndPoint);
140 assertThat(recordedRequest.getHeader("Authorization")).isEqualTo("token " + accessToken.getValue());
141 assertThat(recordedRequest.getHeader(GH_API_VERSION_HEADER)).isEqualTo(GH_API_VERSION);
145 public void get_returns_body_as_response_if_code_is_200() throws IOException {
146 server.enqueue(new MockResponse().setResponseCode(200).setBody(randomBody));
148 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
150 assertThat(response.getContent()).contains(randomBody);
154 public void get_timeout() {
155 server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
158 underTest.get(appUrl, accessToken, randomEndPoint);
159 fail("Expected timeout");
160 } catch (Exception e) {
161 assertThat(e).isInstanceOf(SocketTimeoutException.class);
166 @UseDataProvider("someHttpCodesWithContentBut200")
167 public void get_empty_response_if_code_is_not_200(int code) throws IOException {
168 server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
170 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
172 assertThat(response.getContent()).contains(randomBody);
176 public void get_returns_empty_endPoint_when_no_link_header() throws IOException {
177 server.enqueue(new MockResponse().setBody(randomBody));
179 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
181 assertThat(response.getNextEndPoint()).isEmpty();
185 public void get_returns_empty_endPoint_when_link_header_does_not_have_next_rel() throws IOException {
186 server.enqueue(new MockResponse().setBody(randomBody)
187 .setHeader("link", "<https://api.github.com/installation/repositories?per_page=5&page=4>; rel=\"prev\", " +
188 "<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\""));
190 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
192 assertThat(response.getNextEndPoint()).isEmpty();
196 @UseDataProvider("linkHeadersWithNextRel")
197 public void get_returns_endPoint_when_link_header_has_next_rel(String linkHeader) throws IOException {
198 server.enqueue(new MockResponse().setBody(randomBody)
199 .setHeader("link", linkHeader));
201 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
203 assertThat(response.getNextEndPoint()).contains("https://api.github.com/installation/repositories?per_page=5&page=2");
207 public void get_returns_endPoint_when_link_header_has_next_rel_different_case() throws IOException {
208 String linkHeader = "<https://api.github.com/installation/repositories?per_page=5&page=2>; rel=\"next\"";
209 server.enqueue(new MockResponse().setBody(randomBody)
210 .setHeader("Link", linkHeader));
212 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
214 assertThat(response.getNextEndPoint()).contains("https://api.github.com/installation/repositories?per_page=5&page=2");
218 public void get_returns_endPoint_when_link_header_is_from_gitlab() throws IOException {
219 String linkHeader = "<https://gitlab.com/api/v4/groups?all_available=false&order_by=name&owned=false&page=2&per_page=2&sort=asc&statistics=false&with_custom_attributes=false>; rel=\"next\", <https://gitlab.com/api/v4/groups?all_available=false&order_by=name&owned=false&page=1&per_page=2&sort=asc&statistics=false&with_custom_attributes=false>; rel=\"first\", <https://gitlab.com/api/v4/groups?all_available=false&order_by=name&owned=false&page=8&per_page=2&sort=asc&statistics=false&with_custom_attributes=false>; rel=\"last\"";
220 server.enqueue(new MockResponse().setBody(randomBody)
221 .setHeader("link", linkHeader));
223 GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
225 assertThat(response.getNextEndPoint()).contains("https://gitlab.com/api/v4/groups?all_available=false"
226 + "&order_by=name&owned=false&page=2&per_page=2&sort=asc&statistics=false&with_custom_attributes=false");
230 public static Object[][] linkHeadersWithNextRel() {
231 String expected = "https://api.github.com/installation/repositories?per_page=5&page=2";
232 return new Object[][] {
233 {"<" + expected + ">; rel=\"next\""},
234 {"<" + expected + ">; rel=\"next\", " +
235 "<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\""},
236 {"<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\", " +
237 "<" + expected + ">; rel=\"next\""},
238 {"<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\", " +
239 "<" + expected + ">; rel=\"next\", " +
240 "<https://api.github.com/installation/repositories?per_page=5&page=5>; rel=\"last\""},
245 public static Object[][] someHttpCodesWithContentBut200() {
246 return new Object[][] {
256 public void post_fails_if_endpoint_does_not_start_with_slash() throws IOException {
257 assertThatThrownBy(() -> underTest.post(appUrl, accessToken, "api/foo/bar"))
258 .isInstanceOf(IllegalArgumentException.class)
259 .hasMessage("endpoint must start with '/' or 'http'");
263 public void post_fails_if_endpoint_does_not_start_with_http() throws IOException {
264 assertThatThrownBy(() -> underTest.post(appUrl, accessToken, "ttp://api/foo/bar"))
265 .isInstanceOf(IllegalArgumentException.class)
266 .hasMessage("endpoint must start with '/' or 'http'");
270 public void post_fails_if_github_endpoint_is_invalid() throws IOException {
271 assertThatThrownBy(() -> underTest.post("invalidUrl", accessToken, "/endpoint"))
272 .isInstanceOf(IllegalArgumentException.class)
273 .hasMessage("invalidUrl/endpoint is not a valid url");
277 public void post_adds_authentication_header_with_Bearer_type_and_Accept_header() throws IOException, InterruptedException {
278 server.enqueue(new MockResponse());
280 Response response = underTest.post(appUrl, accessToken, randomEndPoint);
282 assertThat(response).isNotNull();
283 RecordedRequest recordedRequest = server.takeRequest();
284 assertThat(recordedRequest.getMethod()).isEqualTo("POST");
285 assertThat(recordedRequest.getPath()).isEqualTo(randomEndPoint);
286 assertThat(recordedRequest.getHeader("Authorization")).isEqualTo("token " + accessToken.getValue());
287 assertThat(recordedRequest.getHeader(GH_API_VERSION_HEADER)).isEqualTo(GH_API_VERSION);
291 @DataProvider({"200", "201", "202"})
292 public void post_returns_body_as_response_if_success(int code) throws IOException {
293 server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
295 Response response = underTest.post(appUrl, accessToken, randomEndPoint);
297 assertThat(response.getContent()).contains(randomBody);
301 public void post_returns_empty_response_if_code_is_204() throws IOException {
302 server.enqueue(new MockResponse().setResponseCode(204));
304 Response response = underTest.post(appUrl, accessToken, randomEndPoint);
306 assertThat(response.getContent()).isEmpty();
310 @UseDataProvider("httpCodesBut200_201And204")
311 public void post_has_json_error_in_body_if_code_is_neither_200_201_nor_204(int code) throws IOException {
312 server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
314 Response response = underTest.post(appUrl, accessToken, randomEndPoint);
316 assertThat(response.getContent()).contains(randomBody);
320 public static Object[][] httpCodesBut200_201And204() {
321 return new Object[][] {
333 public void post_with_json_body_adds_json_to_body_request() throws IOException, InterruptedException {
334 server.enqueue(new MockResponse());
335 String jsonBody = "{\"foo\": \"bar\"}";
336 Response response = underTest.post(appUrl, accessToken, randomEndPoint, jsonBody);
338 assertThat(response).isNotNull();
339 RecordedRequest recordedRequest = server.takeRequest();
340 assertThat(recordedRequest.getBody().readUtf8()).isEqualTo(jsonBody);
344 public void patch_with_json_body_adds_json_to_body_request() throws IOException, InterruptedException {
345 server.enqueue(new MockResponse());
346 String jsonBody = "{\"foo\": \"bar\"}";
348 Response response = underTest.patch(appUrl, accessToken, randomEndPoint, jsonBody);
350 assertThat(response).isNotNull();
351 RecordedRequest recordedRequest = server.takeRequest();
352 assertThat(recordedRequest.getBody().readUtf8()).isEqualTo(jsonBody);
356 public void patch_returns_body_as_response_if_code_is_200() throws IOException {
357 server.enqueue(new MockResponse().setResponseCode(200).setBody(randomBody));
359 Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
361 assertThat(response.getContent()).contains(randomBody);
365 public void patch_returns_empty_response_if_code_is_204() throws IOException {
366 server.enqueue(new MockResponse().setResponseCode(204));
368 Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
370 assertThat(response.getContent()).isEmpty();
374 public void delete_returns_empty_response_if_code_is_204() throws IOException {
375 server.enqueue(new MockResponse().setResponseCode(204));
377 Response response = underTest.delete(appUrl, accessToken, randomEndPoint);
379 assertThat(response.getContent()).isEmpty();
383 public static Object[][] httpCodesBut204() {
384 return new Object[][] {
398 @UseDataProvider("httpCodesBut204")
399 public void delete_returns_response_if_code_is_not_204(int code) throws IOException {
400 server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
402 Response response = underTest.delete(appUrl, accessToken, randomEndPoint);
404 assertThat(response.getContent()).hasValue(randomBody);
408 public static Object[][] httpCodesBut200And204() {
409 return new Object[][] {
422 @UseDataProvider("httpCodesBut200And204")
423 public void patch_has_json_error_in_body_if_code_is_neither_200_nor_204(int code) throws IOException {
424 server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
426 Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
428 assertThat(response.getContent()).contains(randomBody);
432 public void get_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
433 testRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint), false);
437 public void get_whenRateLimitHeadersArePresentAndUppercased_returnsRateLimit() throws Exception {
438 testRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint), true);
441 private void testRateLimitHeader(Callable<Response> request, boolean uppercasedHeaders) throws Exception {
442 server.enqueue(new MockResponse().setBody(randomBody)
443 .setHeader(uppercasedHeaders ? "x-ratelimit-remaining" : "x-ratelimit-REMAINING", "1")
444 .setHeader(uppercasedHeaders ? "x-ratelimit-limit" : "X-RATELIMIT-LIMIT", "10")
445 .setHeader(uppercasedHeaders ? "x-ratelimit-reset" : "X-ratelimit-reset", "1000"));
447 Response response = request.call();
449 assertThat(response.getRateLimit())
450 .isEqualTo(new RateLimit(1, 10, 1000L));
454 public void get_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
456 testMissingRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint));
460 private void testMissingRateLimitHeader(Callable<Response> request) throws Exception {
461 server.enqueue(new MockResponse().setBody(randomBody));
463 Response response = request.call();
464 assertThat(response.getRateLimit())
469 public void delete_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
470 testRateLimitHeader(() -> underTest.delete(appUrl, accessToken, randomEndPoint), false);
475 public void delete_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
476 testMissingRateLimitHeader(() -> underTest.delete(appUrl, accessToken, randomEndPoint));
481 public void patch_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
482 testRateLimitHeader(() -> underTest.patch(appUrl, accessToken, randomEndPoint, "body"), false);
486 public void patch_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
487 testMissingRateLimitHeader(() -> underTest.patch(appUrl, accessToken, randomEndPoint, "body"));
491 public void post_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
492 testRateLimitHeader(() -> underTest.post(appUrl, accessToken, randomEndPoint), false);
496 public void post_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
497 testMissingRateLimitHeader(() -> underTest.post(appUrl, accessToken, randomEndPoint));