]> source.dussan.org Git - sonarqube.git/blob
cbe044b9bfef6bb8fa4328921d143fa55d468654
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2023 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
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.nio.charset.StandardCharsets;
27 import java.util.List;
28 import java.util.Optional;
29 import java.util.Set;
30 import java.util.function.Function;
31 import javax.annotation.Nullable;
32 import org.apache.commons.io.IOUtils;
33 import org.junit.Before;
34 import org.junit.ClassRule;
35 import org.junit.Test;
36 import org.junit.runner.RunWith;
37 import org.mockito.ArgumentCaptor;
38 import org.slf4j.event.Level;
39 import org.sonar.alm.client.github.GithubApplicationHttpClient.RateLimit;
40 import org.sonar.alm.client.github.api.GsonRepositoryCollaborator;
41 import org.sonar.alm.client.github.api.GsonRepositoryTeam;
42 import org.sonar.alm.client.github.config.GithubAppConfiguration;
43 import org.sonar.alm.client.github.config.GithubAppInstallation;
44 import org.sonar.alm.client.github.security.AccessToken;
45 import org.sonar.alm.client.github.security.AppToken;
46 import org.sonar.alm.client.github.security.GithubAppSecurity;
47 import org.sonar.alm.client.github.security.UserAccessToken;
48 import org.sonar.api.testfixtures.log.LogAndArguments;
49 import org.sonar.api.testfixtures.log.LogTester;
50 import org.sonar.api.utils.log.LoggerLevel;
51 import org.sonar.auth.github.GitHubSettings;
52 import org.sonar.auth.github.GsonRepositoryPermissions;
53 import org.sonarqube.ws.client.HttpException;
54
55 import static java.net.HttpURLConnection.HTTP_CREATED;
56 import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
57 import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
58 import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
59 import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
60 import static org.assertj.core.api.Assertions.assertThat;
61 import static org.assertj.core.api.Assertions.assertThatCode;
62 import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
63 import static org.assertj.core.api.Assertions.assertThatThrownBy;
64 import static org.assertj.core.groups.Tuple.tuple;
65 import static org.mockito.ArgumentMatchers.any;
66 import static org.mockito.ArgumentMatchers.anyString;
67 import static org.mockito.ArgumentMatchers.eq;
68 import static org.mockito.Mockito.mock;
69 import static org.mockito.Mockito.verify;
70 import static org.mockito.Mockito.when;
71 import static org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse;
72
73 @RunWith(DataProviderRunner.class)
74 public class GithubApplicationClientImplTest {
75   private static final String ORG_NAME = "ORG_NAME";
76   private static final String TEAM_NAME = "team1";
77   private static final String REPO_NAME = "repo1";
78   private static final String APP_URL = "https://github.com/";
79   private static final String REPO_TEAMS_ENDPOINT = "/repos/ORG_NAME/repo1/teams";
80   private static final String REPO_COLLABORATORS_ENDPOINT = "/repos/ORG_NAME/repo1/collaborators?affiliation=direct";
81   private static final int INSTALLATION_ID = 1;
82   private static final String APP_JWT_TOKEN = "APP_TOKEN_JWT";
83   private static final String PAYLOAD_2_ORGS = """
84     [
85       {
86         "id": 1,
87         "account": {
88           "login": "org1",
89           "type": "Organization"
90         },
91         "target_type": "Organization",
92         "permissions": {
93           "members": "read",
94           "metadata": "read"
95         },
96         "suspended_at": "2023-05-30T08:40:55Z"
97       },
98       {
99         "id": 2,
100         "account": {
101           "login": "org2",
102           "type": "Organization"
103         },
104         "target_type": "Organization",
105         "permissions": {
106           "members": "read",
107           "metadata": "read"
108         }
109       }
110     ]""";
111
112   private static final RateLimit RATE_LIMIT = new RateLimit(Integer.MAX_VALUE, Integer.MAX_VALUE, 0L);
113
114   @ClassRule
115   public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
116
117   private GithubApplicationHttpClientImpl httpClient = mock();
118   private GithubAppSecurity appSecurity = mock();
119   private GithubAppConfiguration githubAppConfiguration = mock();
120   private GitHubSettings gitHubSettings = mock();
121
122   private GithubPaginatedHttpClient githubPaginatedHttpClient = mock();
123   private AppInstallationToken appInstallationToken = mock();
124   private GithubApplicationClient underTest;
125
126
127   private String appUrl = "Any URL";
128
129   @Before
130   public void setup() {
131     when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
132     underTest = new GithubApplicationClientImpl(httpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient);
133     logTester.clear();
134   }
135
136   @Test
137   @UseDataProvider("invalidApiEndpoints")
138   public void checkApiEndpoint_Invalid(String url, String expectedMessage) {
139     GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
140
141     assertThatThrownBy(() -> underTest.checkApiEndpoint(configuration))
142       .isInstanceOf(IllegalArgumentException.class)
143       .hasMessage(expectedMessage);
144   }
145
146   @DataProvider
147   public static Object[][] invalidApiEndpoints() {
148     return new Object[][] {
149       {"", "Missing URL"},
150       {"ftp://api.github.com", "Only http and https schemes are supported"},
151       {"https://github.com", "Invalid GitHub URL"}
152     };
153   }
154
155   @Test
156   @UseDataProvider("validApiEndpoints")
157   public void checkApiEndpoint(String url) {
158     GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
159
160     assertThatCode(() -> underTest.checkApiEndpoint(configuration)).isNull();
161   }
162
163   @DataProvider
164   public static Object[][] validApiEndpoints() {
165     return new Object[][] {
166       {"https://github.sonarsource.com/api/v3"},
167       {"https://api.github.com"},
168       {"https://github.sonarsource.com/api/v3/"},
169       {"https://api.github.com/"},
170       {"HTTPS://api.github.com/"},
171       {"HTTP://api.github.com/"},
172       {"HtTpS://github.SonarSource.com/api/v3"},
173       {"HtTpS://github.sonarsource.com/api/V3"},
174       {"HtTpS://github.sonarsource.COM/ApI/v3"}
175     };
176   }
177
178   @Test
179   public void checkAppPermissions_IOException() throws IOException {
180     AppToken appToken = mockAppToken();
181
182     when(httpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS"));
183
184     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
185       .isInstanceOf(IllegalArgumentException.class)
186       .hasMessage("Failed to validate configuration, check URL and Private Key");
187   }
188
189   @Test
190   @UseDataProvider("checkAppPermissionsErrorCodes")
191   public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException {
192     AppToken appToken = mockAppToken();
193
194     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null));
195
196     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
197       .isInstanceOf(IllegalArgumentException.class)
198       .hasMessage(expectedMessage);
199   }
200
201   @DataProvider
202   public static Object[][] checkAppPermissionsErrorCodes() {
203     return new Object[][] {
204       {HTTP_UNAUTHORIZED, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
205       {HTTP_FORBIDDEN, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
206       {HTTP_NOT_FOUND, "Failed to check permissions with Github, check the configuration"}
207     };
208   }
209
210   @Test
211   public void checkAppPermissions_MissingPermissions() throws IOException {
212     AppToken appToken = mockAppToken();
213
214     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}"));
215
216     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
217       .isInstanceOf(IllegalArgumentException.class)
218       .hasMessage("Failed to get app permissions, unexpected response body");
219   }
220
221   @Test
222   public void checkAppPermissions_IncorrectPermissions() throws IOException {
223     AppToken appToken = mockAppToken();
224
225     String json = "{"
226                   + "      \"permissions\": {\n"
227                   + "        \"checks\": \"read\",\n"
228                   + "        \"metadata\": \"read\",\n"
229                   + "        \"pull_requests\": \"read\"\n"
230                   + "      }\n"
231                   + "}";
232
233     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
234
235     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
236       .isInstanceOf(IllegalArgumentException.class)
237       .hasMessage("Missing permissions; permission granted on pull_requests is 'read', should be 'write', checks is 'read', should be 'write'");
238   }
239
240   @Test
241   public void checkAppPermissions() throws IOException {
242     AppToken appToken = mockAppToken();
243
244     String json = "{"
245                   + "      \"permissions\": {\n"
246                   + "        \"checks\": \"write\",\n"
247                   + "        \"metadata\": \"read\",\n"
248                   + "        \"pull_requests\": \"write\"\n"
249                   + "      }\n"
250                   + "}";
251
252     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
253
254     assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull();
255   }
256
257   @Test
258   public void getInstallationId_returns_installation_id_of_given_account() throws IOException {
259     AppToken appToken = new AppToken(APP_JWT_TOKEN);
260     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
261     when(httpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation"))
262       .thenReturn(new OkGetResponse("{" +
263         "  \"id\": 2," +
264         "  \"account\": {" +
265         "    \"login\": \"torvalds\"" +
266         "  }" +
267         "}"));
268
269     assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds/linux")).hasValue(2L);
270   }
271
272   @Test
273   public void getInstallationId_throws_IAE_if_fail_to_create_app_token() {
274     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenThrow(IllegalArgumentException.class);
275
276     assertThatThrownBy(() -> underTest.getInstallationId(githubAppConfiguration, "torvalds"))
277       .isInstanceOf(IllegalArgumentException.class);
278   }
279
280   @Test
281   public void getInstallationId_return_empty_if_no_installation_found_for_githubAccount() throws IOException {
282     AppToken appToken = new AppToken(APP_JWT_TOKEN);
283     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
284     when(httpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation"))
285       .thenReturn(new ErrorGetResponse(404, null));
286
287     assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds")).isEmpty();
288   }
289
290   @Test
291   @UseDataProvider("githubServers")
292   public void createUserAccessToken_returns_empty_if_access_token_cant_be_created(String apiUrl, String appUrl) throws IOException {
293     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
294       .thenReturn(new Response(400, null));
295
296     assertThatThrownBy(() -> underTest.createUserAccessToken(appUrl, "clientId", "clientSecret", "code"))
297       .isInstanceOf(IllegalStateException.class);
298     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
299   }
300
301   @Test
302   @UseDataProvider("githubServers")
303   public void createUserAccessToken_fail_if_access_token_request_fails(String apiUrl, String appUrl) throws IOException {
304     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
305       .thenThrow(new IOException("OOPS"));
306
307     assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
308       .isInstanceOf(IllegalStateException.class)
309       .hasMessage("Failed to create GitHub's user access token");
310
311     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
312   }
313
314   @Test
315   @UseDataProvider("githubServers")
316   public void createUserAccessToken_throws_illegal_argument_exception_if_access_token_code_is_expired(String apiUrl, String appUrl) throws IOException {
317     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
318       .thenReturn(new OkGetResponse("error_code=100&error=expired_or_invalid"));
319
320     assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
321       .isInstanceOf(IllegalArgumentException.class);
322
323     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
324   }
325
326   @Test
327   @UseDataProvider("githubServers")
328   public void createUserAccessToken_from_authorization_code_returns_access_token(String apiUrl, String appUrl) throws IOException {
329     String token = randomAlphanumeric(10);
330     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
331       .thenReturn(new OkGetResponse("access_token=" + token + "&status="));
332
333     UserAccessToken userAccessToken = underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code");
334
335     assertThat(userAccessToken)
336       .extracting(UserAccessToken::getValue, UserAccessToken::getAuthorizationHeaderPrefix)
337       .containsOnly(token, "token");
338     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
339   }
340
341   @Test
342   public void getApp_returns_id() throws IOException {
343     AppToken appToken = new AppToken(APP_JWT_TOKEN);
344     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
345     when(httpClient.get(appUrl, appToken, "/app"))
346       .thenReturn(new OkGetResponse("{\"installations_count\": 2}"));
347
348     assertThat(underTest.getApp(githubAppConfiguration).getInstallationsCount()).isEqualTo(2L);
349   }
350
351   @Test
352   public void getApp_whenStatusCodeIsNotOk_shouldThrowHttpException() throws IOException {
353     AppToken appToken = new AppToken(APP_JWT_TOKEN);
354     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
355     when(httpClient.get(appUrl, appToken, "/app"))
356       .thenReturn(new ErrorGetResponse(418, "I'm a teapot"));
357
358     assertThatThrownBy(() -> underTest.getApp(githubAppConfiguration))
359       .isInstanceOfSatisfying(HttpException.class, httpException -> {
360         assertThat(httpException.code()).isEqualTo(418);
361         assertThat(httpException.url()).isEqualTo("Any URL/app");
362         assertThat(httpException.content()).isEqualTo("I'm a teapot");
363       });
364   }
365
366   @DataProvider
367   public static Object[][] githubServers() {
368     return new Object[][] {
369       {"https://github.sonarsource.com/api/v3", "https://github.sonarsource.com"},
370       {"https://api.github.com", "https://github.com"},
371       {"https://github.sonarsource.com/api/v3/", "https://github.sonarsource.com"},
372       {"https://api.github.com/", "https://github.com"},
373     };
374   }
375
376   @Test
377   public void listOrganizations_fail_on_failure() throws IOException {
378     String appUrl = "https://github.sonarsource.com";
379     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
380
381     when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
382       .thenThrow(new IOException("OOPS"));
383
384     assertThatThrownBy(() -> underTest.listOrganizations(appUrl, accessToken, 1, 100))
385       .isInstanceOf(IllegalStateException.class)
386       .hasMessage("Failed to list all organizations accessible by user access token on %s", appUrl);
387   }
388
389   @Test
390   public void listOrganizations_fail_if_pageIndex_out_of_bounds() {
391     UserAccessToken token = new UserAccessToken("token");
392     assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 0, 100))
393       .isInstanceOf(IllegalArgumentException.class)
394       .hasMessage("'page' must be larger than 0.");
395   }
396
397   @Test
398   public void listOrganizations_fail_if_pageSize_out_of_bounds() {
399     UserAccessToken token = new UserAccessToken("token");
400     assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 1, 0))
401       .isInstanceOf(IllegalArgumentException.class)
402       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
403     assertThatThrownBy(() -> underTest.listOrganizations("", token, 1, 101))
404       .isInstanceOf(IllegalArgumentException.class)
405       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
406   }
407
408   @Test
409   public void listOrganizations_returns_no_installations() throws IOException {
410     String appUrl = "https://github.sonarsource.com";
411     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
412     String responseJson = "{\n"
413                           + "  \"total_count\": 0\n"
414                           + "} ";
415
416     when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
417       .thenReturn(new OkGetResponse(responseJson));
418
419     GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
420
421     assertThat(organizations.getTotal()).isZero();
422     assertThat(organizations.getOrganizations()).isNull();
423   }
424
425   @Test
426   public void listOrganizations_returns_pages_results() throws IOException {
427     String appUrl = "https://github.sonarsource.com";
428     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
429     String responseJson = "{\n"
430                           + "  \"total_count\": 2,\n"
431                           + "  \"installations\": [\n"
432                           + "    {\n"
433                           + "      \"id\": 1,\n"
434                           + "      \"account\": {\n"
435                           + "        \"login\": \"github\",\n"
436                           + "        \"id\": 1,\n"
437                           + "        \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjE=\",\n"
438                           + "        \"url\": \"https://github.sonarsource.com/api/v3/orgs/github\",\n"
439                           + "        \"repos_url\": \"https://github.sonarsource.com/api/v3/orgs/github/repos\",\n"
440                           + "        \"events_url\": \"https://github.sonarsource.com/api/v3/orgs/github/events\",\n"
441                           + "        \"hooks_url\": \"https://github.sonarsource.com/api/v3/orgs/github/hooks\",\n"
442                           + "        \"issues_url\": \"https://github.sonarsource.com/api/v3/orgs/github/issues\",\n"
443                           + "        \"members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/members{/member}\",\n"
444                           + "        \"public_members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/public_members{/member}\",\n"
445                           + "        \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
446                           + "        \"description\": \"A great organization\"\n"
447                           + "      },\n"
448                           + "      \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
449                           + "      \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
450                           + "      \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
451                           + "      \"app_id\": 1,\n"
452                           + "      \"target_id\": 1,\n"
453                           + "      \"target_type\": \"Organization\",\n"
454                           + "      \"permissions\": {\n"
455                           + "        \"checks\": \"write\",\n"
456                           + "        \"metadata\": \"read\",\n"
457                           + "        \"contents\": \"read\"\n"
458                           + "      },\n"
459                           + "      \"events\": [\n"
460                           + "        \"push\",\n"
461                           + "        \"pull_request\"\n"
462                           + "      ],\n"
463                           + "      \"single_file_name\": \"config.yml\"\n"
464                           + "    },\n"
465                           + "    {\n"
466                           + "      \"id\": 3,\n"
467                           + "      \"account\": {\n"
468                           + "        \"login\": \"octocat\",\n"
469                           + "        \"id\": 2,\n"
470                           + "        \"node_id\": \"MDQ6VXNlcjE=\",\n"
471                           + "        \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
472                           + "        \"gravatar_id\": \"\",\n"
473                           + "        \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
474                           + "        \"html_url\": \"https://github.com/octocat\",\n"
475                           + "        \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
476                           + "        \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
477                           + "        \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
478                           + "        \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
479                           + "        \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
480                           + "        \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
481                           + "        \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
482                           + "        \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
483                           + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
484                           + "        \"type\": \"User\",\n"
485                           + "        \"site_admin\": false\n"
486                           + "      },\n"
487                           + "      \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
488                           + "      \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
489                           + "      \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
490                           + "      \"app_id\": 1,\n"
491                           + "      \"target_id\": 1,\n"
492                           + "      \"target_type\": \"Organization\",\n"
493                           + "      \"permissions\": {\n"
494                           + "        \"checks\": \"write\",\n"
495                           + "        \"metadata\": \"read\",\n"
496                           + "        \"contents\": \"read\"\n"
497                           + "      },\n"
498                           + "      \"events\": [\n"
499                           + "        \"push\",\n"
500                           + "        \"pull_request\"\n"
501                           + "      ],\n"
502                           + "      \"single_file_name\": \"config.yml\"\n"
503                           + "    }\n"
504                           + "  ]\n"
505                           + "} ";
506
507     when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
508       .thenReturn(new OkGetResponse(responseJson));
509
510     GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
511
512     assertThat(organizations.getTotal()).isEqualTo(2);
513     assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat");
514   }
515
516   @Test
517   public void getWhitelistedGithubAppInstallations_whenWhitelistNotSpecified_doesNotFilter() throws IOException {
518     List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS);
519     assertOrgDeserialization(allOrgInstallations);
520   }
521
522   private static void assertOrgDeserialization(List<GithubAppInstallation> orgs) {
523     GithubAppInstallation org1 = orgs.get(0);
524     assertThat(org1.installationId()).isEqualTo("1");
525     assertThat(org1.organizationName()).isEqualTo("org1");
526     assertThat(org1.permissions().getMembers()).isEqualTo("read");
527     assertThat(org1.isSuspended()).isTrue();
528
529     GithubAppInstallation org2 = orgs.get(1);
530     assertThat(org2.installationId()).isEqualTo("2");
531     assertThat(org2.organizationName()).isEqualTo("org2");
532     assertThat(org2.permissions().getMembers()).isEqualTo("read");
533     assertThat(org2.isSuspended()).isFalse();
534   }
535
536   @Test
537   public void getWhitelistedGithubAppInstallations_whenWhitelistSpecified_filtersWhitelistedOrgs() throws IOException {
538     when(gitHubSettings.getOrganizations()).thenReturn(Set.of("org2"));
539     List<GithubAppInstallation> orgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS);
540     assertThat(orgInstallations)
541       .hasSize(1)
542       .extracting(GithubAppInstallation::organizationName)
543       .containsExactlyInAnyOrder("org2");
544   }
545
546   @Test
547   public void getWhitelistedGithubAppInstallations_whenEmptyResponse_shouldReturnEmpty() throws IOException {
548     List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse("[]");
549     assertThat(allOrgInstallations).isEmpty();
550   }
551
552   @Test
553   public void getWhitelistedGithubAppInstallations_whenNoOrganization_shouldReturnEmpty() throws IOException {
554     List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse("""
555       [
556         {
557           "id": 1,
558           "account": {
559             "login": "user1",
560             "type": "User"
561           },
562           "target_type": "User",
563           "permissions": {
564             "metadata": "read"
565           }
566         }
567       ]""");
568     assertThat(allOrgInstallations).isEmpty();
569   }
570
571   @SuppressWarnings("unchecked")
572   private List<GithubAppInstallation> getGithubAppInstallationsFromGithubResponse(String content) throws IOException {
573     AppToken appToken = new AppToken(APP_JWT_TOKEN);
574     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
575     when(githubPaginatedHttpClient.get(eq(appUrl), eq(appToken), eq("/app/installations"), any()))
576       .thenAnswer(invocation -> {
577         Function<String, List<GithubBinding.GsonInstallation>> deserializingFunction = invocation.getArgument(3, Function.class);
578         return deserializingFunction.apply(content);
579       });
580     return underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration);
581   }
582
583   @Test
584   public void getWhitelistedGithubAppInstallations_whenGithubReturnsError_shouldThrow() throws IOException {
585     AppToken appToken = new AppToken(APP_JWT_TOKEN);
586     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
587     when(githubPaginatedHttpClient.get(any(), any(), any(), any())).thenThrow(new IOException("io exception"));
588
589     assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration))
590       .isInstanceOf(IllegalStateException.class)
591       .hasMessage(
592         "SonarQube was not able to retrieve resources from GitHub. "
593         + "This is likely due to a connectivity problem or a temporary network outage: "
594         + "Error while executing a paginated call to GitHub - appUrl: Any URL, path: /app/installations. io exception"
595         );
596   }
597
598   @Test
599   public void listRepositories_fail_on_failure() throws IOException {
600     String appUrl = "https://github.sonarsource.com";
601     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
602
603     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "org:test", 1, 100)))
604       .thenThrow(new IOException("OOPS"));
605
606     assertThatThrownBy(() -> underTest.listRepositories(appUrl, accessToken, "test", null, 1, 100))
607       .isInstanceOf(IllegalStateException.class)
608       .hasMessage("Failed to list all repositories of 'test' accessible by user access token on 'https://github.sonarsource.com' using query 'fork:true+org:test'");
609   }
610
611   @Test
612   public void listRepositories_fail_if_pageIndex_out_of_bounds() {
613     UserAccessToken token = new UserAccessToken("token");
614     assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 0, 100))
615       .isInstanceOf(IllegalArgumentException.class)
616       .hasMessage("'page' must be larger than 0.");
617   }
618
619   @Test
620   public void listRepositories_fail_if_pageSize_out_of_bounds() {
621     UserAccessToken token = new UserAccessToken("token");
622     assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 1, 0))
623       .isInstanceOf(IllegalArgumentException.class)
624       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
625     assertThatThrownBy(() -> underTest.listRepositories("", token, "test", null, 1, 101))
626       .isInstanceOf(IllegalArgumentException.class)
627       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
628   }
629
630   @Test
631   public void listRepositories_returns_empty_results() throws IOException {
632     String appUrl = "https://github.sonarsource.com";
633     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
634     String responseJson = "{\n"
635                           + "  \"total_count\": 0\n"
636                           + "}";
637
638     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
639       .thenReturn(new OkGetResponse(responseJson));
640
641     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
642
643     assertThat(repositories.getTotal()).isZero();
644     assertThat(repositories.getRepositories()).isNull();
645   }
646
647   @Test
648   public void listRepositories_returns_pages_results() throws IOException {
649     String appUrl = "https://github.sonarsource.com";
650     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
651     String responseJson = "{\n"
652                           + "  \"total_count\": 2,\n"
653                           + "  \"incomplete_results\": false,\n"
654                           + "  \"items\": [\n"
655                           + "    {\n"
656                           + "      \"id\": 3081286,\n"
657                           + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
658                           + "      \"name\": \"HelloWorld\",\n"
659                           + "      \"full_name\": \"github/HelloWorld\",\n"
660                           + "      \"owner\": {\n"
661                           + "        \"login\": \"github\",\n"
662                           + "        \"id\": 872147,\n"
663                           + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
664                           + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
665                           + "        \"gravatar_id\": \"\",\n"
666                           + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
667                           + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
668                           + "        \"type\": \"User\"\n"
669                           + "      },\n"
670                           + "      \"private\": false,\n"
671                           + "      \"html_url\": \"https://github.com/github/HelloWorld\",\n"
672                           + "      \"description\": \"A C implementation of HelloWorld\",\n"
673                           + "      \"fork\": false,\n"
674                           + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
675                           + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
676                           + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
677                           + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
678                           + "      \"homepage\": \"\",\n"
679                           + "      \"size\": 524,\n"
680                           + "      \"stargazers_count\": 1,\n"
681                           + "      \"watchers_count\": 1,\n"
682                           + "      \"language\": \"Assembly\",\n"
683                           + "      \"forks_count\": 0,\n"
684                           + "      \"open_issues_count\": 0,\n"
685                           + "      \"master_branch\": \"master\",\n"
686                           + "      \"default_branch\": \"master\",\n"
687                           + "      \"score\": 1.0\n"
688                           + "    },\n"
689                           + "    {\n"
690                           + "      \"id\": 3081286,\n"
691                           + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
692                           + "      \"name\": \"HelloUniverse\",\n"
693                           + "      \"full_name\": \"github/HelloUniverse\",\n"
694                           + "      \"owner\": {\n"
695                           + "        \"login\": \"github\",\n"
696                           + "        \"id\": 872147,\n"
697                           + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
698                           + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
699                           + "        \"gravatar_id\": \"\",\n"
700                           + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
701                           + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
702                           + "        \"type\": \"User\"\n"
703                           + "      },\n"
704                           + "      \"private\": false,\n"
705                           + "      \"html_url\": \"https://github.com/github/HelloUniverse\",\n"
706                           + "      \"description\": \"A C implementation of HelloUniverse\",\n"
707                           + "      \"fork\": false,\n"
708                           + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloUniverse\",\n"
709                           + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
710                           + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
711                           + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
712                           + "      \"homepage\": \"\",\n"
713                           + "      \"size\": 524,\n"
714                           + "      \"stargazers_count\": 1,\n"
715                           + "      \"watchers_count\": 1,\n"
716                           + "      \"language\": \"Assembly\",\n"
717                           + "      \"forks_count\": 0,\n"
718                           + "      \"open_issues_count\": 0,\n"
719                           + "      \"master_branch\": \"master\",\n"
720                           + "      \"default_branch\": \"master\",\n"
721                           + "      \"score\": 1.0\n"
722                           + "    }\n"
723                           + "  ]\n"
724                           + "}";
725
726     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
727       .thenReturn(new OkGetResponse(responseJson));
728     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
729
730     assertThat(repositories.getTotal()).isEqualTo(2);
731     assertThat(repositories.getRepositories())
732       .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
733       .containsOnly(tuple("HelloWorld", "github/HelloWorld"), tuple("HelloUniverse", "github/HelloUniverse"));
734   }
735
736   @Test
737   public void listRepositories_returns_search_results() throws IOException {
738     String appUrl = "https://github.sonarsource.com";
739     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
740     String responseJson = "{\n"
741                           + "  \"total_count\": 2,\n"
742                           + "  \"incomplete_results\": false,\n"
743                           + "  \"items\": [\n"
744                           + "    {\n"
745                           + "      \"id\": 3081286,\n"
746                           + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
747                           + "      \"name\": \"HelloWorld\",\n"
748                           + "      \"full_name\": \"github/HelloWorld\",\n"
749                           + "      \"owner\": {\n"
750                           + "        \"login\": \"github\",\n"
751                           + "        \"id\": 872147,\n"
752                           + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
753                           + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
754                           + "        \"gravatar_id\": \"\",\n"
755                           + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
756                           + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
757                           + "        \"type\": \"User\"\n"
758                           + "      },\n"
759                           + "      \"private\": false,\n"
760                           + "      \"html_url\": \"https://github.com/github/HelloWorld\",\n"
761                           + "      \"description\": \"A C implementation of HelloWorld\",\n"
762                           + "      \"fork\": false,\n"
763                           + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
764                           + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
765                           + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
766                           + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
767                           + "      \"homepage\": \"\",\n"
768                           + "      \"size\": 524,\n"
769                           + "      \"stargazers_count\": 1,\n"
770                           + "      \"watchers_count\": 1,\n"
771                           + "      \"language\": \"Assembly\",\n"
772                           + "      \"forks_count\": 0,\n"
773                           + "      \"open_issues_count\": 0,\n"
774                           + "      \"master_branch\": \"master\",\n"
775                           + "      \"default_branch\": \"master\",\n"
776                           + "      \"score\": 1.0\n"
777                           + "    }\n"
778                           + "  ]\n"
779                           + "}";
780
781     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100)))
782       .thenReturn(new GetResponse() {
783         @Override
784         public Optional<String> getNextEndPoint() {
785           return Optional.empty();
786         }
787
788         @Override
789         public int getCode() {
790           return 200;
791         }
792
793         @Override
794         public Optional<String> getContent() {
795           return Optional.of(responseJson);
796         }
797
798         @Override
799         public RateLimit getRateLimit() {
800           return RATE_LIMIT;
801         }
802       });
803
804     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", "world", 1, 100);
805
806     assertThat(repositories.getTotal()).isEqualTo(2);
807     assertThat(repositories.getRepositories())
808       .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
809       .containsOnly(tuple("HelloWorld", "github/HelloWorld"));
810   }
811
812   @Test
813   public void getRepository_returns_empty_when_repository_doesnt_exist() throws IOException {
814     when(httpClient.get(any(), any(), any()))
815       .thenReturn(new Response(404, null));
816
817     Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, new UserAccessToken("temp"), "octocat/Hello-World");
818
819     assertThat(repository).isEmpty();
820   }
821
822   @Test
823   public void getRepository_fails_on_failure() throws IOException {
824     String repositoryKey = "octocat/Hello-World";
825
826     when(httpClient.get(any(), any(), any()))
827       .thenThrow(new IOException("OOPS"));
828
829     UserAccessToken token = new UserAccessToken("temp");
830     assertThatThrownBy(() -> underTest.getRepository(appUrl, token, repositoryKey))
831       .isInstanceOf(IllegalStateException.class)
832       .hasMessage("Failed to get repository 'octocat/Hello-World' on 'Any URL' (this might be related to the GitHub App installation scope)");
833   }
834
835   @Test
836   public void getRepository_returns_repository() throws IOException {
837     String appUrl = "https://github.sonarsource.com";
838     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
839     String responseJson = "{\n"
840                           + "  \"id\": 1296269,\n"
841                           + "  \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMjk2MjY5\",\n"
842                           + "  \"name\": \"Hello-World\",\n"
843                           + "  \"full_name\": \"octocat/Hello-World\",\n"
844                           + "  \"owner\": {\n"
845                           + "    \"login\": \"octocat\",\n"
846                           + "    \"id\": 1,\n"
847                           + "    \"node_id\": \"MDQ6VXNlcjE=\",\n"
848                           + "    \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
849                           + "    \"gravatar_id\": \"\",\n"
850                           + "    \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
851                           + "    \"html_url\": \"https://github.com/octocat\",\n"
852                           + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
853                           + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
854                           + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
855                           + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
856                           + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
857                           + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
858                           + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
859                           + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
860                           + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
861                           + "    \"type\": \"User\",\n"
862                           + "    \"site_admin\": false\n"
863                           + "  },\n"
864                           + "  \"private\": false,\n"
865                           + "  \"html_url\": \"https://github.com/octocat/Hello-World\",\n"
866                           + "  \"description\": \"This your first repo!\",\n"
867                           + "  \"fork\": false,\n"
868                           + "  \"url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World\",\n"
869                           + "  \"archive_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/{archive_format}{/ref}\",\n"
870                           + "  \"assignees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/assignees{/user}\",\n"
871                           + "  \"blobs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/blobs{/sha}\",\n"
872                           + "  \"branches_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/branches{/branch}\",\n"
873                           + "  \"collaborators_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/collaborators{/collaborator}\",\n"
874                           + "  \"comments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/comments{/number}\",\n"
875                           + "  \"commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/commits{/sha}\",\n"
876                           + "  \"compare_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/compare/{base}...{head}\",\n"
877                           + "  \"contents_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contents/{+path}\",\n"
878                           + "  \"contributors_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contributors\",\n"
879                           + "  \"deployments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/deployments\",\n"
880                           + "  \"downloads_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/downloads\",\n"
881                           + "  \"events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/events\",\n"
882                           + "  \"forks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/forks\",\n"
883                           + "  \"git_commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/commits{/sha}\",\n"
884                           + "  \"git_refs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/refs{/sha}\",\n"
885                           + "  \"git_tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/tags{/sha}\",\n"
886                           + "  \"git_url\": \"git:github.com/octocat/Hello-World.git\",\n"
887                           + "  \"issue_comment_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/comments{/number}\",\n"
888                           + "  \"issue_events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/events{/number}\",\n"
889                           + "  \"issues_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues{/number}\",\n"
890                           + "  \"keys_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/keys{/key_id}\",\n"
891                           + "  \"labels_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/labels{/name}\",\n"
892                           + "  \"languages_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/languages\",\n"
893                           + "  \"merges_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/merges\",\n"
894                           + "  \"milestones_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/milestones{/number}\",\n"
895                           + "  \"notifications_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/notifications{?since,all,participating}\",\n"
896                           + "  \"pulls_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/pulls{/number}\",\n"
897                           + "  \"releases_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/releases{/id}\",\n"
898                           + "  \"ssh_url\": \"git@github.com:octocat/Hello-World.git\",\n"
899                           + "  \"stargazers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/stargazers\",\n"
900                           + "  \"statuses_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/statuses/{sha}\",\n"
901                           + "  \"subscribers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscribers\",\n"
902                           + "  \"subscription_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscription\",\n"
903                           + "  \"tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/tags\",\n"
904                           + "  \"teams_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/teams\",\n"
905                           + "  \"trees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/trees{/sha}\",\n"
906                           + "  \"clone_url\": \"https://github.com/octocat/Hello-World.git\",\n"
907                           + "  \"mirror_url\": \"git:git.example.com/octocat/Hello-World\",\n"
908                           + "  \"hooks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/hooks\",\n"
909                           + "  \"svn_url\": \"https://svn.github.com/octocat/Hello-World\",\n"
910                           + "  \"homepage\": \"https://github.com\",\n"
911                           + "  \"language\": null,\n"
912                           + "  \"forks_count\": 9,\n"
913                           + "  \"stargazers_count\": 80,\n"
914                           + "  \"watchers_count\": 80,\n"
915                           + "  \"size\": 108,\n"
916                           + "  \"default_branch\": \"master\",\n"
917                           + "  \"open_issues_count\": 0,\n"
918                           + "  \"is_template\": true,\n"
919                           + "  \"topics\": [\n"
920                           + "    \"octocat\",\n"
921                           + "    \"atom\",\n"
922                           + "    \"electron\",\n"
923                           + "    \"api\"\n"
924                           + "  ],\n"
925                           + "  \"has_issues\": true,\n"
926                           + "  \"has_projects\": true,\n"
927                           + "  \"has_wiki\": true,\n"
928                           + "  \"has_pages\": false,\n"
929                           + "  \"has_downloads\": true,\n"
930                           + "  \"archived\": false,\n"
931                           + "  \"disabled\": false,\n"
932                           + "  \"visibility\": \"public\",\n"
933                           + "  \"pushed_at\": \"2011-01-26T19:06:43Z\",\n"
934                           + "  \"created_at\": \"2011-01-26T19:01:12Z\",\n"
935                           + "  \"updated_at\": \"2011-01-26T19:14:43Z\",\n"
936                           + "  \"permissions\": {\n"
937                           + "    \"admin\": false,\n"
938                           + "    \"push\": false,\n"
939                           + "    \"pull\": true\n"
940                           + "  },\n"
941                           + "  \"allow_rebase_merge\": true,\n"
942                           + "  \"template_repository\": null,\n"
943                           + "  \"allow_squash_merge\": true,\n"
944                           + "  \"allow_merge_commit\": true,\n"
945                           + "  \"subscribers_count\": 42,\n"
946                           + "  \"network_count\": 0,\n"
947                           + "  \"anonymous_access_enabled\": false,\n"
948                           + "  \"license\": {\n"
949                           + "    \"key\": \"mit\",\n"
950                           + "    \"name\": \"MIT License\",\n"
951                           + "    \"spdx_id\": \"MIT\",\n"
952                           + "    \"url\": \"https://github.sonarsource.com/api/v3/licenses/mit\",\n"
953                           + "    \"node_id\": \"MDc6TGljZW5zZW1pdA==\"\n"
954                           + "  },\n"
955                           + "  \"organization\": {\n"
956                           + "    \"login\": \"octocat\",\n"
957                           + "    \"id\": 1,\n"
958                           + "    \"node_id\": \"MDQ6VXNlcjE=\",\n"
959                           + "    \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
960                           + "    \"gravatar_id\": \"\",\n"
961                           + "    \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
962                           + "    \"html_url\": \"https://github.com/octocat\",\n"
963                           + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
964                           + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
965                           + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
966                           + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
967                           + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
968                           + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
969                           + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
970                           + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
971                           + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
972                           + "    \"type\": \"Organization\",\n"
973                           + "    \"site_admin\": false\n"
974                           + "  }"
975                           + "}";
976
977     when(httpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
978       .thenReturn(new GetResponse() {
979         @Override
980         public Optional<String> getNextEndPoint() {
981           return Optional.empty();
982         }
983
984         @Override
985         public int getCode() {
986           return 200;
987         }
988
989         @Override
990         public Optional<String> getContent() {
991           return Optional.of(responseJson);
992         }
993
994         @Override
995         public RateLimit getRateLimit() {
996           return RATE_LIMIT;
997         }
998       });
999
1000     Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, accessToken, "octocat/Hello-World");
1001
1002     assertThat(repository)
1003       .isPresent()
1004       .get()
1005       .extracting(GithubApplicationClient.Repository::getId, GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName,
1006         GithubApplicationClient.Repository::getUrl, GithubApplicationClient.Repository::isPrivate, GithubApplicationClient.Repository::getDefaultBranch)
1007       .containsOnly(1296269L, "Hello-World", "octocat/Hello-World", "https://github.com/octocat/Hello-World", false, "master");
1008   }
1009
1010   @Test
1011   public void createAppInstallationToken_throws_IAE_if_application_token_cant_be_created() {
1012     mockNoApplicationJwtToken();
1013
1014     assertThatThrownBy(() -> underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID))
1015       .isInstanceOf(IllegalArgumentException.class);
1016   }
1017
1018   private void mockNoApplicationJwtToken() {
1019     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenThrow(IllegalArgumentException.class);
1020   }
1021
1022   @Test
1023   public void createAppInstallationToken_returns_empty_if_post_throws_IOE() throws IOException {
1024     mockAppToken();
1025     when(httpClient.post(anyString(), any(AccessToken.class), anyString())).thenThrow(IOException.class);
1026     Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
1027
1028     assertThat(accessToken).isEmpty();
1029     assertThat(logTester.getLogs(Level.WARN)).extracting(LogAndArguments::getRawMsg).anyMatch(s -> s.startsWith("Failed to request"));
1030   }
1031
1032   @Test
1033   public void createAppInstallationToken_returns_empty_if_access_token_cant_be_created() throws IOException {
1034     AppToken appToken = mockAppToken();
1035     mockAccessTokenCallingGithubFailure();
1036
1037     Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
1038
1039     assertThat(accessToken).isEmpty();
1040     verify(httpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
1041   }
1042
1043   @Test
1044   public void createAppInstallationToken_from_installation_id_returns_access_token() throws IOException {
1045     AppToken appToken = mockAppToken();
1046     AppInstallationToken installToken = mockCreateAccessTokenCallingGithub();
1047
1048     Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
1049
1050     assertThat(accessToken).hasValue(installToken);
1051     verify(httpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
1052   }
1053
1054   @Test
1055   public void getRepositoryTeams_returnsRepositoryTeams() throws IOException {
1056     ArgumentCaptor<Function<String, List<GsonRepositoryTeam>>> deserializerCaptor = ArgumentCaptor.forClass(Function.class);
1057
1058     when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedTeams());
1059
1060     Set<GsonRepositoryTeam> repoTeams = underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME);
1061
1062     assertThat(repoTeams)
1063       .containsExactlyInAnyOrderElementsOf(expectedTeams());
1064
1065     String responseContent = getResponseContent("repo-teams-full-response.json");
1066     assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedTeams());
1067   }
1068
1069   @Test
1070   public void getRepositoryTeams_whenGitHubCallThrowsIOException_shouldLogAndThrow() throws IOException {
1071     when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), any())).thenThrow(new IOException("error"));
1072
1073     assertThatIllegalStateException()
1074       .isThrownBy(() -> underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME))
1075       .isInstanceOf(IllegalStateException.class)
1076       .withMessage(
1077         "SonarQube was not able to retrieve resources from GitHub. This is likely due to a connectivity problem or a temporary network outage: Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/teams. error");
1078
1079     assertThat(logTester.logs()).hasSize(1);
1080     assertThat(logTester.logs(Level.WARN))
1081       .containsExactly("Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/teams.");
1082   }
1083
1084   private static List<GsonRepositoryTeam> expectedTeams() {
1085     return List.of(
1086       new GsonRepositoryTeam("team1", 1, "team1", "pull", new GsonRepositoryPermissions(true, true, true, true, true)),
1087       new GsonRepositoryTeam("team2", 2, "team2", "push", new GsonRepositoryPermissions(false, false, true, true, true)));
1088   }
1089
1090   @Test
1091   public void getRepositoryCollaborators_returnsCollaboratorsFromGithub() throws IOException {
1092     ArgumentCaptor<Function<String, List<GsonRepositoryCollaborator>>> deserializerCaptor = ArgumentCaptor.forClass(Function.class);
1093
1094     when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedCollaborators());
1095
1096     Set<GsonRepositoryCollaborator> repoTeams = underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME);
1097
1098     assertThat(repoTeams)
1099       .containsExactlyInAnyOrderElementsOf(expectedCollaborators());
1100
1101     String responseContent = getResponseContent("repo-collaborators-full-response.json");
1102     assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedCollaborators());
1103
1104   }
1105
1106   @Test
1107   public void getRepositoryCollaborators_whenGitHubCallThrowsIOException_shouldLogAndThrow() throws IOException {
1108     when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), any())).thenThrow(new IOException("error"));
1109
1110     assertThatIllegalStateException()
1111       .isThrownBy(() -> underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME))
1112       .isInstanceOf(IllegalStateException.class)
1113       .withMessage(
1114         "SonarQube was not able to retrieve resources from GitHub. This is likely due to a connectivity problem or a temporary network outage: "
1115         + "Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/collaborators?affiliation=direct. error");
1116
1117     assertThat(logTester.logs()).hasSize(1);
1118     assertThat(logTester.logs(Level.WARN))
1119       .containsExactly("Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/collaborators?affiliation=direct.");
1120   }
1121
1122   private static String getResponseContent(String path) throws IOException {
1123     return IOUtils.toString(GithubApplicationClientImplTest.class.getResourceAsStream(path), StandardCharsets.UTF_8);
1124   }
1125
1126   private static List<GsonRepositoryCollaborator> expectedCollaborators() {
1127     return List.of(
1128       new GsonRepositoryCollaborator("jean-michel", 1, "role1", new GsonRepositoryPermissions(true, true, true, true, true)),
1129       new GsonRepositoryCollaborator("jean-pierre", 2, "role2", new GsonRepositoryPermissions(false, false, true, true, true)));
1130   }
1131
1132   private void mockAccessTokenCallingGithubFailure() throws IOException {
1133     Response response = mock(Response.class);
1134     when(response.getContent()).thenReturn(Optional.empty());
1135     when(response.getCode()).thenReturn(HTTP_UNAUTHORIZED);
1136     when(httpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response);
1137   }
1138
1139   private AppToken mockAppToken() {
1140     String jwt = randomAlphanumeric(5);
1141     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(new AppToken(jwt));
1142     return new AppToken(jwt);
1143   }
1144
1145   private AppInstallationToken mockCreateAccessTokenCallingGithub() throws IOException {
1146     String token = randomAlphanumeric(5);
1147     Response response = mock(Response.class);
1148     when(response.getContent()).thenReturn(Optional.of("{" +
1149                                                        "  \"token\": \"" + token + "\"" +
1150                                                        "}"));
1151     when(response.getCode()).thenReturn(HTTP_CREATED);
1152     when(httpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response);
1153     return new AppInstallationToken(token);
1154   }
1155
1156   private static class OkGetResponse extends Response {
1157     private OkGetResponse(String content) {
1158       super(200, content);
1159     }
1160   }
1161
1162   private static class ErrorGetResponse extends Response {
1163     ErrorGetResponse() {
1164       super(401, null);
1165     }
1166
1167     ErrorGetResponse(int code, String content) {
1168       super(code, content);
1169     }
1170   }
1171
1172   private static class Response implements GetResponse {
1173     private final int code;
1174     private final String content;
1175     private final String nextEndPoint;
1176
1177     private Response(int code, @Nullable String content) {
1178       this(code, content, null);
1179     }
1180
1181     private Response(int code, @Nullable String content, @Nullable String nextEndPoint) {
1182       this.code = code;
1183       this.content = content;
1184       this.nextEndPoint = nextEndPoint;
1185     }
1186
1187     @Override
1188     public int getCode() {
1189       return code;
1190     }
1191
1192     @Override
1193     public Optional<String> getContent() {
1194       return Optional.ofNullable(content);
1195     }
1196
1197     @Override
1198     public RateLimit getRateLimit() {
1199       return RATE_LIMIT;
1200     }
1201
1202     @Override
1203     public Optional<String> getNextEndPoint() {
1204       return Optional.ofNullable(nextEndPoint);
1205     }
1206   }
1207 }