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