3 * Copyright (C) 2009-2024 SonarSource SA
4 * mailto:info AT sonarsource DOT com
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU Lesser General Public
8 * License as published by the Free Software Foundation; either
9 * version 3 of the License, or (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 * Lesser General Public License for more details.
16 * You should have received a copy of the GNU Lesser General Public License
17 * along with this program; if not, write to the Free Software Foundation,
18 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 package org.sonar.alm.client.github;
22 import com.tngtech.java.junit.dataprovider.DataProvider;
23 import com.tngtech.java.junit.dataprovider.DataProviderRunner;
24 import com.tngtech.java.junit.dataprovider.UseDataProvider;
25 import java.io.IOException;
26 import java.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;
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;
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;
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 = """
97 "type": "Organization"
99 "target_type": "Organization",
104 "suspended_at": "2023-05-30T08:40:55Z"
110 "type": "Organization"
112 "target_type": "Organization",
120 private static final RateLimit RATE_LIMIT = new RateLimit(Integer.MAX_VALUE, Integer.MAX_VALUE, 0L);
123 public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
125 private GithubApplicationHttpClient githubApplicationHttpClient = mock();
126 private GithubAppSecurity appSecurity = mock();
127 private GithubAppConfiguration githubAppConfiguration = mock();
128 private GitHubSettings gitHubSettings = mock();
130 private GithubPaginatedHttpClient githubPaginatedHttpClient = mock();
131 private AppInstallationToken appInstallationToken = mock();
132 private GithubApplicationClient underTest;
134 private Clock clock = Clock.fixed(Instant.EPOCH, ZoneId.systemDefault());
135 private String appUrl = "Any URL";
138 public void setup() {
139 when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
140 underTest = new GithubApplicationClientImpl(clock, githubApplicationHttpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient);
145 @UseDataProvider("invalidApiEndpoints")
146 public void checkApiEndpoint_Invalid(String url, String expectedMessage) {
147 GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
149 assertThatThrownBy(() -> underTest.checkApiEndpoint(configuration))
150 .isInstanceOf(IllegalArgumentException.class)
151 .hasMessage(expectedMessage);
155 public static Object[][] invalidApiEndpoints() {
156 return new Object[][] {
158 {"ftp://api.github.com", "Only http and https schemes are supported"},
159 {"https://github.com", "Invalid GitHub URL"}
164 @UseDataProvider("validApiEndpoints")
165 public void checkApiEndpoint(String url) {
166 GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
168 assertThatCode(() -> underTest.checkApiEndpoint(configuration)).isNull();
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"}
187 public void checkAppPermissions_IOException() throws IOException {
188 AppToken appToken = mockAppToken();
190 when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS"));
192 assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
193 .isInstanceOf(IllegalArgumentException.class)
194 .hasMessage("Failed to validate configuration, check URL and Private Key");
198 @UseDataProvider("checkAppPermissionsErrorCodes")
199 public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException {
200 AppToken appToken = mockAppToken();
202 when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null));
204 assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
205 .isInstanceOf(IllegalArgumentException.class)
206 .hasMessage(expectedMessage);
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"}
219 public void checkAppPermissions_MissingPermissions() throws IOException {
220 AppToken appToken = mockAppToken();
222 when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}"));
224 assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
225 .isInstanceOf(IllegalArgumentException.class)
226 .hasMessage("Failed to get app permissions, unexpected response body");
230 public void checkAppPermissions_IncorrectPermissions() throws IOException {
231 AppToken appToken = mockAppToken();
238 "pull_requests": "read"
243 when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
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'");
251 public void checkAppPermissions() throws IOException {
252 AppToken appToken = mockAppToken();
259 "pull_requests": "write"
264 when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
266 assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull();
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("""
282 assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds/linux")).hasValue(2L);
286 public void getInstallationId_throws_IAE_if_fail_to_create_app_token() {
287 when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenThrow(IllegalArgumentException.class);
289 assertThatThrownBy(() -> underTest.getInstallationId(githubAppConfiguration, "torvalds"))
290 .isInstanceOf(IllegalArgumentException.class);
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));
300 assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds")).isEmpty();
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));
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");
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"));
320 assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
321 .isInstanceOf(IllegalStateException.class)
322 .hasMessage("Failed to create GitHub's user access token");
324 verify(githubApplicationHttpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
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"));
333 assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
334 .isInstanceOf(IllegalArgumentException.class);
336 verify(githubApplicationHttpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
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="));
346 UserAccessToken userAccessToken = underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code");
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");
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}"));
361 assertThat(underTest.getApp(githubAppConfiguration).getInstallationsCount()).isEqualTo(2L);
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"));
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");
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"},
390 public void listOrganizations_fail_on_failure() throws IOException {
391 String appUrl = "https://github.sonarsource.com";
392 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
394 when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100)))
395 .thenThrow(new IOException("OOPS"));
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);
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.");
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.");
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 = """
431 when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100)))
432 .thenReturn(new OkGetResponse(responseJson));
434 GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
436 assertThat(organizations.getTotal()).isZero();
437 assertThat(organizations.getOrganizations()).isNull();
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 = """
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"
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",
469 "target_type": "Organization",
479 "single_file_name": "config.yml"
486 "node_id": "MDQ6VXNlcjE=",
487 "avatar_url": "https://github.com/images/error/octocat_happy.gif",
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",
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",
508 "target_type": "Organization",
518 "single_file_name": "config.yml"
524 when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100)))
525 .thenReturn(new OkGetResponse(responseJson));
527 GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
529 assertThat(organizations.getTotal()).isEqualTo(2);
530 assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat");
534 public void getWhitelistedGithubAppInstallations_whenWhitelistNotSpecified_doesNotFilter() throws IOException {
535 List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS);
536 assertOrgDeserialization(allOrgInstallations);
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();
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();
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)
559 .extracting(GithubAppInstallation::organizationName)
560 .containsExactlyInAnyOrder("org2");
564 public void getWhitelistedGithubAppInstallations_whenEmptyResponse_shouldReturnEmpty() throws IOException {
565 List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse("[]");
566 assertThat(allOrgInstallations).isEmpty();
570 public void getWhitelistedGithubAppInstallations_whenNoOrganization_shouldReturnEmpty() throws IOException {
571 List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse("""
579 "target_type": "User",
585 assertThat(allOrgInstallations).isEmpty();
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);
597 return underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration);
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"));
606 assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration))
607 .isInstanceOf(IllegalStateException.class)
608 .hasMessage("exception");
612 public void listRepositories_fail_on_failure() throws IOException {
613 String appUrl = "https://github.sonarsource.com";
614 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
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"));
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'");
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.");
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.");
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"
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));
654 GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
656 assertThat(repositories.getTotal()).isZero();
657 assertThat(repositories.getRepositories()).isNull();
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 = """
667 "incomplete_results": false,
671 "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
672 "name": "HelloWorld",
673 "full_name": "github/HelloWorld",
677 "node_id": "MDQ6VXNlcjg3MjE0Nw==",
678 "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif",
680 "url": "https://github.sonarsource.com/api/v3/users/github",
681 "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events",
685 "html_url": "https://github.com/github/HelloWorld",
686 "description": "A C implementation of HelloWorld",
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",
694 "stargazers_count": 1,
696 "language": "Assembly",
698 "open_issues_count": 0,
699 "master_branch": "master",
700 "default_branch": "master",
705 "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
706 "name": "HelloUniverse",
707 "full_name": "github/HelloUniverse",
711 "node_id": "MDQ6VXNlcjg3MjE0Nw==",
712 "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif",
714 "url": "https://github.sonarsource.com/api/v3/users/github",
715 "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events",
719 "html_url": "https://github.com/github/HelloUniverse",
720 "description": "A C implementation of HelloUniverse",
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",
728 "stargazers_count": 1,
730 "language": "Assembly",
732 "open_issues_count": 0,
733 "master_branch": "master",
734 "default_branch": "master",
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);
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"));
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 = """
757 "incomplete_results": false,
761 "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
762 "name": "HelloWorld",
763 "full_name": "github/HelloWorld",
767 "node_id": "MDQ6VXNlcjg3MjE0Nw==",
768 "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif",
770 "url": "https://github.sonarsource.com/api/v3/users/github",
771 "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events",
775 "html_url": "https://github.com/github/HelloWorld",
776 "description": "A C implementation of HelloWorld",
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",
784 "stargazers_count": 1,
786 "language": "Assembly",
788 "open_issues_count": 0,
789 "master_branch": "master",
790 "default_branch": "master",
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() {
799 public Optional<String> getNextEndPoint() {
800 return Optional.empty();
804 public int getCode() {
809 public Optional<String> getContent() {
810 return Optional.of(responseJson);
814 public RateLimit getRateLimit() {
819 GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", "world", 1, 100);
821 assertThat(repositories.getTotal()).isEqualTo(2);
822 assertThat(repositories.getRepositories())
823 .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
824 .containsOnly(tuple("HelloWorld", "github/HelloWorld"));
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));
832 Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, new UserAccessToken("temp"), "octocat/Hello-World");
834 assertThat(repository).isEmpty();
838 public void getRepository_fails_on_failure() throws IOException {
839 String repositoryKey = "octocat/Hello-World";
841 when(githubApplicationHttpClient.get(any(), any(), any()))
842 .thenThrow(new IOException("OOPS"));
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)");
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"
860 + " \"login\": \"octocat\",\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"
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"
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"
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"
970 + " \"organization\": {\n"
971 + " \"login\": \"octocat\",\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"
992 when(githubApplicationHttpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
993 .thenReturn(new GetResponse() {
995 public Optional<String> getNextEndPoint() {
996 return Optional.empty();
1000 public int getCode() {
1005 public Optional<String> getContent() {
1006 return Optional.of(responseJson);
1010 public RateLimit getRateLimit() {
1015 Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, accessToken, "octocat/Hello-World");
1017 assertThat(repository)
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");
1026 public void createAppInstallationToken_throws_IAE_if_application_token_cant_be_created() {
1027 mockNoApplicationJwtToken();
1029 assertThatThrownBy(() -> underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID))
1030 .isInstanceOf(IllegalArgumentException.class);
1033 private void mockNoApplicationJwtToken() {
1034 when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenThrow(IllegalArgumentException.class);
1038 public void createAppInstallationToken_returns_empty_if_post_throws_IOE() throws IOException {
1040 when(githubApplicationHttpClient.post(anyString(), any(AccessToken.class), anyString())).thenThrow(IOException.class);
1041 Optional<ExpiringAppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
1043 assertThat(accessToken).isEmpty();
1044 assertThat(logTester.getLogs(Level.WARN)).extracting(LogAndArguments::getRawMsg).anyMatch(s -> s.startsWith("Failed to request"));
1048 public void createAppInstallationToken_returns_empty_if_access_token_cant_be_created() throws IOException {
1049 AppToken appToken = mockAppToken();
1050 mockAccessTokenCallingGithubFailure();
1052 Optional<ExpiringAppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
1054 assertThat(accessToken).isEmpty();
1055 verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
1059 public void createAppInstallationToken_from_installation_id_returns_access_token() throws IOException {
1060 AppToken appToken = mockAppToken();
1061 ExpiringAppInstallationToken installToken = mockCreateAccessTokenCallingGithub();
1063 Optional<ExpiringAppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
1065 assertThat(accessToken).hasValue(installToken);
1066 verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
1070 public void getRepositoryTeams_returnsRepositoryTeams() throws IOException {
1071 ArgumentCaptor<Function<String, List<GsonRepositoryTeam>>> deserializerCaptor = ArgumentCaptor.forClass(Function.class);
1073 when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedTeams());
1075 Set<GsonRepositoryTeam> repoTeams = underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME);
1077 assertThat(repoTeams)
1078 .containsExactlyInAnyOrderElementsOf(expectedTeams());
1080 String responseContent = getResponseContent("repo-teams-full-response.json");
1081 assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedTeams());
1085 public void getRepositoryTeams_whenGitHubCallThrowsException_shouldRethrow() {
1086 when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), any())).thenThrow(new IllegalStateException("error"));
1088 assertThatIllegalStateException()
1089 .isThrownBy(() -> underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME))
1090 .withMessage("error");
1093 private static List<GsonRepositoryTeam> expectedTeams() {
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)));
1100 public void getRepositoryCollaborators_returnsCollaboratorsFromGithub() throws IOException {
1101 ArgumentCaptor<Function<String, List<GsonRepositoryCollaborator>>> deserializerCaptor = ArgumentCaptor.forClass(Function.class);
1103 when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedCollaborators());
1105 Set<GsonRepositoryCollaborator> repoTeams = underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME);
1107 assertThat(repoTeams)
1108 .containsExactlyInAnyOrderElementsOf(expectedCollaborators());
1110 String responseContent = getResponseContent("repo-collaborators-full-response.json");
1111 assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedCollaborators());
1116 public void getRepositoryCollaborators_whenGitHubCallThrowsException_shouldRethrow() {
1117 when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), any())).thenThrow(new IllegalStateException("error"));
1119 assertThatIllegalStateException()
1120 .isThrownBy(() -> underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME))
1121 .withMessage("error");
1124 private static String getResponseContent(String path) throws IOException {
1125 return IOUtils.toString(GithubApplicationClientImplTest.class.getResourceAsStream(path), StandardCharsets.UTF_8);
1128 private static List<GsonRepositoryCollaborator> expectedCollaborators() {
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)));
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);
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);
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("""
1153 "expires_at": "2024-08-28T10:44:51Z",
1156 "organization_administration": "read",
1157 "administration": "read",
1160 "repository_selection": "all"
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");
1168 private static class OkGetResponse extends Response {
1169 private OkGetResponse(String content) {
1170 super(200, content);
1174 private static class ErrorGetResponse extends Response {
1175 ErrorGetResponse() {
1179 ErrorGetResponse(int code, String content) {
1180 super(code, content);
1184 private static class Response implements GetResponse {
1185 private final int code;
1186 private final String content;
1187 private final String nextEndPoint;
1189 private Response(int code, @Nullable String content) {
1190 this(code, content, null);
1193 private Response(int code, @Nullable String content, @Nullable String nextEndPoint) {
1195 this.content = content;
1196 this.nextEndPoint = nextEndPoint;
1200 public int getCode() {
1205 public Optional<String> getContent() {
1206 return Optional.ofNullable(content);
1210 public RateLimit getRateLimit() {
1215 public Optional<String> getNextEndPoint() {
1216 return Optional.ofNullable(nextEndPoint);