You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

GenericApplicationHttpClientTest.java 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  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.
  10. *
  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.
  15. *
  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.
  19. */
  20. package org.sonar.alm.client.github;
  21. import com.tngtech.java.junit.dataprovider.DataProvider;
  22. import com.tngtech.java.junit.dataprovider.DataProviderRunner;
  23. import com.tngtech.java.junit.dataprovider.UseDataProvider;
  24. import java.io.IOException;
  25. import java.net.SocketTimeoutException;
  26. import java.util.concurrent.Callable;
  27. import okhttp3.mockwebserver.MockResponse;
  28. import okhttp3.mockwebserver.MockWebServer;
  29. import okhttp3.mockwebserver.RecordedRequest;
  30. import okhttp3.mockwebserver.SocketPolicy;
  31. import org.junit.Before;
  32. import org.junit.ClassRule;
  33. import org.junit.Rule;
  34. import org.junit.Test;
  35. import org.junit.runner.RunWith;
  36. import org.slf4j.event.Level;
  37. import org.sonar.alm.client.ApplicationHttpClient.GetResponse;
  38. import org.sonar.alm.client.ApplicationHttpClient.Response;
  39. import org.sonar.alm.client.ConstantTimeoutConfiguration;
  40. import org.sonar.alm.client.DevopsPlatformHeaders;
  41. import org.sonar.alm.client.GenericApplicationHttpClient;
  42. import org.sonar.alm.client.TimeoutConfiguration;
  43. import org.sonar.api.testfixtures.log.LogTester;
  44. import org.sonar.api.utils.log.LoggerLevel;
  45. import org.sonar.auth.github.security.AccessToken;
  46. import org.sonar.auth.github.security.UserAccessToken;
  47. import static java.lang.String.format;
  48. import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
  49. import static org.assertj.core.api.Assertions.assertThat;
  50. import static org.assertj.core.api.Assertions.assertThatThrownBy;
  51. import static org.junit.Assert.fail;
  52. import static org.sonar.alm.client.ApplicationHttpClient.RateLimit;
  53. @RunWith(DataProviderRunner.class)
  54. public class GenericApplicationHttpClientTest {
  55. private static final String GH_API_VERSION_HEADER = "X-GitHub-Api-Version";
  56. private static final String GH_API_VERSION = "2022-11-28";
  57. @Rule
  58. public MockWebServer server = new MockWebServer();
  59. @ClassRule
  60. public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
  61. private GenericApplicationHttpClient underTest;
  62. private final AccessToken accessToken = new UserAccessToken(randomAlphabetic(10));
  63. private final String randomEndPoint = "/" + randomAlphabetic(10);
  64. private final String randomBody = randomAlphabetic(40);
  65. private String appUrl;
  66. @Before
  67. public void setUp() {
  68. this.appUrl = format("http://%s:%s", server.getHostName(), server.getPort());
  69. this.underTest = new TestApplicationHttpClient(new GithubHeaders(), new ConstantTimeoutConfiguration(500));
  70. logTester.clear();
  71. }
  72. private static class TestApplicationHttpClient extends GenericApplicationHttpClient {
  73. public TestApplicationHttpClient(DevopsPlatformHeaders devopsPlatformHeaders, TimeoutConfiguration timeoutConfiguration) {
  74. super(devopsPlatformHeaders, timeoutConfiguration);
  75. }
  76. }
  77. @Test
  78. public void get_fails_if_endpoint_does_not_start_with_slash() throws IOException {
  79. assertThatThrownBy(() -> underTest.get(appUrl, accessToken, "api/foo/bar"))
  80. .hasMessage("endpoint must start with '/' or 'http'")
  81. .isInstanceOf(IllegalArgumentException.class);
  82. }
  83. @Test
  84. public void get_fails_if_endpoint_does_not_start_with_http() throws IOException {
  85. assertThatThrownBy(() -> underTest.get(appUrl, accessToken, "ttp://api/foo/bar"))
  86. .isInstanceOf(IllegalArgumentException.class)
  87. .hasMessage("endpoint must start with '/' or 'http'");
  88. }
  89. @Test
  90. public void get_fails_if_github_endpoint_is_invalid() throws IOException {
  91. assertThatThrownBy(() -> underTest.get("invalidUrl", accessToken, "/endpoint"))
  92. .isInstanceOf(IllegalArgumentException.class)
  93. .hasMessage("invalidUrl/endpoint is not a valid url");
  94. }
  95. @Test
  96. public void getSilent_no_log_if_code_is_not_200() throws IOException {
  97. server.enqueue(new MockResponse().setResponseCode(403));
  98. GetResponse response = underTest.getSilent(appUrl, accessToken, randomEndPoint);
  99. assertThat(logTester.logs()).isEmpty();
  100. assertThat(response.getContent()).isEmpty();
  101. }
  102. @Test
  103. public void get_log_if_code_is_not_200() throws IOException {
  104. server.enqueue(new MockResponse().setResponseCode(403));
  105. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  106. assertThat(logTester.logs(Level.WARN)).isNotEmpty();
  107. assertThat(response.getContent()).isEmpty();
  108. }
  109. @Test
  110. public void get_adds_authentication_header_with_Bearer_type_and_Accept_header() throws IOException, InterruptedException {
  111. server.enqueue(new MockResponse());
  112. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  113. assertThat(response).isNotNull();
  114. RecordedRequest recordedRequest = server.takeRequest();
  115. assertThat(recordedRequest.getMethod()).isEqualTo("GET");
  116. assertThat(recordedRequest.getPath()).isEqualTo(randomEndPoint);
  117. assertThat(recordedRequest.getHeader("Authorization")).isEqualTo("token " + accessToken.getValue());
  118. assertThat(recordedRequest.getHeader(GH_API_VERSION_HEADER)).isEqualTo(GH_API_VERSION);
  119. }
  120. @Test
  121. public void get_returns_body_as_response_if_code_is_200() throws IOException {
  122. server.enqueue(new MockResponse().setResponseCode(200).setBody(randomBody));
  123. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  124. assertThat(response.getContent()).contains(randomBody);
  125. }
  126. @Test
  127. public void get_timeout() {
  128. server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
  129. try {
  130. underTest.get(appUrl, accessToken, randomEndPoint);
  131. fail("Expected timeout");
  132. } catch (Exception e) {
  133. assertThat(e).isInstanceOf(SocketTimeoutException.class);
  134. }
  135. }
  136. @Test
  137. @UseDataProvider("someHttpCodesWithContentBut200")
  138. public void get_empty_response_if_code_is_not_200(int code) throws IOException {
  139. server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
  140. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  141. assertThat(response.getContent()).contains(randomBody);
  142. }
  143. @Test
  144. public void get_returns_empty_endPoint_when_no_link_header() throws IOException {
  145. server.enqueue(new MockResponse().setBody(randomBody));
  146. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  147. assertThat(response.getNextEndPoint()).isEmpty();
  148. }
  149. @Test
  150. public void get_returns_empty_endPoint_when_link_header_does_not_have_next_rel() throws IOException {
  151. server.enqueue(new MockResponse().setBody(randomBody)
  152. .setHeader("link", "<https://api.github.com/installation/repositories?per_page=5&page=4>; rel=\"prev\", " +
  153. "<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\""));
  154. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  155. assertThat(response.getNextEndPoint()).isEmpty();
  156. }
  157. @Test
  158. @UseDataProvider("linkHeadersWithNextRel")
  159. public void get_returns_endPoint_when_link_header_has_next_rel(String linkHeader) throws IOException {
  160. server.enqueue(new MockResponse().setBody(randomBody)
  161. .setHeader("link", linkHeader));
  162. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  163. assertThat(response.getNextEndPoint()).contains("https://api.github.com/installation/repositories?per_page=5&page=2");
  164. }
  165. @Test
  166. public void get_returns_endPoint_when_link_header_has_next_rel_different_case() throws IOException {
  167. String linkHeader = "<https://api.github.com/installation/repositories?per_page=5&page=2>; rel=\"next\"";
  168. server.enqueue(new MockResponse().setBody(randomBody)
  169. .setHeader("Link", linkHeader));
  170. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  171. assertThat(response.getNextEndPoint()).contains("https://api.github.com/installation/repositories?per_page=5&page=2");
  172. }
  173. @Test
  174. public void get_returns_endPoint_when_link_header_is_from_gitlab() throws IOException {
  175. 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\"";
  176. server.enqueue(new MockResponse().setBody(randomBody)
  177. .setHeader("link", linkHeader));
  178. GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
  179. assertThat(response.getNextEndPoint()).contains("https://gitlab.com/api/v4/groups?all_available=false"
  180. + "&order_by=name&owned=false&page=2&per_page=2&sort=asc&statistics=false&with_custom_attributes=false");
  181. }
  182. @DataProvider
  183. public static Object[][] linkHeadersWithNextRel() {
  184. String expected = "https://api.github.com/installation/repositories?per_page=5&page=2";
  185. return new Object[][] {
  186. {"<" + expected + ">; rel=\"next\""},
  187. {"<" + expected + ">; rel=\"next\", " +
  188. "<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\""},
  189. {"<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\", " +
  190. "<" + expected + ">; rel=\"next\""},
  191. {"<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\", " +
  192. "<" + expected + ">; rel=\"next\", " +
  193. "<https://api.github.com/installation/repositories?per_page=5&page=5>; rel=\"last\""},
  194. };
  195. }
  196. @DataProvider
  197. public static Object[][] someHttpCodesWithContentBut200() {
  198. return new Object[][] {
  199. {201},
  200. {202},
  201. {203},
  202. {404},
  203. {500}
  204. };
  205. }
  206. @Test
  207. public void post_fails_if_endpoint_does_not_start_with_slash() throws IOException {
  208. assertThatThrownBy(() -> underTest.post(appUrl, accessToken, "api/foo/bar"))
  209. .isInstanceOf(IllegalArgumentException.class)
  210. .hasMessage("endpoint must start with '/' or 'http'");
  211. }
  212. @Test
  213. public void post_fails_if_endpoint_does_not_start_with_http() throws IOException {
  214. assertThatThrownBy(() -> underTest.post(appUrl, accessToken, "ttp://api/foo/bar"))
  215. .isInstanceOf(IllegalArgumentException.class)
  216. .hasMessage("endpoint must start with '/' or 'http'");
  217. }
  218. @Test
  219. public void post_fails_if_github_endpoint_is_invalid() throws IOException {
  220. assertThatThrownBy(() -> underTest.post("invalidUrl", accessToken, "/endpoint"))
  221. .isInstanceOf(IllegalArgumentException.class)
  222. .hasMessage("invalidUrl/endpoint is not a valid url");
  223. }
  224. @Test
  225. public void post_adds_authentication_header_with_Bearer_type_and_Accept_header() throws IOException, InterruptedException {
  226. server.enqueue(new MockResponse());
  227. Response response = underTest.post(appUrl, accessToken, randomEndPoint);
  228. assertThat(response).isNotNull();
  229. RecordedRequest recordedRequest = server.takeRequest();
  230. assertThat(recordedRequest.getMethod()).isEqualTo("POST");
  231. assertThat(recordedRequest.getPath()).isEqualTo(randomEndPoint);
  232. assertThat(recordedRequest.getHeader("Authorization")).isEqualTo("token " + accessToken.getValue());
  233. assertThat(recordedRequest.getHeader(GH_API_VERSION_HEADER)).isEqualTo(GH_API_VERSION);
  234. }
  235. @Test
  236. @DataProvider({"200", "201", "202"})
  237. public void post_returns_body_as_response_if_success(int code) throws IOException {
  238. server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
  239. Response response = underTest.post(appUrl, accessToken, randomEndPoint);
  240. assertThat(response.getContent()).contains(randomBody);
  241. }
  242. @Test
  243. public void post_returns_empty_response_if_code_is_204() throws IOException {
  244. server.enqueue(new MockResponse().setResponseCode(204));
  245. Response response = underTest.post(appUrl, accessToken, randomEndPoint);
  246. assertThat(response.getContent()).isEmpty();
  247. }
  248. @Test
  249. @UseDataProvider("httpCodesBut200_201And204")
  250. public void post_has_json_error_in_body_if_code_is_neither_200_201_nor_204(int code) throws IOException {
  251. server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
  252. Response response = underTest.post(appUrl, accessToken, randomEndPoint);
  253. assertThat(response.getContent()).contains(randomBody);
  254. }
  255. @DataProvider
  256. public static Object[][] httpCodesBut200_201And204() {
  257. return new Object[][] {
  258. {202},
  259. {203},
  260. {400},
  261. {401},
  262. {403},
  263. {404},
  264. {500}
  265. };
  266. }
  267. @Test
  268. public void post_with_json_body_adds_json_to_body_request() throws IOException, InterruptedException {
  269. server.enqueue(new MockResponse());
  270. String jsonBody = "{\"foo\": \"bar\"}";
  271. Response response = underTest.post(appUrl, accessToken, randomEndPoint, jsonBody);
  272. assertThat(response).isNotNull();
  273. RecordedRequest recordedRequest = server.takeRequest();
  274. assertThat(recordedRequest.getBody().readUtf8()).isEqualTo(jsonBody);
  275. }
  276. @Test
  277. public void patch_with_json_body_adds_json_to_body_request() throws IOException, InterruptedException {
  278. server.enqueue(new MockResponse());
  279. String jsonBody = "{\"foo\": \"bar\"}";
  280. Response response = underTest.patch(appUrl, accessToken, randomEndPoint, jsonBody);
  281. assertThat(response).isNotNull();
  282. RecordedRequest recordedRequest = server.takeRequest();
  283. assertThat(recordedRequest.getBody().readUtf8()).isEqualTo(jsonBody);
  284. }
  285. @Test
  286. public void patch_returns_body_as_response_if_code_is_200() throws IOException {
  287. server.enqueue(new MockResponse().setResponseCode(200).setBody(randomBody));
  288. Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
  289. assertThat(response.getContent()).contains(randomBody);
  290. }
  291. @Test
  292. public void patch_returns_empty_response_if_code_is_204() throws IOException {
  293. server.enqueue(new MockResponse().setResponseCode(204));
  294. Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
  295. assertThat(response.getContent()).isEmpty();
  296. }
  297. @Test
  298. public void delete_returns_empty_response_if_code_is_204() throws IOException {
  299. server.enqueue(new MockResponse().setResponseCode(204));
  300. Response response = underTest.delete(appUrl, accessToken, randomEndPoint);
  301. assertThat(response.getContent()).isEmpty();
  302. }
  303. @DataProvider
  304. public static Object[][] httpCodesBut204() {
  305. return new Object[][] {
  306. {200},
  307. {201},
  308. {202},
  309. {203},
  310. {400},
  311. {401},
  312. {403},
  313. {404},
  314. {500}
  315. };
  316. }
  317. @Test
  318. @UseDataProvider("httpCodesBut204")
  319. public void delete_returns_response_if_code_is_not_204(int code) throws IOException {
  320. server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
  321. Response response = underTest.delete(appUrl, accessToken, randomEndPoint);
  322. assertThat(response.getContent()).hasValue(randomBody);
  323. }
  324. @DataProvider
  325. public static Object[][] httpCodesBut200And204() {
  326. return new Object[][] {
  327. {201},
  328. {202},
  329. {203},
  330. {400},
  331. {401},
  332. {403},
  333. {404},
  334. {500}
  335. };
  336. }
  337. @Test
  338. @UseDataProvider("httpCodesBut200And204")
  339. public void patch_has_json_error_in_body_if_code_is_neither_200_nor_204(int code) throws IOException {
  340. server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
  341. Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
  342. assertThat(response.getContent()).contains(randomBody);
  343. }
  344. @Test
  345. public void get_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
  346. testRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint), false);
  347. }
  348. @Test
  349. public void get_whenRateLimitHeadersArePresentAndUppercased_returnsRateLimit() throws Exception {
  350. testRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint), true);
  351. }
  352. private void testRateLimitHeader(Callable<Response> request, boolean uppercasedHeaders) throws Exception {
  353. server.enqueue(new MockResponse().setBody(randomBody)
  354. .setHeader(uppercasedHeaders ? "x-ratelimit-remaining" : "x-ratelimit-REMAINING", "1")
  355. .setHeader(uppercasedHeaders ? "x-ratelimit-limit" : "X-RATELIMIT-LIMIT", "10")
  356. .setHeader(uppercasedHeaders ? "x-ratelimit-reset" : "X-ratelimit-reset", "1000"));
  357. Response response = request.call();
  358. assertThat(response.getRateLimit())
  359. .isEqualTo(new RateLimit(1, 10, 1000L));
  360. }
  361. @Test
  362. public void get_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
  363. testMissingRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint));
  364. }
  365. private void testMissingRateLimitHeader(Callable<Response> request) throws Exception {
  366. server.enqueue(new MockResponse().setBody(randomBody));
  367. Response response = request.call();
  368. assertThat(response.getRateLimit())
  369. .isNull();
  370. }
  371. @Test
  372. public void delete_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
  373. testRateLimitHeader(() -> underTest.delete(appUrl, accessToken, randomEndPoint), false);
  374. }
  375. @Test
  376. public void delete_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
  377. testMissingRateLimitHeader(() -> underTest.delete(appUrl, accessToken, randomEndPoint));
  378. }
  379. @Test
  380. public void patch_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
  381. testRateLimitHeader(() -> underTest.patch(appUrl, accessToken, randomEndPoint, "body"), false);
  382. }
  383. @Test
  384. public void patch_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
  385. testMissingRateLimitHeader(() -> underTest.patch(appUrl, accessToken, randomEndPoint, "body"));
  386. }
  387. @Test
  388. public void post_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
  389. testRateLimitHeader(() -> underTest.post(appUrl, accessToken, randomEndPoint), false);
  390. }
  391. @Test
  392. public void post_whenRateLimitHeadersAreMissing_returnsNull() throws Exception {
  393. testMissingRateLimitHeader(() -> underTest.post(appUrl, accessToken, randomEndPoint));
  394. }
  395. }