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