/* * SonarQube * Copyright (C) 2009-2025 SonarSource SA * mailto:info AT sonarsource DOT com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package org.sonar.alm.client.github; import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Function; import javax.annotation.Nullable; import org.apache.commons.io.IOUtils; import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.slf4j.event.Level; import org.sonar.alm.client.ApplicationHttpClient.RateLimit; import org.sonar.alm.client.github.security.AppToken; import org.sonar.alm.client.github.security.GithubAppSecurity; import org.sonar.api.testfixtures.log.LogAndArguments; import org.sonar.api.testfixtures.log.LogTester; import org.sonar.auth.github.AppInstallationToken; import org.sonar.auth.github.ExpiringAppInstallationToken; import org.sonar.auth.github.GitHubSettings; import org.sonar.auth.github.GithubAppConfiguration; import org.sonar.auth.github.GithubAppInstallation; import org.sonar.auth.github.GithubBinding; import org.sonar.auth.github.GsonRepositoryCollaborator; import org.sonar.auth.github.GsonRepositoryPermissions; import org.sonar.auth.github.GsonRepositoryTeam; import org.sonar.auth.github.GithubApplicationClient; import org.sonar.auth.github.security.AccessToken; import org.sonar.auth.github.security.UserAccessToken; import org.sonarqube.ws.client.HttpException; import static java.lang.String.format; import static java.net.HttpURLConnection.HTTP_CREATED; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; import static org.apache.commons.lang3.RandomStringUtils.secure; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.sonar.alm.client.ApplicationHttpClient.GetResponse; @RunWith(DataProviderRunner.class) public class GithubApplicationClientImplTest { private static final String ORG_NAME = "ORG_NAME"; private static final String TEAM_NAME = "team1"; private static final String REPO_NAME = "repo1"; private static final String APP_URL = "https://github.com/"; private static final String REPO_TEAMS_ENDPOINT = "/repos/ORG_NAME/repo1/teams"; private static final String REPO_COLLABORATORS_ENDPOINT = "/repos/ORG_NAME/repo1/collaborators?affiliation=direct"; private static final int INSTALLATION_ID = 1; private static final String APP_JWT_TOKEN = "APP_TOKEN_JWT"; private static final String PAYLOAD_2_ORGS = """ [ { "id": 1, "account": { "login": "org1", "type": "Organization" }, "target_type": "Organization", "permissions": { "members": "read", "metadata": "read" }, "suspended_at": "2023-05-30T08:40:55Z" }, { "id": 2, "account": { "login": "org2", "type": "Organization" }, "target_type": "Organization", "permissions": { "members": "read", "metadata": "read" } } ]"""; private static final RateLimit RATE_LIMIT = new RateLimit(Integer.MAX_VALUE, Integer.MAX_VALUE, 0L); @ClassRule public static LogTester logTester = new LogTester().setLevel(Level.WARN); private GithubApplicationHttpClient githubApplicationHttpClient = mock(); private GithubAppSecurity appSecurity = mock(); private GithubAppConfiguration githubAppConfiguration = mock(); private GitHubSettings gitHubSettings = mock(); private GithubPaginatedHttpClient githubPaginatedHttpClient = mock(); private AppInstallationToken appInstallationToken = mock(); private GithubApplicationClient underTest; private Clock clock = Clock.fixed(Instant.EPOCH, ZoneId.systemDefault()); private String appUrl = "Any URL"; @Before public void setup() { when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl); underTest = new GithubApplicationClientImpl(clock, githubApplicationHttpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient); logTester.clear(); } @Test @UseDataProvider("invalidApiEndpoints") public void checkApiEndpoint_Invalid(String url, String expectedMessage) { GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url); assertThatThrownBy(() -> underTest.checkApiEndpoint(configuration)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(expectedMessage); } @DataProvider public static Object[][] invalidApiEndpoints() { return new Object[][] { {"", "Missing URL"}, {"ftp://api.github.com", "Only http and https schemes are supported"}, {"https://github.com", "Invalid GitHub URL"} }; } @Test @UseDataProvider("validApiEndpoints") public void checkApiEndpoint(String url) { GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url); assertThatCode(() -> underTest.checkApiEndpoint(configuration)).isNull(); } @DataProvider public static Object[][] validApiEndpoints() { return new Object[][] { {"https://github.sonarsource.com/api/v3"}, {"https://api.github.com"}, {"https://github.sonarsource.com/api/v3/"}, {"https://api.github.com/"}, {"HTTPS://api.github.com/"}, {"HTTP://api.github.com/"}, {"HtTpS://github.SonarSource.com/api/v3"}, {"HtTpS://github.sonarsource.com/api/V3"}, {"HtTpS://github.sonarsource.COM/ApI/v3"} }; } @Test public void checkAppPermissions_IOException() throws IOException { AppToken appToken = mockAppToken(); when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS")); assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Failed to validate configuration, check URL and Private Key"); } @Test @UseDataProvider("checkAppPermissionsErrorCodes") public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException { AppToken appToken = mockAppToken(); when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null)); assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(expectedMessage); } @DataProvider public static Object[][] checkAppPermissionsErrorCodes() { return new Object[][] { {HTTP_UNAUTHORIZED, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"}, {HTTP_FORBIDDEN, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"}, {HTTP_NOT_FOUND, "Failed to check permissions with Github, check the configuration"} }; } @Test public void checkAppPermissions_MissingPermissions() throws IOException { AppToken appToken = mockAppToken(); when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}")); assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Failed to get app permissions, unexpected response body"); } @Test public void checkAppPermissions_IncorrectPermissions() throws IOException { AppToken appToken = mockAppToken(); String json = """ { "permissions": { "checks": "read", "metadata": "read", "pull_requests": "read" } } """; when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json)); assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Missing permissions; permission granted on pull_requests is 'read', should be 'write', checks is 'read', should be 'write'"); } @Test public void checkAppPermissions() throws IOException { AppToken appToken = mockAppToken(); String json = """ { "permissions": { "checks": "write", "metadata": "read", "pull_requests": "write" } } """; when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json)); assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull(); } @Test public void getInstallationId_returns_installation_id_of_given_account() throws IOException { AppToken appToken = new AppToken(APP_JWT_TOKEN); when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); when(githubApplicationHttpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation")) .thenReturn(new OkGetResponse(""" { "id": 2, "account": { "login": "torvalds" } }""")); assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds/linux")).hasValue(2L); } @Test public void getInstallationId_throws_IAE_if_fail_to_create_app_token() { when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenThrow(IllegalArgumentException.class); assertThatThrownBy(() -> underTest.getInstallationId(githubAppConfiguration, "torvalds")) .isInstanceOf(IllegalArgumentException.class); } @Test public void getInstallationId_return_empty_if_no_installation_found_for_githubAccount() throws IOException { AppToken appToken = new AppToken(APP_JWT_TOKEN); when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); when(githubApplicationHttpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation")) .thenReturn(new ErrorGetResponse(404, null)); assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds")).isEmpty(); } @Test @UseDataProvider("githubServers") public void createUserAccessToken_returns_empty_if_access_token_cant_be_created(String apiUrl, String appUrl) throws IOException { when(githubApplicationHttpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code")) .thenReturn(new Response(400, null)); assertThatThrownBy(() -> underTest.createUserAccessToken(appUrl, "clientId", "clientSecret", "code")) .isInstanceOf(IllegalStateException.class); verify(githubApplicationHttpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"); } @Test @UseDataProvider("githubServers") public void createUserAccessToken_fail_if_access_token_request_fails(String apiUrl, String appUrl) throws IOException { when(githubApplicationHttpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code")) .thenThrow(new IOException("OOPS")); assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code")) .isInstanceOf(IllegalStateException.class) .hasMessage("Failed to create GitHub's user access token"); verify(githubApplicationHttpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"); } @Test @UseDataProvider("githubServers") public void createUserAccessToken_throws_illegal_argument_exception_if_access_token_code_is_expired(String apiUrl, String appUrl) throws IOException { when(githubApplicationHttpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code")) .thenReturn(new OkGetResponse("error_code=100&error=expired_or_invalid")); assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code")) .isInstanceOf(IllegalArgumentException.class); verify(githubApplicationHttpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"); } @Test @UseDataProvider("githubServers") public void createUserAccessToken_from_authorization_code_returns_access_token(String apiUrl, String appUrl) throws IOException { String token = secure().nextAlphanumeric(10); when(githubApplicationHttpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code")) .thenReturn(new OkGetResponse("access_token=" + token + "&status=")); UserAccessToken userAccessToken = underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"); assertThat(userAccessToken) .extracting(UserAccessToken::getValue, UserAccessToken::getAuthorizationHeaderPrefix) .containsOnly(token, "token"); verify(githubApplicationHttpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"); } @Test public void getApp_returns_id() throws IOException { AppToken appToken = new AppToken(APP_JWT_TOKEN); when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); when(githubApplicationHttpClient.get(appUrl, appToken, "/app")) .thenReturn(new OkGetResponse("{\"installations_count\": 2}")); assertThat(underTest.getApp(githubAppConfiguration).getInstallationsCount()).isEqualTo(2L); } @Test public void getApp_whenStatusCodeIsNotOk_shouldThrowHttpException() throws IOException { AppToken appToken = new AppToken(APP_JWT_TOKEN); when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); when(githubApplicationHttpClient.get(appUrl, appToken, "/app")) .thenReturn(new ErrorGetResponse(418, "I'm a teapot")); assertThatThrownBy(() -> underTest.getApp(githubAppConfiguration)) .isInstanceOfSatisfying(HttpException.class, httpException -> { assertThat(httpException.code()).isEqualTo(418); assertThat(httpException.url()).isEqualTo("Any URL/app"); assertThat(httpException.content()).isEqualTo("I'm a teapot"); }); } @DataProvider public static Object[][] githubServers() { return new Object[][] { {"https://github.sonarsource.com/api/v3", "https://github.sonarsource.com"}, {"https://api.github.com", "https://github.com"}, {"https://github.sonarsource.com/api/v3/", "https://github.sonarsource.com"}, {"https://api.github.com/", "https://github.com"}, }; } @Test public void listOrganizations_fail_on_failure() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100))) .thenThrow(new IOException("OOPS")); assertThatThrownBy(() -> underTest.listOrganizations(appUrl, accessToken, 1, 100)) .isInstanceOf(IllegalStateException.class) .hasMessage("Failed to list all organizations accessible by user access token on %s", appUrl); } @Test public void listOrganizations_fail_if_pageIndex_out_of_bounds() { UserAccessToken token = new UserAccessToken("token"); assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 0, 100)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("'page' must be larger than 0."); } @Test public void listOrganizations_fail_if_pageSize_out_of_bounds() { UserAccessToken token = new UserAccessToken("token"); assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 1, 0)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100."); assertThatThrownBy(() -> underTest.listOrganizations("", token, 1, 101)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100."); } @Test public void listOrganizations_returns_no_installations() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); String responseJson = """ { "total_count": 0 } """; when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100))) .thenReturn(new OkGetResponse(responseJson)); GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100); assertThat(organizations.getTotal()).isZero(); assertThat(organizations.getOrganizations()).isNull(); } @Test public void listOrganizations_returns_pages_results() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); String responseJson = """ { "total_count": 2, "installations": [ { "id": 1, "account": { "login": "github", "id": 1, "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", "url": "https://github.sonarsource.com/api/v3/orgs/github", "repos_url": "https://github.sonarsource.com/api/v3/orgs/github/repos", "events_url": "https://github.sonarsource.com/api/v3/orgs/github/events", "hooks_url": "https://github.sonarsource.com/api/v3/orgs/github/hooks", "issues_url": "https://github.sonarsource.com/api/v3/orgs/github/issues", "members_url": "https://github.sonarsource.com/api/v3/orgs/github/members{/member}", "public_members_url": "https://github.sonarsource.com/api/v3/orgs/github/public_members{/member}", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "description": "A great organization" }, "access_tokens_url": "https://github.sonarsource.com/api/v3/app/installations/1/access_tokens", "repositories_url": "https://github.sonarsource.com/api/v3/installation/repositories", "html_url": "https://github.com/organizations/github/settings/installations/1", "app_id": 1, "target_id": 1, "target_type": "Organization", "permissions": { "checks": "write", "metadata": "read", "contents": "read" }, "events": [ "push", "pull_request" ], "single_file_name": "config.yml" }, { "id": 3, "account": { "login": "octocat", "id": 2, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://github.sonarsource.com/api/v3/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://github.sonarsource.com/api/v3/users/octocat/followers", "following_url": "https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}", "gists_url": "https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}", "starred_url": "https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://github.sonarsource.com/api/v3/users/octocat/subscriptions", "organizations_url": "https://github.sonarsource.com/api/v3/users/octocat/orgs", "repos_url": "https://github.sonarsource.com/api/v3/users/octocat/repos", "events_url": "https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}", "received_events_url": "https://github.sonarsource.com/api/v3/users/octocat/received_events", "type": "User", "site_admin": false }, "access_tokens_url": "https://github.sonarsource.com/api/v3/app/installations/1/access_tokens", "repositories_url": "https://github.sonarsource.com/api/v3/installation/repositories", "html_url": "https://github.com/organizations/github/settings/installations/1", "app_id": 1, "target_id": 1, "target_type": "Organization", "permissions": { "checks": "write", "metadata": "read", "contents": "read" }, "events": [ "push", "pull_request" ], "single_file_name": "config.yml" } ] } """; when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100))) .thenReturn(new OkGetResponse(responseJson)); GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100); assertThat(organizations.getTotal()).isEqualTo(2); assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat"); } @Test public void getWhitelistedGithubAppInstallations_whenWhitelistNotSpecified_doesNotFilter() throws IOException { List allOrgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS); assertOrgDeserialization(allOrgInstallations); } private static void assertOrgDeserialization(List orgs) { GithubAppInstallation org1 = orgs.get(0); assertThat(org1.installationId()).isEqualTo("1"); assertThat(org1.organizationName()).isEqualTo("org1"); assertThat(org1.permissions().getMembers()).isEqualTo("read"); assertThat(org1.isSuspended()).isTrue(); GithubAppInstallation org2 = orgs.get(1); assertThat(org2.installationId()).isEqualTo("2"); assertThat(org2.organizationName()).isEqualTo("org2"); assertThat(org2.permissions().getMembers()).isEqualTo("read"); assertThat(org2.isSuspended()).isFalse(); } @Test public void getWhitelistedGithubAppInstallations_whenWhitelistSpecified_filtersWhitelistedOrgs() throws IOException { when(gitHubSettings.getOrganizations()).thenReturn(Set.of("org2")); List orgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS); assertThat(orgInstallations) .hasSize(1) .extracting(GithubAppInstallation::organizationName) .containsExactlyInAnyOrder("org2"); } @Test public void getWhitelistedGithubAppInstallations_whenEmptyResponse_shouldReturnEmpty() throws IOException { List allOrgInstallations = getGithubAppInstallationsFromGithubResponse("[]"); assertThat(allOrgInstallations).isEmpty(); } @Test public void getWhitelistedGithubAppInstallations_whenNoOrganization_shouldReturnEmpty() throws IOException { List allOrgInstallations = getGithubAppInstallationsFromGithubResponse(""" [ { "id": 1, "account": { "login": "user1", "type": "User" }, "target_type": "User", "permissions": { "metadata": "read" } } ]"""); assertThat(allOrgInstallations).isEmpty(); } @SuppressWarnings("unchecked") private List getGithubAppInstallationsFromGithubResponse(String content) throws IOException { AppToken appToken = new AppToken(APP_JWT_TOKEN); when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); when(githubPaginatedHttpClient.get(eq(appUrl), eq(appToken), eq("/app/installations"), any())) .thenAnswer(invocation -> { Function> deserializingFunction = invocation.getArgument(3, Function.class); return deserializingFunction.apply(content); }); return underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration); } @Test public void getWhitelistedGithubAppInstallations_whenGithubReturnsError_shouldReThrow() { AppToken appToken = new AppToken(APP_JWT_TOKEN); when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); when(githubPaginatedHttpClient.get(any(), any(), any(), any())).thenThrow(new IllegalStateException("exception")); assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration)) .isInstanceOf(IllegalStateException.class) .hasMessage("exception"); } @Test public void listRepositories_fail_on_failure() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "org:test", 1, 100))) .thenThrow(new IOException("OOPS")); assertThatThrownBy(() -> underTest.listRepositories(appUrl, accessToken, "test", null, 1, 100)) .isInstanceOf(IllegalStateException.class) .hasMessage("Failed to list all repositories of 'test' accessible by user access token on 'https://github.sonarsource.com' using query 'fork:true+org:test'"); } @Test public void listRepositories_fail_if_pageIndex_out_of_bounds() { UserAccessToken token = new UserAccessToken("token"); assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 0, 100)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("'page' must be larger than 0."); } @Test public void listRepositories_fail_if_pageSize_out_of_bounds() { UserAccessToken token = new UserAccessToken("token"); assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 1, 0)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100."); assertThatThrownBy(() -> underTest.listRepositories("", token, "test", null, 1, 101)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100."); } @Test public void listRepositories_returns_empty_results() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); String responseJson = """ { "total_count": 0 }"""; when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100))) .thenReturn(new OkGetResponse(responseJson)); GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100); assertThat(repositories.getTotal()).isZero(); assertThat(repositories.getRepositories()).isNull(); } @Test public void listRepositories_returns_pages_results() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); String responseJson = """ { "total_count": 2, "incomplete_results": false, "items": [ { "id": 3081286, "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2", "name": "HelloWorld", "full_name": "github/HelloWorld", "owner": { "login": "github", "id": 872147, "node_id": "MDQ6VXNlcjg3MjE0Nw==", "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://github.sonarsource.com/api/v3/users/github", "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events", "type": "User" }, "private": false, "html_url": "https://github.com/github/HelloWorld", "description": "A C implementation of HelloWorld", "fork": false, "url": "https://github.sonarsource.com/api/v3/repos/github/HelloWorld", "created_at": "2012-01-01T00:31:50Z", "updated_at": "2013-01-05T17:58:47Z", "pushed_at": "2012-01-01T00:37:02Z", "homepage": "", "size": 524, "stargazers_count": 1, "watchers_count": 1, "language": "Assembly", "forks_count": 0, "open_issues_count": 0, "master_branch": "master", "default_branch": "master", "score": 1.0 }, { "id": 3081286, "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2", "name": "HelloUniverse", "full_name": "github/HelloUniverse", "owner": { "login": "github", "id": 872147, "node_id": "MDQ6VXNlcjg3MjE0Nw==", "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://github.sonarsource.com/api/v3/users/github", "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events", "type": "User" }, "private": false, "html_url": "https://github.com/github/HelloUniverse", "description": "A C implementation of HelloUniverse", "fork": false, "url": "https://github.sonarsource.com/api/v3/repos/github/HelloUniverse", "created_at": "2012-01-01T00:31:50Z", "updated_at": "2013-01-05T17:58:47Z", "pushed_at": "2012-01-01T00:37:02Z", "homepage": "", "size": 524, "stargazers_count": 1, "watchers_count": 1, "language": "Assembly", "forks_count": 0, "open_issues_count": 0, "master_branch": "master", "default_branch": "master", "score": 1.0 } ] }"""; when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100))) .thenReturn(new OkGetResponse(responseJson)); GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100); assertThat(repositories.getTotal()).isEqualTo(2); assertThat(repositories.getRepositories()) .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName) .containsOnly(tuple("HelloWorld", "github/HelloWorld"), tuple("HelloUniverse", "github/HelloUniverse")); } @Test public void listRepositories_returns_search_results() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); String responseJson = """ { "total_count": 2, "incomplete_results": false, "items": [ { "id": 3081286, "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2", "name": "HelloWorld", "full_name": "github/HelloWorld", "owner": { "login": "github", "id": 872147, "node_id": "MDQ6VXNlcjg3MjE0Nw==", "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://github.sonarsource.com/api/v3/users/github", "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events", "type": "User" }, "private": false, "html_url": "https://github.com/github/HelloWorld", "description": "A C implementation of HelloWorld", "fork": false, "url": "https://github.sonarsource.com/api/v3/repos/github/HelloWorld", "created_at": "2012-01-01T00:31:50Z", "updated_at": "2013-01-05T17:58:47Z", "pushed_at": "2012-01-01T00:37:02Z", "homepage": "", "size": 524, "stargazers_count": 1, "watchers_count": 1, "language": "Assembly", "forks_count": 0, "open_issues_count": 0, "master_branch": "master", "default_branch": "master", "score": 1.0 } ] }"""; when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100))) .thenReturn(new GetResponse() { @Override public Optional getNextEndPoint() { return Optional.empty(); } @Override public int getCode() { return 200; } @Override public Optional getContent() { return Optional.of(responseJson); } @Override public RateLimit getRateLimit() { return RATE_LIMIT; } }); GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", "world", 1, 100); assertThat(repositories.getTotal()).isEqualTo(2); assertThat(repositories.getRepositories()) .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName) .containsOnly(tuple("HelloWorld", "github/HelloWorld")); } @Test public void getRepository_returns_empty_when_repository_doesnt_exist() throws IOException { when(githubApplicationHttpClient.get(any(), any(), any())) .thenReturn(new Response(404, null)); Optional repository = underTest.getRepository(appUrl, new UserAccessToken("temp"), "octocat/Hello-World"); assertThat(repository).isEmpty(); } @Test public void getRepository_fails_on_failure() throws IOException { String repositoryKey = "octocat/Hello-World"; when(githubApplicationHttpClient.get(any(), any(), any())) .thenThrow(new IOException("OOPS")); UserAccessToken token = new UserAccessToken("temp"); assertThatThrownBy(() -> underTest.getRepository(appUrl, token, repositoryKey)) .isInstanceOf(IllegalStateException.class) .hasMessage("Failed to get repository 'octocat/Hello-World' on 'Any URL' (this might be related to the GitHub App installation scope)"); } @Test public void getRepository_returns_repository() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(secure().nextAlphanumeric(10)); String responseJson = """ { "id": 1296269, "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", "name": "Hello-World", "full_name": "octocat/Hello-World", "owner": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://github.sonarsource.com/api/v3/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://github.sonarsource.com/api/v3/users/octocat/followers", "following_url": "https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}", "gists_url": "https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}", "starred_url": "https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://github.sonarsource.com/api/v3/users/octocat/subscriptions", "organizations_url": "https://github.sonarsource.com/api/v3/users/octocat/orgs", "repos_url": "https://github.sonarsource.com/api/v3/users/octocat/repos", "events_url": "https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}", "received_events_url": "https://github.sonarsource.com/api/v3/users/octocat/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/octocat/Hello-World", "description": "This your first repo!", "fork": false, "url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World", "archive_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/{archive_format}{/ref}", "assignees_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/assignees{/user}", "blobs_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/blobs{/sha}", "branches_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/branches{/branch}", "collaborators_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/collaborators{/collaborator}", "comments_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/comments{/number}", "commits_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/commits{/sha}", "compare_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/compare/{base}...{head}", "contents_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contents/{+path}", "contributors_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contributors", "deployments_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/deployments", "downloads_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/downloads", "events_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/events", "forks_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/forks", "git_commits_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/commits{/sha}", "git_refs_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/refs{/sha}", "git_tags_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/tags{/sha}", "git_url": "git:github.com/octocat/Hello-World.git", "issue_comment_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/comments{/number}", "issue_events_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/events{/number}", "issues_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues{/number}", "keys_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/keys{/key_id}", "labels_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/labels{/name}", "languages_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/languages", "merges_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/merges", "milestones_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/milestones{/number}", "notifications_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/notifications{?since,all,participating}", "pulls_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/pulls{/number}", "releases_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/releases{/id}", "ssh_url": "git@github.com:octocat/Hello-World.git", "stargazers_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/stargazers", "statuses_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/statuses/{sha}", "subscribers_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscribers", "subscription_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscription", "tags_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/tags", "teams_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/teams", "trees_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/trees{/sha}", "clone_url": "https://github.com/octocat/Hello-World.git", "mirror_url": "git:git.example.com/octocat/Hello-World", "hooks_url": "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/hooks", "svn_url": "https://svn.github.com/octocat/Hello-World", "homepage": "https://github.com", "language": null, "forks_count": 9, "stargazers_count": 80, "watchers_count": 80, "size": 108, "default_branch": "master", "open_issues_count": 0, "is_template": true, "topics": [ "octocat", "atom", "electron", "api" ], "has_issues": true, "has_projects": true, "has_wiki": true, "has_pages": false, "has_downloads": true, "archived": false, "disabled": false, "visibility": "public", "pushed_at": "2011-01-26T19:06:43Z", "created_at": "2011-01-26T19:01:12Z", "updated_at": "2011-01-26T19:14:43Z", "permissions": { "admin": false, "push": false, "pull": true }, "allow_rebase_merge": true, "template_repository": null, "allow_squash_merge": true, "allow_merge_commit": true, "subscribers_count": 42, "network_count": 0, "anonymous_access_enabled": false, "license": { "key": "mit", "name": "MIT License", "spdx_id": "MIT", "url": "https://github.sonarsource.com/api/v3/licenses/mit", "node_id": "MDc6TGljZW5zZW1pdA==" }, "organization": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://github.sonarsource.com/api/v3/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://github.sonarsource.com/api/v3/users/octocat/followers", "following_url": "https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}", "gists_url": "https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}", "starred_url": "https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://github.sonarsource.com/api/v3/users/octocat/subscriptions", "organizations_url": "https://github.sonarsource.com/api/v3/users/octocat/orgs", "repos_url": "https://github.sonarsource.com/api/v3/users/octocat/repos", "events_url": "https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}", "received_events_url": "https://github.sonarsource.com/api/v3/users/octocat/received_events", "type": "Organization", "site_admin": false }\ }"""; when(githubApplicationHttpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World")) .thenReturn(new GetResponse() { @Override public Optional getNextEndPoint() { return Optional.empty(); } @Override public int getCode() { return 200; } @Override public Optional getContent() { return Optional.of(responseJson); } @Override public RateLimit getRateLimit() { return RATE_LIMIT; } }); Optional repository = underTest.getRepository(appUrl, accessToken, "octocat/Hello-World"); assertThat(repository) .isPresent() .get() .extracting(GithubApplicationClient.Repository::getId, GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName, GithubApplicationClient.Repository::getUrl, GithubApplicationClient.Repository::isPrivate, GithubApplicationClient.Repository::getDefaultBranch) .containsOnly(1296269L, "Hello-World", "octocat/Hello-World", "https://github.com/octocat/Hello-World", false, "master"); } @Test public void createAppInstallationToken_throws_IAE_if_application_token_cant_be_created() { mockNoApplicationJwtToken(); assertThatThrownBy(() -> underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID)) .isInstanceOf(IllegalArgumentException.class); } private void mockNoApplicationJwtToken() { when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenThrow(IllegalArgumentException.class); } @Test public void createAppInstallationToken_returns_empty_if_post_throws_IOE() throws IOException { mockAppToken(); when(githubApplicationHttpClient.post(anyString(), any(AccessToken.class), anyString())).thenThrow(IOException.class); Optional accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID); assertThat(accessToken).isEmpty(); assertThat(logTester.getLogs(Level.WARN)).extracting(LogAndArguments::getRawMsg).anyMatch(s -> s.startsWith("Failed to request")); } @Test public void createAppInstallationToken_returns_empty_if_access_token_cant_be_created() throws IOException { AppToken appToken = mockAppToken(); mockAccessTokenCallingGithubFailure(); Optional accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID); assertThat(accessToken).isEmpty(); verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens"); } @Test public void createAppInstallationToken_from_installation_id_returns_access_token() throws IOException { AppToken appToken = mockAppToken(); ExpiringAppInstallationToken installToken = mockCreateAccessTokenCallingGithub(); Optional accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID); assertThat(accessToken).hasValue(installToken); verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens"); } @Test public void getRepositoryTeams_returnsRepositoryTeams() throws IOException { ArgumentCaptor>> deserializerCaptor = ArgumentCaptor.forClass(Function.class); when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedTeams()); Set repoTeams = underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME); assertThat(repoTeams) .containsExactlyInAnyOrderElementsOf(expectedTeams()); String responseContent = getResponseContent("repo-teams-full-response.json"); assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedTeams()); } @Test public void getRepositoryTeams_whenGitHubCallThrowsException_shouldRethrow() { when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), any())).thenThrow(new IllegalStateException("error")); assertThatIllegalStateException() .isThrownBy(() -> underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME)) .withMessage("error"); } private static List expectedTeams() { return List.of( new GsonRepositoryTeam("team1", 1, "team1", "pull", new GsonRepositoryPermissions(true, true, true, true, true)), new GsonRepositoryTeam("team2", 2, "team2", "push", new GsonRepositoryPermissions(false, false, true, true, true))); } @Test public void getRepositoryCollaborators_returnsCollaboratorsFromGithub() throws IOException { ArgumentCaptor>> deserializerCaptor = ArgumentCaptor.forClass(Function.class); when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedCollaborators()); Set repoTeams = underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME); assertThat(repoTeams) .containsExactlyInAnyOrderElementsOf(expectedCollaborators()); String responseContent = getResponseContent("repo-collaborators-full-response.json"); assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedCollaborators()); } @Test public void getRepositoryCollaborators_whenGitHubCallThrowsException_shouldRethrow() { when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), any())).thenThrow(new IllegalStateException("error")); assertThatIllegalStateException() .isThrownBy(() -> underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME)) .withMessage("error"); } private static String getResponseContent(String path) throws IOException { return IOUtils.toString(GithubApplicationClientImplTest.class.getResourceAsStream(path), StandardCharsets.UTF_8); } private static List expectedCollaborators() { return List.of( new GsonRepositoryCollaborator("jean-michel", 1, "role1", new GsonRepositoryPermissions(true, true, true, true, true)), new GsonRepositoryCollaborator("jean-pierre", 2, "role2", new GsonRepositoryPermissions(false, false, true, true, true))); } private void mockAccessTokenCallingGithubFailure() throws IOException { Response response = mock(Response.class); when(response.getContent()).thenReturn(Optional.empty()); when(response.getCode()).thenReturn(HTTP_UNAUTHORIZED); when(githubApplicationHttpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response); } private AppToken mockAppToken() { String jwt = secure().nextAlphanumeric(5); when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(new AppToken(jwt)); return new AppToken(jwt); } private ExpiringAppInstallationToken mockCreateAccessTokenCallingGithub() throws IOException { String token = secure().nextAlphanumeric(5); Response response = mock(Response.class); when(response.getContent()).thenReturn(Optional.of(format(""" { "token": "%s", "expires_at": "2024-08-28T10:44:51Z", "permissions": { "members": "read", "organization_administration": "read", "administration": "read", "metadata": "read" }, "repository_selection": "all" } """, token))); when(response.getCode()).thenReturn(HTTP_CREATED); when(githubApplicationHttpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response); return new ExpiringAppInstallationToken(clock, token, "2024-08-28T10:44:51Z"); } private static class OkGetResponse extends Response { private OkGetResponse(String content) { super(200, content); } } private static class ErrorGetResponse extends Response { ErrorGetResponse() { super(401, null); } ErrorGetResponse(int code, String content) { super(code, content); } } private static class Response implements GetResponse { private final int code; private final String content; private final String nextEndPoint; private Response(int code, @Nullable String content) { this(code, content, null); } private Response(int code, @Nullable String content, @Nullable String nextEndPoint) { this.code = code; this.content = content; this.nextEndPoint = nextEndPoint; } @Override public int getCode() { return code; } @Override public Optional getContent() { return Optional.ofNullable(content); } @Override public RateLimit getRateLimit() { return RATE_LIMIT; } @Override public Optional getNextEndPoint() { return Optional.ofNullable(nextEndPoint); } } }