3 * Copyright (C) 2009-2023 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.util.List;
27 import java.util.Optional;
29 import java.util.function.Function;
30 import javax.annotation.Nullable;
31 import org.junit.Before;
32 import org.junit.ClassRule;
33 import org.junit.Test;
34 import org.junit.runner.RunWith;
35 import org.sonar.alm.client.github.GithubApplicationHttpClient.RateLimit;
36 import org.sonar.alm.client.github.config.GithubAppConfiguration;
37 import org.sonar.alm.client.github.config.GithubAppInstallation;
38 import org.sonar.alm.client.github.security.AccessToken;
39 import org.sonar.alm.client.github.security.AppToken;
40 import org.sonar.alm.client.github.security.GithubAppSecurity;
41 import org.sonar.alm.client.github.security.UserAccessToken;
42 import org.sonar.api.testfixtures.log.LogTester;
43 import org.sonar.api.utils.log.LoggerLevel;
44 import org.sonar.auth.github.GitHubSettings;
45 import org.sonarqube.ws.client.HttpException;
47 import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
48 import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
49 import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
50 import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
51 import static org.assertj.core.api.Assertions.assertThat;
52 import static org.assertj.core.api.Assertions.assertThatCode;
53 import static org.assertj.core.api.Assertions.assertThatThrownBy;
54 import static org.assertj.core.groups.Tuple.tuple;
55 import static org.mockito.ArgumentMatchers.any;
56 import static org.mockito.ArgumentMatchers.eq;
57 import static org.mockito.Mockito.mock;
58 import static org.mockito.Mockito.verify;
59 import static org.mockito.Mockito.when;
60 import static org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse;
62 @RunWith(DataProviderRunner.class)
63 public class GithubApplicationClientImplTest {
65 private static final String APP_JWT_TOKEN = "APP_TOKEN_JWT";
66 private static final String PAYLOAD_2_ORGS = """
72 "type": "Organization"
74 "target_type": "Organization",
79 "suspended_at": "2023-05-30T08:40:55Z"
85 "type": "Organization"
87 "target_type": "Organization",
95 private static final RateLimit RATE_LIMIT = new RateLimit(Integer.MAX_VALUE, Integer.MAX_VALUE, 0L);
98 public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
100 private GithubApplicationHttpClientImpl httpClient = mock();
101 private GithubAppSecurity appSecurity = mock();
102 private GithubAppConfiguration githubAppConfiguration = mock();
103 private GitHubSettings gitHubSettings = mock();
105 private GithubPaginatedHttpClient githubPaginatedHttpClient = mock();
106 private GithubApplicationClient underTest;
108 private String appUrl = "Any URL";
111 public void setup() {
112 when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
113 underTest = new GithubApplicationClientImpl(httpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient);
118 @UseDataProvider("invalidApiEndpoints")
119 public void checkApiEndpoint_Invalid(String url, String expectedMessage) {
120 GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
122 assertThatThrownBy(() -> underTest.checkApiEndpoint(configuration))
123 .isInstanceOf(IllegalArgumentException.class)
124 .hasMessage(expectedMessage);
128 public static Object[][] invalidApiEndpoints() {
129 return new Object[][] {
131 {"ftp://api.github.com", "Only http and https schemes are supported"},
132 {"https://github.com", "Invalid GitHub URL"}
137 @UseDataProvider("validApiEndpoints")
138 public void checkApiEndpoint(String url) {
139 GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
141 assertThatCode(() -> underTest.checkApiEndpoint(configuration)).isNull();
145 public static Object[][] validApiEndpoints() {
146 return new Object[][] {
147 {"https://github.sonarsource.com/api/v3"},
148 {"https://api.github.com"},
149 {"https://github.sonarsource.com/api/v3/"},
150 {"https://api.github.com/"},
151 {"HTTPS://api.github.com/"},
152 {"HTTP://api.github.com/"},
153 {"HtTpS://github.SonarSource.com/api/v3"},
154 {"HtTpS://github.sonarsource.com/api/V3"},
155 {"HtTpS://github.sonarsource.COM/ApI/v3"}
160 public void checkAppPermissions_IOException() throws IOException {
161 AppToken appToken = mockAppToken();
163 when(httpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS"));
165 assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
166 .isInstanceOf(IllegalArgumentException.class)
167 .hasMessage("Failed to validate configuration, check URL and Private Key");
171 @UseDataProvider("checkAppPermissionsErrorCodes")
172 public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException {
173 AppToken appToken = mockAppToken();
175 when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null));
177 assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
178 .isInstanceOf(IllegalArgumentException.class)
179 .hasMessage(expectedMessage);
183 public static Object[][] checkAppPermissionsErrorCodes() {
184 return new Object[][] {
185 {HTTP_UNAUTHORIZED, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
186 {HTTP_FORBIDDEN, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
187 {HTTP_NOT_FOUND, "Failed to check permissions with Github, check the configuration"}
192 public void checkAppPermissions_MissingPermissions() throws IOException {
193 AppToken appToken = mockAppToken();
195 when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}"));
197 assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
198 .isInstanceOf(IllegalArgumentException.class)
199 .hasMessage("Failed to get app permissions, unexpected response body");
203 public void checkAppPermissions_IncorrectPermissions() throws IOException {
204 AppToken appToken = mockAppToken();
207 + " \"permissions\": {\n"
208 + " \"checks\": \"read\",\n"
209 + " \"metadata\": \"read\",\n"
210 + " \"pull_requests\": \"read\"\n"
214 when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
216 assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
217 .isInstanceOf(IllegalArgumentException.class)
218 .hasMessage("Missing permissions; permission granted on pull_requests is 'read', should be 'write', checks is 'read', should be 'write'");
222 public void checkAppPermissions() throws IOException {
223 AppToken appToken = mockAppToken();
226 + " \"permissions\": {\n"
227 + " \"checks\": \"write\",\n"
228 + " \"metadata\": \"read\",\n"
229 + " \"pull_requests\": \"write\"\n"
233 when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
235 assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull();
239 @UseDataProvider("githubServers")
240 public void createUserAccessToken_returns_empty_if_access_token_cant_be_created(String apiUrl, String appUrl) throws IOException {
241 when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
242 .thenReturn(new Response(400, null));
244 assertThatThrownBy(() -> underTest.createUserAccessToken(appUrl, "clientId", "clientSecret", "code"))
245 .isInstanceOf(IllegalStateException.class);
246 verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
250 @UseDataProvider("githubServers")
251 public void createUserAccessToken_fail_if_access_token_request_fails(String apiUrl, String appUrl) throws IOException {
252 when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
253 .thenThrow(new IOException("OOPS"));
255 assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
256 .isInstanceOf(IllegalStateException.class)
257 .hasMessage("Failed to create GitHub's user access token");
259 verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
263 @UseDataProvider("githubServers")
264 public void createUserAccessToken_throws_illegal_argument_exception_if_access_token_code_is_expired(String apiUrl, String appUrl) throws IOException {
265 when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
266 .thenReturn(new OkGetResponse("error_code=100&error=expired_or_invalid"));
268 assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
269 .isInstanceOf(IllegalArgumentException.class);
271 verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
275 @UseDataProvider("githubServers")
276 public void createUserAccessToken_from_authorization_code_returns_access_token(String apiUrl, String appUrl) throws IOException {
277 String token = randomAlphanumeric(10);
278 when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
279 .thenReturn(new OkGetResponse("access_token=" + token + "&status="));
281 UserAccessToken userAccessToken = underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code");
283 assertThat(userAccessToken)
284 .extracting(UserAccessToken::getValue, UserAccessToken::getAuthorizationHeaderPrefix)
285 .containsOnly(token, "token");
286 verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
290 public void getApp_returns_id() throws IOException {
291 AppToken appToken = new AppToken(APP_JWT_TOKEN);
292 when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
293 when(httpClient.get(appUrl, appToken, "/app"))
294 .thenReturn(new OkGetResponse("{\"installations_count\": 2}"));
296 assertThat(underTest.getApp(githubAppConfiguration).getInstallationsCount()).isEqualTo(2L);
300 public void getApp_whenStatusCodeIsNotOk_shouldThrowHttpException() throws IOException {
301 AppToken appToken = new AppToken(APP_JWT_TOKEN);
302 when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
303 when(httpClient.get(appUrl, appToken, "/app"))
304 .thenReturn(new ErrorGetResponse(418, "I'm a teapot"));
306 assertThatThrownBy(() -> underTest.getApp(githubAppConfiguration))
307 .isInstanceOfSatisfying(HttpException.class, httpException -> {
308 assertThat(httpException.code()).isEqualTo(418);
309 assertThat(httpException.url()).isEqualTo("Any URL/app");
310 assertThat(httpException.content()).isEqualTo("I'm a teapot");
315 public static Object[][] githubServers() {
316 return new Object[][] {
317 {"https://github.sonarsource.com/api/v3", "https://github.sonarsource.com"},
318 {"https://api.github.com", "https://github.com"},
319 {"https://github.sonarsource.com/api/v3/", "https://github.sonarsource.com"},
320 {"https://api.github.com/", "https://github.com"},
325 public void listOrganizations_fail_on_failure() throws IOException {
326 String appUrl = "https://github.sonarsource.com";
327 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
329 when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
330 .thenThrow(new IOException("OOPS"));
332 assertThatThrownBy(() -> underTest.listOrganizations(appUrl, accessToken, 1, 100))
333 .isInstanceOf(IllegalStateException.class)
334 .hasMessage("Failed to list all organizations accessible by user access token on %s", appUrl);
338 public void listOrganizations_fail_if_pageIndex_out_of_bounds() {
339 UserAccessToken token = new UserAccessToken("token");
340 assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 0, 100))
341 .isInstanceOf(IllegalArgumentException.class)
342 .hasMessage("'page' must be larger than 0.");
346 public void listOrganizations_fail_if_pageSize_out_of_bounds() {
347 UserAccessToken token = new UserAccessToken("token");
348 assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 1, 0))
349 .isInstanceOf(IllegalArgumentException.class)
350 .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
351 assertThatThrownBy(() -> underTest.listOrganizations("", token, 1, 101))
352 .isInstanceOf(IllegalArgumentException.class)
353 .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
357 public void listOrganizations_returns_no_installations() throws IOException {
358 String appUrl = "https://github.sonarsource.com";
359 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
360 String responseJson = "{\n"
361 + " \"total_count\": 0\n"
364 when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
365 .thenReturn(new OkGetResponse(responseJson));
367 GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
369 assertThat(organizations.getTotal()).isZero();
370 assertThat(organizations.getOrganizations()).isNull();
374 public void listOrganizations_returns_pages_results() throws IOException {
375 String appUrl = "https://github.sonarsource.com";
376 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
377 String responseJson = "{\n"
378 + " \"total_count\": 2,\n"
379 + " \"installations\": [\n"
382 + " \"account\": {\n"
383 + " \"login\": \"github\",\n"
385 + " \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjE=\",\n"
386 + " \"url\": \"https://github.sonarsource.com/api/v3/orgs/github\",\n"
387 + " \"repos_url\": \"https://github.sonarsource.com/api/v3/orgs/github/repos\",\n"
388 + " \"events_url\": \"https://github.sonarsource.com/api/v3/orgs/github/events\",\n"
389 + " \"hooks_url\": \"https://github.sonarsource.com/api/v3/orgs/github/hooks\",\n"
390 + " \"issues_url\": \"https://github.sonarsource.com/api/v3/orgs/github/issues\",\n"
391 + " \"members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/members{/member}\",\n"
392 + " \"public_members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/public_members{/member}\",\n"
393 + " \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
394 + " \"description\": \"A great organization\"\n"
396 + " \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
397 + " \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
398 + " \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
399 + " \"app_id\": 1,\n"
400 + " \"target_id\": 1,\n"
401 + " \"target_type\": \"Organization\",\n"
402 + " \"permissions\": {\n"
403 + " \"checks\": \"write\",\n"
404 + " \"metadata\": \"read\",\n"
405 + " \"contents\": \"read\"\n"
409 + " \"pull_request\"\n"
411 + " \"single_file_name\": \"config.yml\"\n"
415 + " \"account\": {\n"
416 + " \"login\": \"octocat\",\n"
418 + " \"node_id\": \"MDQ6VXNlcjE=\",\n"
419 + " \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
420 + " \"gravatar_id\": \"\",\n"
421 + " \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
422 + " \"html_url\": \"https://github.com/octocat\",\n"
423 + " \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
424 + " \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
425 + " \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
426 + " \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
427 + " \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
428 + " \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
429 + " \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
430 + " \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
431 + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
432 + " \"type\": \"User\",\n"
433 + " \"site_admin\": false\n"
435 + " \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
436 + " \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
437 + " \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
438 + " \"app_id\": 1,\n"
439 + " \"target_id\": 1,\n"
440 + " \"target_type\": \"Organization\",\n"
441 + " \"permissions\": {\n"
442 + " \"checks\": \"write\",\n"
443 + " \"metadata\": \"read\",\n"
444 + " \"contents\": \"read\"\n"
448 + " \"pull_request\"\n"
450 + " \"single_file_name\": \"config.yml\"\n"
455 when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
456 .thenReturn(new OkGetResponse(responseJson));
458 GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
460 assertThat(organizations.getTotal()).isEqualTo(2);
461 assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat");
465 public void getWhitelistedGithubAppInstallations_whenWhitelistNotSpecified_doesNotFilter() throws IOException {
466 List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS);
467 assertOrgDeserialization(allOrgInstallations);
470 private static void assertOrgDeserialization(List<GithubAppInstallation> orgs) {
471 GithubAppInstallation org1 = orgs.get(0);
472 assertThat(org1.installationId()).isEqualTo("1");
473 assertThat(org1.organizationName()).isEqualTo("org1");
474 assertThat(org1.permissions().getMembers()).isEqualTo("read");
475 assertThat(org1.isSuspended()).isTrue();
477 GithubAppInstallation org2 = orgs.get(1);
478 assertThat(org2.installationId()).isEqualTo("2");
479 assertThat(org2.organizationName()).isEqualTo("org2");
480 assertThat(org2.permissions().getMembers()).isEqualTo("read");
481 assertThat(org2.isSuspended()).isFalse();
485 public void getWhitelistedGithubAppInstallations_whenWhitelistSpecified_filtersWhitelistedOrgs() throws IOException {
486 when(gitHubSettings.getOrganizations()).thenReturn(Set.of("org2"));
487 List<GithubAppInstallation> orgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS);
488 assertThat(orgInstallations)
490 .extracting(GithubAppInstallation::organizationName)
491 .containsExactlyInAnyOrder("org2");
495 public void getWhitelistedGithubAppInstallations_whenEmptyResponse_shouldReturnEmpty() throws IOException {
496 List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse("[]");
497 assertThat(allOrgInstallations).isEmpty();
501 public void getWhitelistedGithubAppInstallations_whenNoOrganization_shouldReturnEmpty() throws IOException {
502 List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse("""
510 "target_type": "User",
516 assertThat(allOrgInstallations).isEmpty();
519 @SuppressWarnings("unchecked")
520 private List<GithubAppInstallation> getGithubAppInstallationsFromGithubResponse(String content) throws IOException {
521 AppToken appToken = new AppToken(APP_JWT_TOKEN);
522 when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
523 when(githubPaginatedHttpClient.get(eq(appUrl), eq(appToken), eq("/app/installations"), any()))
524 .thenAnswer(invocation -> {
525 Function<String, List<GithubBinding.GsonInstallation>> deserializingFunction = invocation.getArgument(3, Function.class);
526 return deserializingFunction.apply(content);
528 return underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration);
532 public void getWhitelistedGithubAppInstallations_whenGithubReturnsError_shouldThrow() throws IOException {
533 AppToken appToken = new AppToken(APP_JWT_TOKEN);
534 when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
535 when(githubPaginatedHttpClient.get(any(),any(),any(),any())).thenThrow(new IOException("io exception"));
537 assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration))
538 .isInstanceOf(IllegalStateException.class)
539 .hasMessage("An error occurred when retrieving your GitHup App installations. "
540 + "It might be related to your GitHub App configuration or a connectivity problem.");
544 public void listRepositories_fail_on_failure() throws IOException {
545 String appUrl = "https://github.sonarsource.com";
546 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
548 when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "org:test", 1, 100)))
549 .thenThrow(new IOException("OOPS"));
551 assertThatThrownBy(() -> underTest.listRepositories(appUrl, accessToken, "test", null, 1, 100))
552 .isInstanceOf(IllegalStateException.class)
553 .hasMessage("Failed to list all repositories of 'test' accessible by user access token on 'https://github.sonarsource.com' using query 'fork:true+org:test'");
557 public void listRepositories_fail_if_pageIndex_out_of_bounds() {
558 UserAccessToken token = new UserAccessToken("token");
559 assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 0, 100))
560 .isInstanceOf(IllegalArgumentException.class)
561 .hasMessage("'page' must be larger than 0.");
565 public void listRepositories_fail_if_pageSize_out_of_bounds() {
566 UserAccessToken token = new UserAccessToken("token");
567 assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 1, 0))
568 .isInstanceOf(IllegalArgumentException.class)
569 .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
570 assertThatThrownBy(() -> underTest.listRepositories("", token, "test", null, 1, 101))
571 .isInstanceOf(IllegalArgumentException.class)
572 .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
576 public void listRepositories_returns_empty_results() throws IOException {
577 String appUrl = "https://github.sonarsource.com";
578 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
579 String responseJson = "{\n"
580 + " \"total_count\": 0\n"
583 when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
584 .thenReturn(new OkGetResponse(responseJson));
586 GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
588 assertThat(repositories.getTotal()).isZero();
589 assertThat(repositories.getRepositories()).isNull();
593 public void listRepositories_returns_pages_results() throws IOException {
594 String appUrl = "https://github.sonarsource.com";
595 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
596 String responseJson = "{\n"
597 + " \"total_count\": 2,\n"
598 + " \"incomplete_results\": false,\n"
601 + " \"id\": 3081286,\n"
602 + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
603 + " \"name\": \"HelloWorld\",\n"
604 + " \"full_name\": \"github/HelloWorld\",\n"
606 + " \"login\": \"github\",\n"
607 + " \"id\": 872147,\n"
608 + " \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
609 + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
610 + " \"gravatar_id\": \"\",\n"
611 + " \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
612 + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
613 + " \"type\": \"User\"\n"
615 + " \"private\": false,\n"
616 + " \"html_url\": \"https://github.com/github/HelloWorld\",\n"
617 + " \"description\": \"A C implementation of HelloWorld\",\n"
618 + " \"fork\": false,\n"
619 + " \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
620 + " \"created_at\": \"2012-01-01T00:31:50Z\",\n"
621 + " \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
622 + " \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
623 + " \"homepage\": \"\",\n"
624 + " \"size\": 524,\n"
625 + " \"stargazers_count\": 1,\n"
626 + " \"watchers_count\": 1,\n"
627 + " \"language\": \"Assembly\",\n"
628 + " \"forks_count\": 0,\n"
629 + " \"open_issues_count\": 0,\n"
630 + " \"master_branch\": \"master\",\n"
631 + " \"default_branch\": \"master\",\n"
632 + " \"score\": 1.0\n"
635 + " \"id\": 3081286,\n"
636 + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
637 + " \"name\": \"HelloUniverse\",\n"
638 + " \"full_name\": \"github/HelloUniverse\",\n"
640 + " \"login\": \"github\",\n"
641 + " \"id\": 872147,\n"
642 + " \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
643 + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
644 + " \"gravatar_id\": \"\",\n"
645 + " \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
646 + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
647 + " \"type\": \"User\"\n"
649 + " \"private\": false,\n"
650 + " \"html_url\": \"https://github.com/github/HelloUniverse\",\n"
651 + " \"description\": \"A C implementation of HelloUniverse\",\n"
652 + " \"fork\": false,\n"
653 + " \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloUniverse\",\n"
654 + " \"created_at\": \"2012-01-01T00:31:50Z\",\n"
655 + " \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
656 + " \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
657 + " \"homepage\": \"\",\n"
658 + " \"size\": 524,\n"
659 + " \"stargazers_count\": 1,\n"
660 + " \"watchers_count\": 1,\n"
661 + " \"language\": \"Assembly\",\n"
662 + " \"forks_count\": 0,\n"
663 + " \"open_issues_count\": 0,\n"
664 + " \"master_branch\": \"master\",\n"
665 + " \"default_branch\": \"master\",\n"
666 + " \"score\": 1.0\n"
671 when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
672 .thenReturn(new OkGetResponse(responseJson));
673 GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
675 assertThat(repositories.getTotal()).isEqualTo(2);
676 assertThat(repositories.getRepositories())
677 .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
678 .containsOnly(tuple("HelloWorld", "github/HelloWorld"), tuple("HelloUniverse", "github/HelloUniverse"));
682 public void listRepositories_returns_search_results() throws IOException {
683 String appUrl = "https://github.sonarsource.com";
684 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
685 String responseJson = "{\n"
686 + " \"total_count\": 2,\n"
687 + " \"incomplete_results\": false,\n"
690 + " \"id\": 3081286,\n"
691 + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
692 + " \"name\": \"HelloWorld\",\n"
693 + " \"full_name\": \"github/HelloWorld\",\n"
695 + " \"login\": \"github\",\n"
696 + " \"id\": 872147,\n"
697 + " \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
698 + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
699 + " \"gravatar_id\": \"\",\n"
700 + " \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
701 + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
702 + " \"type\": \"User\"\n"
704 + " \"private\": false,\n"
705 + " \"html_url\": \"https://github.com/github/HelloWorld\",\n"
706 + " \"description\": \"A C implementation of HelloWorld\",\n"
707 + " \"fork\": false,\n"
708 + " \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
709 + " \"created_at\": \"2012-01-01T00:31:50Z\",\n"
710 + " \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
711 + " \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
712 + " \"homepage\": \"\",\n"
713 + " \"size\": 524,\n"
714 + " \"stargazers_count\": 1,\n"
715 + " \"watchers_count\": 1,\n"
716 + " \"language\": \"Assembly\",\n"
717 + " \"forks_count\": 0,\n"
718 + " \"open_issues_count\": 0,\n"
719 + " \"master_branch\": \"master\",\n"
720 + " \"default_branch\": \"master\",\n"
721 + " \"score\": 1.0\n"
726 when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100)))
727 .thenReturn(new GetResponse() {
729 public Optional<String> getNextEndPoint() {
730 return Optional.empty();
734 public int getCode() {
739 public Optional<String> getContent() {
740 return Optional.of(responseJson);
744 public RateLimit getRateLimit() {
749 GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", "world", 1, 100);
751 assertThat(repositories.getTotal()).isEqualTo(2);
752 assertThat(repositories.getRepositories())
753 .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
754 .containsOnly(tuple("HelloWorld", "github/HelloWorld"));
758 public void getRepository_returns_empty_when_repository_doesnt_exist() throws IOException {
759 when(httpClient.get(any(), any(), any()))
760 .thenReturn(new Response(404, null));
762 Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, new UserAccessToken("temp"), "octocat", "octocat/Hello-World");
764 assertThat(repository).isEmpty();
768 public void getRepository_fails_on_failure() throws IOException {
769 String repositoryKey = "octocat/Hello-World";
770 String organization = "octocat";
772 when(httpClient.get(any(), any(), any()))
773 .thenThrow(new IOException("OOPS"));
775 UserAccessToken token = new UserAccessToken("temp");
776 assertThatThrownBy(() -> underTest.getRepository(appUrl, token, organization, repositoryKey))
777 .isInstanceOf(IllegalStateException.class)
778 .hasMessage("Failed to get repository '%s' of '%s' accessible by user access token on '%s'", repositoryKey, organization, appUrl);
782 public void getRepository_returns_repository() throws IOException {
783 String appUrl = "https://github.sonarsource.com";
784 AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
785 String responseJson = "{\n"
786 + " \"id\": 1296269,\n"
787 + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMjk2MjY5\",\n"
788 + " \"name\": \"Hello-World\",\n"
789 + " \"full_name\": \"octocat/Hello-World\",\n"
791 + " \"login\": \"octocat\",\n"
793 + " \"node_id\": \"MDQ6VXNlcjE=\",\n"
794 + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
795 + " \"gravatar_id\": \"\",\n"
796 + " \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
797 + " \"html_url\": \"https://github.com/octocat\",\n"
798 + " \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
799 + " \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
800 + " \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
801 + " \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
802 + " \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
803 + " \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
804 + " \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
805 + " \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
806 + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
807 + " \"type\": \"User\",\n"
808 + " \"site_admin\": false\n"
810 + " \"private\": false,\n"
811 + " \"html_url\": \"https://github.com/octocat/Hello-World\",\n"
812 + " \"description\": \"This your first repo!\",\n"
813 + " \"fork\": false,\n"
814 + " \"url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World\",\n"
815 + " \"archive_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/{archive_format}{/ref}\",\n"
816 + " \"assignees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/assignees{/user}\",\n"
817 + " \"blobs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/blobs{/sha}\",\n"
818 + " \"branches_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/branches{/branch}\",\n"
819 + " \"collaborators_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/collaborators{/collaborator}\",\n"
820 + " \"comments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/comments{/number}\",\n"
821 + " \"commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/commits{/sha}\",\n"
822 + " \"compare_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/compare/{base}...{head}\",\n"
823 + " \"contents_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contents/{+path}\",\n"
824 + " \"contributors_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contributors\",\n"
825 + " \"deployments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/deployments\",\n"
826 + " \"downloads_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/downloads\",\n"
827 + " \"events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/events\",\n"
828 + " \"forks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/forks\",\n"
829 + " \"git_commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/commits{/sha}\",\n"
830 + " \"git_refs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/refs{/sha}\",\n"
831 + " \"git_tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/tags{/sha}\",\n"
832 + " \"git_url\": \"git:github.com/octocat/Hello-World.git\",\n"
833 + " \"issue_comment_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/comments{/number}\",\n"
834 + " \"issue_events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/events{/number}\",\n"
835 + " \"issues_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues{/number}\",\n"
836 + " \"keys_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/keys{/key_id}\",\n"
837 + " \"labels_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/labels{/name}\",\n"
838 + " \"languages_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/languages\",\n"
839 + " \"merges_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/merges\",\n"
840 + " \"milestones_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/milestones{/number}\",\n"
841 + " \"notifications_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/notifications{?since,all,participating}\",\n"
842 + " \"pulls_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/pulls{/number}\",\n"
843 + " \"releases_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/releases{/id}\",\n"
844 + " \"ssh_url\": \"git@github.com:octocat/Hello-World.git\",\n"
845 + " \"stargazers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/stargazers\",\n"
846 + " \"statuses_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/statuses/{sha}\",\n"
847 + " \"subscribers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscribers\",\n"
848 + " \"subscription_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscription\",\n"
849 + " \"tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/tags\",\n"
850 + " \"teams_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/teams\",\n"
851 + " \"trees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/trees{/sha}\",\n"
852 + " \"clone_url\": \"https://github.com/octocat/Hello-World.git\",\n"
853 + " \"mirror_url\": \"git:git.example.com/octocat/Hello-World\",\n"
854 + " \"hooks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/hooks\",\n"
855 + " \"svn_url\": \"https://svn.github.com/octocat/Hello-World\",\n"
856 + " \"homepage\": \"https://github.com\",\n"
857 + " \"language\": null,\n"
858 + " \"forks_count\": 9,\n"
859 + " \"stargazers_count\": 80,\n"
860 + " \"watchers_count\": 80,\n"
861 + " \"size\": 108,\n"
862 + " \"default_branch\": \"master\",\n"
863 + " \"open_issues_count\": 0,\n"
864 + " \"is_template\": true,\n"
871 + " \"has_issues\": true,\n"
872 + " \"has_projects\": true,\n"
873 + " \"has_wiki\": true,\n"
874 + " \"has_pages\": false,\n"
875 + " \"has_downloads\": true,\n"
876 + " \"archived\": false,\n"
877 + " \"disabled\": false,\n"
878 + " \"visibility\": \"public\",\n"
879 + " \"pushed_at\": \"2011-01-26T19:06:43Z\",\n"
880 + " \"created_at\": \"2011-01-26T19:01:12Z\",\n"
881 + " \"updated_at\": \"2011-01-26T19:14:43Z\",\n"
882 + " \"permissions\": {\n"
883 + " \"admin\": false,\n"
884 + " \"push\": false,\n"
885 + " \"pull\": true\n"
887 + " \"allow_rebase_merge\": true,\n"
888 + " \"template_repository\": null,\n"
889 + " \"allow_squash_merge\": true,\n"
890 + " \"allow_merge_commit\": true,\n"
891 + " \"subscribers_count\": 42,\n"
892 + " \"network_count\": 0,\n"
893 + " \"anonymous_access_enabled\": false,\n"
894 + " \"license\": {\n"
895 + " \"key\": \"mit\",\n"
896 + " \"name\": \"MIT License\",\n"
897 + " \"spdx_id\": \"MIT\",\n"
898 + " \"url\": \"https://github.sonarsource.com/api/v3/licenses/mit\",\n"
899 + " \"node_id\": \"MDc6TGljZW5zZW1pdA==\"\n"
901 + " \"organization\": {\n"
902 + " \"login\": \"octocat\",\n"
904 + " \"node_id\": \"MDQ6VXNlcjE=\",\n"
905 + " \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
906 + " \"gravatar_id\": \"\",\n"
907 + " \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
908 + " \"html_url\": \"https://github.com/octocat\",\n"
909 + " \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
910 + " \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
911 + " \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
912 + " \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
913 + " \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
914 + " \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
915 + " \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
916 + " \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
917 + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
918 + " \"type\": \"Organization\",\n"
919 + " \"site_admin\": false\n"
923 when(httpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
924 .thenReturn(new GetResponse() {
926 public Optional<String> getNextEndPoint() {
927 return Optional.empty();
931 public int getCode() {
936 public Optional<String> getContent() {
937 return Optional.of(responseJson);
941 public RateLimit getRateLimit() {
946 Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, accessToken, "octocat", "octocat/Hello-World");
948 assertThat(repository)
951 .extracting(GithubApplicationClient.Repository::getId, GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName,
952 GithubApplicationClient.Repository::getUrl, GithubApplicationClient.Repository::isPrivate, GithubApplicationClient.Repository::getDefaultBranch)
953 .containsOnly(1296269L, "Hello-World", "octocat/Hello-World", "https://github.com/octocat/Hello-World", false, "master");
956 private AppToken mockAppToken() {
957 String jwt = randomAlphanumeric(5);
958 when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(new AppToken(jwt));
959 return new AppToken(jwt);
962 private static class OkGetResponse extends Response {
963 private OkGetResponse(String content) {
968 private static class ErrorGetResponse extends Response {
973 ErrorGetResponse(int code, String content) {
974 super(code, content);
978 private static class Response implements GetResponse {
979 private final int code;
980 private final String content;
981 private final String nextEndPoint;
983 private Response(int code, @Nullable String content) {
984 this(code, content, null);
987 private Response(int code, @Nullable String content, @Nullable String nextEndPoint) {
989 this.content = content;
990 this.nextEndPoint = nextEndPoint;
994 public int getCode() {
999 public Optional<String> getContent() {
1000 return Optional.ofNullable(content);
1004 public RateLimit getRateLimit() {
1009 public Optional<String> getNextEndPoint() {
1010 return Optional.ofNullable(nextEndPoint);