]> source.dussan.org Git - sonarqube.git/blob
fccb783fffdeac612dd70ac009e42ee8f1a0b680
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2023 SonarSource SA
4  * mailto:info AT sonarsource DOT com
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU Lesser General Public
8  * License as published by the Free Software Foundation; either
9  * version 3 of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14  * Lesser General Public License for more details.
15  *
16  * You should have received a copy of the GNU Lesser General Public License
17  * along with this program; if not, write to the Free Software Foundation,
18  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19  */
20 package org.sonar.alm.client.github;
21
22 import com.tngtech.java.junit.dataprovider.DataProvider;
23 import com.tngtech.java.junit.dataprovider.DataProviderRunner;
24 import com.tngtech.java.junit.dataprovider.UseDataProvider;
25 import java.io.IOException;
26 import java.util.Optional;
27 import javax.annotation.Nullable;
28 import org.junit.Before;
29 import org.junit.ClassRule;
30 import org.junit.Test;
31 import org.junit.runner.RunWith;
32 import org.sonar.alm.client.github.config.GithubAppConfiguration;
33 import org.sonar.alm.client.github.security.AccessToken;
34 import org.sonar.alm.client.github.security.AppToken;
35 import org.sonar.alm.client.github.security.GithubAppSecurity;
36 import org.sonar.alm.client.github.security.UserAccessToken;
37 import org.sonar.api.utils.log.LogTester;
38 import org.sonar.api.utils.log.LoggerLevel;
39
40 import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
41 import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
42 import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
43 import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
44 import static org.assertj.core.api.Assertions.assertThat;
45 import static org.assertj.core.api.Assertions.assertThatCode;
46 import static org.assertj.core.api.Assertions.assertThatThrownBy;
47 import static org.assertj.core.groups.Tuple.tuple;
48 import static org.mockito.ArgumentMatchers.any;
49 import static org.mockito.Mockito.mock;
50 import static org.mockito.Mockito.verify;
51 import static org.mockito.Mockito.when;
52
53 @RunWith(DataProviderRunner.class)
54 public class GithubApplicationClientImplTest {
55
56   @ClassRule
57   public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
58
59   private GithubApplicationHttpClientImpl httpClient = mock(GithubApplicationHttpClientImpl.class);
60   private GithubAppSecurity appSecurity = mock(GithubAppSecurity.class);
61   private GithubAppConfiguration githubAppConfiguration = mock(GithubAppConfiguration.class);
62   private GithubApplicationClient underTest;
63
64   private String appUrl = "Any URL";
65
66   @Before
67   public void setup() {
68     when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
69     underTest = new GithubApplicationClientImpl(httpClient, appSecurity);
70     logTester.clear();
71   }
72
73   @Test
74   @UseDataProvider("invalidApiEndpoints")
75   public void checkApiEndpoint_Invalid(String url, String expectedMessage) {
76     GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
77
78     assertThatThrownBy(() -> underTest.checkApiEndpoint(configuration))
79       .isInstanceOf(IllegalArgumentException.class)
80       .hasMessage(expectedMessage);
81   }
82
83   @DataProvider
84   public static Object[][] invalidApiEndpoints() {
85     return new Object[][] {
86       {"", "Missing URL"},
87       {"ftp://api.github.com", "Only http and https schemes are supported"},
88       {"https://github.com", "Invalid GitHub URL"}
89     };
90   }
91
92   @Test
93   @UseDataProvider("validApiEndpoints")
94   public void checkApiEndpoint(String url) {
95     GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
96
97     assertThatCode(() -> underTest.checkApiEndpoint(configuration)).isNull();
98   }
99
100   @DataProvider
101   public static Object[][] validApiEndpoints() {
102     return new Object[][] {
103       {"https://github.sonarsource.com/api/v3"},
104       {"https://api.github.com"},
105       {"https://github.sonarsource.com/api/v3/"},
106       {"https://api.github.com/"},
107       {"HTTPS://api.github.com/"},
108       {"HTTP://api.github.com/"},
109       {"HtTpS://github.SonarSource.com/api/v3"},
110       {"HtTpS://github.sonarsource.com/api/V3"},
111       {"HtTpS://github.sonarsource.COM/ApI/v3"}
112     };
113   }
114
115   @Test
116   public void checkAppPermissions_IOException() throws IOException {
117     AppToken appToken = mockAppToken();
118
119     when(httpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS"));
120
121     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
122       .isInstanceOf(IllegalArgumentException.class)
123       .hasMessage("Failed to validate configuration, check URL and Private Key");
124   }
125
126   @Test
127   @UseDataProvider("checkAppPermissionsErrorCodes")
128   public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException {
129     AppToken appToken = mockAppToken();
130
131     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null));
132
133     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
134       .isInstanceOf(IllegalArgumentException.class)
135       .hasMessage(expectedMessage);
136   }
137
138   @DataProvider
139   public static Object[][] checkAppPermissionsErrorCodes() {
140     return new Object[][] {
141       {HTTP_UNAUTHORIZED, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
142       {HTTP_FORBIDDEN, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
143       {HTTP_NOT_FOUND, "Failed to check permissions with Github, check the configuration"}
144     };
145   }
146
147   @Test
148   public void checkAppPermissions_MissingPermissions() throws IOException {
149     AppToken appToken = mockAppToken();
150
151     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}"));
152
153     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
154       .isInstanceOf(IllegalArgumentException.class)
155       .hasMessage("Failed to get app permissions, unexpected response body");
156   }
157
158   @Test
159   public void checkAppPermissions_IncorrectPermissions() throws IOException {
160     AppToken appToken = mockAppToken();
161
162     String json = "{"
163       + "      \"permissions\": {\n"
164       + "        \"checks\": \"read\",\n"
165       + "        \"metadata\": \"read\",\n"
166       + "        \"statuses\": \"read\",\n"
167       + "        \"pull_requests\": \"read\"\n"
168       + "      }\n"
169       + "}";
170
171     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
172
173     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
174       .isInstanceOf(IllegalArgumentException.class)
175       .hasMessage("Missing permissions; permission granted on pull_requests is 'read', should be 'write', checks is 'read', should be 'write'");
176   }
177
178   @Test
179   public void checkAppPermissions() throws IOException {
180     AppToken appToken = mockAppToken();
181
182     String json = "{"
183       + "      \"permissions\": {\n"
184       + "        \"checks\": \"write\",\n"
185       + "        \"metadata\": \"read\",\n"
186       + "        \"statuses\": \"read\",\n"
187       + "        \"pull_requests\": \"write\"\n"
188       + "      }\n"
189       + "}";
190
191     when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
192
193     assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull();
194   }
195
196   @Test
197   @UseDataProvider("githubServers")
198   public void createUserAccessToken_returns_empty_if_access_token_cant_be_created(String apiUrl, String appUrl) throws IOException {
199     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
200       .thenReturn(new Response(400, null));
201
202     assertThatThrownBy(() -> underTest.createUserAccessToken(appUrl, "clientId", "clientSecret", "code"))
203       .isInstanceOf(IllegalStateException.class);
204     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
205   }
206
207   @Test
208   @UseDataProvider("githubServers")
209   public void createUserAccessToken_fail_if_access_token_request_fails(String apiUrl, String appUrl) throws IOException {
210     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
211       .thenThrow(new IOException("OOPS"));
212
213     assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
214       .isInstanceOf(IllegalStateException.class)
215       .hasMessage("Failed to create GitHub's user access token");
216
217     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
218   }
219
220   @Test
221   @UseDataProvider("githubServers")
222   public void createUserAccessToken_throws_illegal_argument_exception_if_access_token_code_is_expired(String apiUrl, String appUrl) throws IOException {
223     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
224       .thenReturn(new OkGetResponse("error_code=100&error=expired_or_invalid"));
225
226     assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
227       .isInstanceOf(IllegalArgumentException.class);
228
229     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
230   }
231
232   @Test
233   @UseDataProvider("githubServers")
234   public void createUserAccessToken_from_authorization_code_returns_access_token(String apiUrl, String appUrl) throws IOException {
235     String token = randomAlphanumeric(10);
236     when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
237       .thenReturn(new OkGetResponse("access_token=" + token + "&status="));
238
239     UserAccessToken userAccessToken = underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code");
240
241     assertThat(userAccessToken)
242       .extracting(UserAccessToken::getValue, UserAccessToken::getAuthorizationHeaderPrefix)
243       .containsOnly(token, "token");
244     verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
245   }
246
247   @DataProvider
248   public static Object[][] githubServers() {
249     return new Object[][] {
250       {"https://github.sonarsource.com/api/v3", "https://github.sonarsource.com"},
251       {"https://api.github.com", "https://github.com"},
252       {"https://github.sonarsource.com/api/v3/", "https://github.sonarsource.com"},
253       {"https://api.github.com/", "https://github.com"},
254     };
255   }
256
257   @Test
258   public void listOrganizations_fail_on_failure() throws IOException {
259     String appUrl = "https://github.sonarsource.com";
260     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
261
262     when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
263       .thenThrow(new IOException("OOPS"));
264
265     assertThatThrownBy(() -> underTest.listOrganizations(appUrl, accessToken, 1, 100))
266       .isInstanceOf(IllegalStateException.class)
267       .hasMessage("Failed to list all organizations accessible by user access token on %s", appUrl);
268   }
269
270   @Test
271   public void listOrganizations_fail_if_pageIndex_out_of_bounds() {
272     UserAccessToken token = new UserAccessToken("token");
273     assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 0, 100))
274       .isInstanceOf(IllegalArgumentException.class)
275       .hasMessage("'page' must be larger than 0.");
276   }
277
278   @Test
279   public void listOrganizations_fail_if_pageSize_out_of_bounds() {
280     UserAccessToken token = new UserAccessToken("token");
281     assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 1, 0))
282       .isInstanceOf(IllegalArgumentException.class)
283       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
284     assertThatThrownBy(() -> underTest.listOrganizations("", token, 1, 101))
285       .isInstanceOf(IllegalArgumentException.class)
286       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
287   }
288
289   @Test
290   public void listOrganizations_returns_no_installations() throws IOException {
291     String appUrl = "https://github.sonarsource.com";
292     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
293     String responseJson = "{\n"
294       + "  \"total_count\": 0\n"
295       + "} ";
296
297     when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
298       .thenReturn(new OkGetResponse(responseJson));
299
300     GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
301
302     assertThat(organizations.getTotal()).isZero();
303     assertThat(organizations.getOrganizations()).isNull();
304   }
305
306   @Test
307   public void listOrganizations_returns_pages_results() throws IOException {
308     String appUrl = "https://github.sonarsource.com";
309     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
310     String responseJson = "{\n"
311       + "  \"total_count\": 2,\n"
312       + "  \"installations\": [\n"
313       + "    {\n"
314       + "      \"id\": 1,\n"
315       + "      \"account\": {\n"
316       + "        \"login\": \"github\",\n"
317       + "        \"id\": 1,\n"
318       + "        \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjE=\",\n"
319       + "        \"url\": \"https://github.sonarsource.com/api/v3/orgs/github\",\n"
320       + "        \"repos_url\": \"https://github.sonarsource.com/api/v3/orgs/github/repos\",\n"
321       + "        \"events_url\": \"https://github.sonarsource.com/api/v3/orgs/github/events\",\n"
322       + "        \"hooks_url\": \"https://github.sonarsource.com/api/v3/orgs/github/hooks\",\n"
323       + "        \"issues_url\": \"https://github.sonarsource.com/api/v3/orgs/github/issues\",\n"
324       + "        \"members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/members{/member}\",\n"
325       + "        \"public_members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/public_members{/member}\",\n"
326       + "        \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
327       + "        \"description\": \"A great organization\"\n"
328       + "      },\n"
329       + "      \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
330       + "      \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
331       + "      \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
332       + "      \"app_id\": 1,\n"
333       + "      \"target_id\": 1,\n"
334       + "      \"target_type\": \"Organization\",\n"
335       + "      \"permissions\": {\n"
336       + "        \"checks\": \"write\",\n"
337       + "        \"metadata\": \"read\",\n"
338       + "        \"contents\": \"read\"\n"
339       + "      },\n"
340       + "      \"events\": [\n"
341       + "        \"push\",\n"
342       + "        \"pull_request\"\n"
343       + "      ],\n"
344       + "      \"single_file_name\": \"config.yml\"\n"
345       + "    },\n"
346       + "    {\n"
347       + "      \"id\": 3,\n"
348       + "      \"account\": {\n"
349       + "        \"login\": \"octocat\",\n"
350       + "        \"id\": 2,\n"
351       + "        \"node_id\": \"MDQ6VXNlcjE=\",\n"
352       + "        \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
353       + "        \"gravatar_id\": \"\",\n"
354       + "        \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
355       + "        \"html_url\": \"https://github.com/octocat\",\n"
356       + "        \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
357       + "        \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
358       + "        \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
359       + "        \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
360       + "        \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
361       + "        \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
362       + "        \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
363       + "        \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
364       + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
365       + "        \"type\": \"User\",\n"
366       + "        \"site_admin\": false\n"
367       + "      },\n"
368       + "      \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
369       + "      \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
370       + "      \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
371       + "      \"app_id\": 1,\n"
372       + "      \"target_id\": 1,\n"
373       + "      \"target_type\": \"Organization\",\n"
374       + "      \"permissions\": {\n"
375       + "        \"checks\": \"write\",\n"
376       + "        \"metadata\": \"read\",\n"
377       + "        \"contents\": \"read\"\n"
378       + "      },\n"
379       + "      \"events\": [\n"
380       + "        \"push\",\n"
381       + "        \"pull_request\"\n"
382       + "      ],\n"
383       + "      \"single_file_name\": \"config.yml\"\n"
384       + "    }\n"
385       + "  ]\n"
386       + "} ";
387
388     when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
389       .thenReturn(new OkGetResponse(responseJson));
390
391     GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
392
393     assertThat(organizations.getTotal()).isEqualTo(2);
394     assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat");
395   }
396
397   @Test
398   public void listRepositories_fail_on_failure() throws IOException {
399     String appUrl = "https://github.sonarsource.com";
400     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
401
402     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "org:test", 1, 100)))
403       .thenThrow(new IOException("OOPS"));
404
405     assertThatThrownBy(() -> underTest.listRepositories(appUrl, accessToken, "test", null, 1, 100))
406       .isInstanceOf(IllegalStateException.class)
407       .hasMessage("Failed to list all repositories of 'test' accessible by user access token on 'https://github.sonarsource.com' using query 'fork:true+org:test'");
408   }
409
410   @Test
411   public void listRepositories_fail_if_pageIndex_out_of_bounds() {
412     UserAccessToken token = new UserAccessToken("token");
413     assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 0, 100))
414       .isInstanceOf(IllegalArgumentException.class)
415       .hasMessage("'page' must be larger than 0.");
416   }
417
418   @Test
419   public void listRepositories_fail_if_pageSize_out_of_bounds() {
420     UserAccessToken token = new UserAccessToken("token");
421     assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 1, 0))
422       .isInstanceOf(IllegalArgumentException.class)
423       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
424     assertThatThrownBy(() -> underTest.listRepositories("", token, "test", null, 1, 101))
425       .isInstanceOf(IllegalArgumentException.class)
426       .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
427   }
428
429   @Test
430   public void listRepositories_returns_empty_results() throws IOException {
431     String appUrl = "https://github.sonarsource.com";
432     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
433     String responseJson = "{\n"
434       + "  \"total_count\": 0\n"
435       + "}";
436
437     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
438       .thenReturn(new OkGetResponse(responseJson));
439
440     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
441
442     assertThat(repositories.getTotal()).isZero();
443     assertThat(repositories.getRepositories()).isNull();
444   }
445
446   @Test
447   public void listRepositories_returns_pages_results() throws IOException {
448     String appUrl = "https://github.sonarsource.com";
449     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
450     String responseJson = "{\n"
451       + "  \"total_count\": 2,\n"
452       + "  \"incomplete_results\": false,\n"
453       + "  \"items\": [\n"
454       + "    {\n"
455       + "      \"id\": 3081286,\n"
456       + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
457       + "      \"name\": \"HelloWorld\",\n"
458       + "      \"full_name\": \"github/HelloWorld\",\n"
459       + "      \"owner\": {\n"
460       + "        \"login\": \"github\",\n"
461       + "        \"id\": 872147,\n"
462       + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
463       + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
464       + "        \"gravatar_id\": \"\",\n"
465       + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
466       + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
467       + "        \"type\": \"User\"\n"
468       + "      },\n"
469       + "      \"private\": false,\n"
470       + "      \"html_url\": \"https://github.com/github/HelloWorld\",\n"
471       + "      \"description\": \"A C implementation of HelloWorld\",\n"
472       + "      \"fork\": false,\n"
473       + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
474       + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
475       + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
476       + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
477       + "      \"homepage\": \"\",\n"
478       + "      \"size\": 524,\n"
479       + "      \"stargazers_count\": 1,\n"
480       + "      \"watchers_count\": 1,\n"
481       + "      \"language\": \"Assembly\",\n"
482       + "      \"forks_count\": 0,\n"
483       + "      \"open_issues_count\": 0,\n"
484       + "      \"master_branch\": \"master\",\n"
485       + "      \"default_branch\": \"master\",\n"
486       + "      \"score\": 1.0\n"
487       + "    },\n"
488       + "    {\n"
489       + "      \"id\": 3081286,\n"
490       + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
491       + "      \"name\": \"HelloUniverse\",\n"
492       + "      \"full_name\": \"github/HelloUniverse\",\n"
493       + "      \"owner\": {\n"
494       + "        \"login\": \"github\",\n"
495       + "        \"id\": 872147,\n"
496       + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
497       + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
498       + "        \"gravatar_id\": \"\",\n"
499       + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
500       + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
501       + "        \"type\": \"User\"\n"
502       + "      },\n"
503       + "      \"private\": false,\n"
504       + "      \"html_url\": \"https://github.com/github/HelloUniverse\",\n"
505       + "      \"description\": \"A C implementation of HelloUniverse\",\n"
506       + "      \"fork\": false,\n"
507       + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloUniverse\",\n"
508       + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
509       + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
510       + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
511       + "      \"homepage\": \"\",\n"
512       + "      \"size\": 524,\n"
513       + "      \"stargazers_count\": 1,\n"
514       + "      \"watchers_count\": 1,\n"
515       + "      \"language\": \"Assembly\",\n"
516       + "      \"forks_count\": 0,\n"
517       + "      \"open_issues_count\": 0,\n"
518       + "      \"master_branch\": \"master\",\n"
519       + "      \"default_branch\": \"master\",\n"
520       + "      \"score\": 1.0\n"
521       + "    }\n"
522       + "  ]\n"
523       + "}";
524
525     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
526       .thenReturn(new OkGetResponse(responseJson));
527     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
528
529     assertThat(repositories.getTotal()).isEqualTo(2);
530     assertThat(repositories.getRepositories())
531       .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
532       .containsOnly(tuple("HelloWorld", "github/HelloWorld"), tuple("HelloUniverse", "github/HelloUniverse"));
533   }
534
535   @Test
536   public void listRepositories_returns_search_results() throws IOException {
537     String appUrl = "https://github.sonarsource.com";
538     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
539     String responseJson = "{\n"
540       + "  \"total_count\": 2,\n"
541       + "  \"incomplete_results\": false,\n"
542       + "  \"items\": [\n"
543       + "    {\n"
544       + "      \"id\": 3081286,\n"
545       + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
546       + "      \"name\": \"HelloWorld\",\n"
547       + "      \"full_name\": \"github/HelloWorld\",\n"
548       + "      \"owner\": {\n"
549       + "        \"login\": \"github\",\n"
550       + "        \"id\": 872147,\n"
551       + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
552       + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
553       + "        \"gravatar_id\": \"\",\n"
554       + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
555       + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
556       + "        \"type\": \"User\"\n"
557       + "      },\n"
558       + "      \"private\": false,\n"
559       + "      \"html_url\": \"https://github.com/github/HelloWorld\",\n"
560       + "      \"description\": \"A C implementation of HelloWorld\",\n"
561       + "      \"fork\": false,\n"
562       + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
563       + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
564       + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
565       + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
566       + "      \"homepage\": \"\",\n"
567       + "      \"size\": 524,\n"
568       + "      \"stargazers_count\": 1,\n"
569       + "      \"watchers_count\": 1,\n"
570       + "      \"language\": \"Assembly\",\n"
571       + "      \"forks_count\": 0,\n"
572       + "      \"open_issues_count\": 0,\n"
573       + "      \"master_branch\": \"master\",\n"
574       + "      \"default_branch\": \"master\",\n"
575       + "      \"score\": 1.0\n"
576       + "    }\n"
577       + "  ]\n"
578       + "}";
579
580     when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100)))
581       .thenReturn(new GithubApplicationHttpClient.GetResponse() {
582         @Override
583         public Optional<String> getNextEndPoint() {
584           return Optional.empty();
585         }
586
587         @Override
588         public int getCode() {
589           return 200;
590         }
591
592         @Override
593         public Optional<String> getContent() {
594           return Optional.of(responseJson);
595         }
596       });
597
598     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", "world", 1, 100);
599
600     assertThat(repositories.getTotal()).isEqualTo(2);
601     assertThat(repositories.getRepositories())
602       .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
603       .containsOnly(tuple("HelloWorld", "github/HelloWorld"));
604   }
605
606   @Test
607   public void getRepository_returns_empty_when_repository_doesnt_exist() throws IOException {
608     when(httpClient.get(any(), any(), any()))
609       .thenReturn(new Response(404, null));
610
611     Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, new UserAccessToken("temp"), "octocat", "octocat/Hello-World");
612
613     assertThat(repository).isEmpty();
614   }
615
616   @Test
617   public void getRepository_fails_on_failure() throws IOException {
618     String repositoryKey = "octocat/Hello-World";
619     String organization = "octocat";
620
621     when(httpClient.get(any(), any(), any()))
622       .thenThrow(new IOException("OOPS"));
623
624     UserAccessToken token = new UserAccessToken("temp");
625     assertThatThrownBy(() -> underTest.getRepository(appUrl, token, organization, repositoryKey))
626       .isInstanceOf(IllegalStateException.class)
627       .hasMessage("Failed to get repository '%s' of '%s' accessible by user access token on '%s'", repositoryKey, organization, appUrl);
628   }
629
630   @Test
631   public void getRepository_returns_repository() throws IOException {
632     String appUrl = "https://github.sonarsource.com";
633     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
634     String responseJson = "{\n"
635       + "  \"id\": 1296269,\n"
636       + "  \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMjk2MjY5\",\n"
637       + "  \"name\": \"Hello-World\",\n"
638       + "  \"full_name\": \"octocat/Hello-World\",\n"
639       + "  \"owner\": {\n"
640       + "    \"login\": \"octocat\",\n"
641       + "    \"id\": 1,\n"
642       + "    \"node_id\": \"MDQ6VXNlcjE=\",\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/octocat\",\n"
646       + "    \"html_url\": \"https://github.com/octocat\",\n"
647       + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
648       + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
649       + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
650       + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
651       + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
652       + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
653       + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
654       + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
655       + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
656       + "    \"type\": \"User\",\n"
657       + "    \"site_admin\": false\n"
658       + "  },\n"
659       + "  \"private\": false,\n"
660       + "  \"html_url\": \"https://github.com/octocat/Hello-World\",\n"
661       + "  \"description\": \"This your first repo!\",\n"
662       + "  \"fork\": false,\n"
663       + "  \"url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World\",\n"
664       + "  \"archive_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/{archive_format}{/ref}\",\n"
665       + "  \"assignees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/assignees{/user}\",\n"
666       + "  \"blobs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/blobs{/sha}\",\n"
667       + "  \"branches_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/branches{/branch}\",\n"
668       + "  \"collaborators_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/collaborators{/collaborator}\",\n"
669       + "  \"comments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/comments{/number}\",\n"
670       + "  \"commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/commits{/sha}\",\n"
671       + "  \"compare_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/compare/{base}...{head}\",\n"
672       + "  \"contents_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contents/{+path}\",\n"
673       + "  \"contributors_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contributors\",\n"
674       + "  \"deployments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/deployments\",\n"
675       + "  \"downloads_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/downloads\",\n"
676       + "  \"events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/events\",\n"
677       + "  \"forks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/forks\",\n"
678       + "  \"git_commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/commits{/sha}\",\n"
679       + "  \"git_refs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/refs{/sha}\",\n"
680       + "  \"git_tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/tags{/sha}\",\n"
681       + "  \"git_url\": \"git:github.com/octocat/Hello-World.git\",\n"
682       + "  \"issue_comment_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/comments{/number}\",\n"
683       + "  \"issue_events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/events{/number}\",\n"
684       + "  \"issues_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues{/number}\",\n"
685       + "  \"keys_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/keys{/key_id}\",\n"
686       + "  \"labels_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/labels{/name}\",\n"
687       + "  \"languages_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/languages\",\n"
688       + "  \"merges_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/merges\",\n"
689       + "  \"milestones_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/milestones{/number}\",\n"
690       + "  \"notifications_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/notifications{?since,all,participating}\",\n"
691       + "  \"pulls_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/pulls{/number}\",\n"
692       + "  \"releases_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/releases{/id}\",\n"
693       + "  \"ssh_url\": \"git@github.com:octocat/Hello-World.git\",\n"
694       + "  \"stargazers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/stargazers\",\n"
695       + "  \"statuses_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/statuses/{sha}\",\n"
696       + "  \"subscribers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscribers\",\n"
697       + "  \"subscription_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscription\",\n"
698       + "  \"tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/tags\",\n"
699       + "  \"teams_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/teams\",\n"
700       + "  \"trees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/trees{/sha}\",\n"
701       + "  \"clone_url\": \"https://github.com/octocat/Hello-World.git\",\n"
702       + "  \"mirror_url\": \"git:git.example.com/octocat/Hello-World\",\n"
703       + "  \"hooks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/hooks\",\n"
704       + "  \"svn_url\": \"https://svn.github.com/octocat/Hello-World\",\n"
705       + "  \"homepage\": \"https://github.com\",\n"
706       + "  \"language\": null,\n"
707       + "  \"forks_count\": 9,\n"
708       + "  \"stargazers_count\": 80,\n"
709       + "  \"watchers_count\": 80,\n"
710       + "  \"size\": 108,\n"
711       + "  \"default_branch\": \"master\",\n"
712       + "  \"open_issues_count\": 0,\n"
713       + "  \"is_template\": true,\n"
714       + "  \"topics\": [\n"
715       + "    \"octocat\",\n"
716       + "    \"atom\",\n"
717       + "    \"electron\",\n"
718       + "    \"api\"\n"
719       + "  ],\n"
720       + "  \"has_issues\": true,\n"
721       + "  \"has_projects\": true,\n"
722       + "  \"has_wiki\": true,\n"
723       + "  \"has_pages\": false,\n"
724       + "  \"has_downloads\": true,\n"
725       + "  \"archived\": false,\n"
726       + "  \"disabled\": false,\n"
727       + "  \"visibility\": \"public\",\n"
728       + "  \"pushed_at\": \"2011-01-26T19:06:43Z\",\n"
729       + "  \"created_at\": \"2011-01-26T19:01:12Z\",\n"
730       + "  \"updated_at\": \"2011-01-26T19:14:43Z\",\n"
731       + "  \"permissions\": {\n"
732       + "    \"admin\": false,\n"
733       + "    \"push\": false,\n"
734       + "    \"pull\": true\n"
735       + "  },\n"
736       + "  \"allow_rebase_merge\": true,\n"
737       + "  \"template_repository\": null,\n"
738       + "  \"allow_squash_merge\": true,\n"
739       + "  \"allow_merge_commit\": true,\n"
740       + "  \"subscribers_count\": 42,\n"
741       + "  \"network_count\": 0,\n"
742       + "  \"anonymous_access_enabled\": false,\n"
743       + "  \"license\": {\n"
744       + "    \"key\": \"mit\",\n"
745       + "    \"name\": \"MIT License\",\n"
746       + "    \"spdx_id\": \"MIT\",\n"
747       + "    \"url\": \"https://github.sonarsource.com/api/v3/licenses/mit\",\n"
748       + "    \"node_id\": \"MDc6TGljZW5zZW1pdA==\"\n"
749       + "  },\n"
750       + "  \"organization\": {\n"
751       + "    \"login\": \"octocat\",\n"
752       + "    \"id\": 1,\n"
753       + "    \"node_id\": \"MDQ6VXNlcjE=\",\n"
754       + "    \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
755       + "    \"gravatar_id\": \"\",\n"
756       + "    \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
757       + "    \"html_url\": \"https://github.com/octocat\",\n"
758       + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
759       + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
760       + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
761       + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
762       + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
763       + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
764       + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
765       + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
766       + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
767       + "    \"type\": \"Organization\",\n"
768       + "    \"site_admin\": false\n"
769       + "  }"
770       + "}";
771
772     when(httpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
773       .thenReturn(new GithubApplicationHttpClient.GetResponse() {
774         @Override
775         public Optional<String> getNextEndPoint() {
776           return Optional.empty();
777         }
778
779         @Override
780         public int getCode() {
781           return 200;
782         }
783
784         @Override
785         public Optional<String> getContent() {
786           return Optional.of(responseJson);
787         }
788       });
789
790     Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, accessToken, "octocat", "octocat/Hello-World");
791
792     assertThat(repository)
793       .isPresent()
794       .get()
795       .extracting(GithubApplicationClient.Repository::getId, GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName,
796         GithubApplicationClient.Repository::getUrl, GithubApplicationClient.Repository::isPrivate, GithubApplicationClient.Repository::getDefaultBranch)
797       .containsOnly(1296269L, "Hello-World", "octocat/Hello-World", "https://github.com/octocat/Hello-World", false, "master");
798   }
799
800   private AppToken mockAppToken() {
801     String jwt = randomAlphanumeric(5);
802     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(new AppToken(jwt));
803     return new AppToken(jwt);
804   }
805
806   private static class OkGetResponse extends Response {
807     private OkGetResponse(String content) {
808       super(200, content);
809     }
810   }
811
812   private static class ErrorGetResponse extends Response {
813     ErrorGetResponse() {
814       super(401, null);
815     }
816
817     ErrorGetResponse(int code, String content) {
818       super(code, content);
819     }
820   }
821
822   private static class Response implements GithubApplicationHttpClient.GetResponse {
823     private final int code;
824     private final String content;
825     private final String nextEndPoint;
826
827     private Response(int code, @Nullable String content) {
828       this(code, content, null);
829     }
830
831     private Response(int code, @Nullable String content, @Nullable String nextEndPoint) {
832       this.code = code;
833       this.content = content;
834       this.nextEndPoint = nextEndPoint;
835     }
836
837     @Override
838     public int getCode() {
839       return code;
840     }
841
842     @Override
843     public Optional<String> getContent() {
844       return Optional.ofNullable(content);
845     }
846
847     @Override
848     public Optional<String> getNextEndPoint() {
849       return Optional.ofNullable(nextEndPoint);
850     }
851   }
852 }