From 4e7329200004aadf459ddf589cf7a9fe6f236e06 Mon Sep 17 00:00:00 2001 From: Brendan Nolan Date: Sat, 10 Dec 2022 21:20:10 +0000 Subject: [PATCH] #508: Support Pagination over AppInstallation When more than a single page worth of app installation is present in the Github API, the plugin does not currently fetch beyond the initial page, and therefore doesn't find the access tokens for the required installation. By loading all pages of installations before searching for the current application we ensure that we don't ignore any relevant installation. --- ...RestApplicationAuthenticationProvider.java | 35 ++++++++++++++----- ...ApplicationAuthenticationProviderTest.java | 33 ++++++++++++++++- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java index 6f9717a90..864b629da 100644 --- a/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java +++ b/src/main/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProvider.java @@ -27,6 +27,7 @@ import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3.model.AppToken; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3.model.InstallationRepositories; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3.model.Repository; +import com.google.common.annotations.VisibleForTesting; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.impl.DefaultJwtBuilder; import org.bouncycastle.openssl.PEMKeyPair; @@ -43,7 +44,10 @@ import java.time.Clock; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; +import java.util.List; import java.util.Optional; public class RestApplicationAuthenticationProvider implements GithubApplicationAuthenticationProvider { @@ -69,6 +73,27 @@ public RestApplicationAuthenticationProvider(Clock clock, LinkHeaderReader linkH this.linkHeaderReader = linkHeaderReader; } + @VisibleForTesting + protected List getAppInstallations(ObjectMapper objectMapper, String apiUrl, String jwtToken) throws IOException { + + List appInstallations = new ArrayList<>(); + + URLConnection appConnection = urlProvider.createUrlConnection(apiUrl); + appConnection.setRequestProperty(ACCEPT_HEADER, APP_PREVIEW_ACCEPT_HEADER); + appConnection.setRequestProperty(AUTHORIZATION_HEADER, BEARER_AUTHORIZATION_HEADER_PREFIX + jwtToken); + + try (Reader reader = new InputStreamReader(appConnection.getInputStream())) { + appInstallations.addAll(Arrays.asList(objectMapper.readerFor(AppInstallation[].class).readValue(reader))); + } + + Optional nextLink = linkHeaderReader.findNextLink(appConnection.getHeaderField("Link")); + if (nextLink.isPresent()) { + appInstallations.addAll(getAppInstallations(objectMapper, nextLink.get(), jwtToken)); + } + + return appInstallations; + } + @Override public RepositoryAuthenticationToken getInstallationToken(String apiUrl, String appId, String apiPrivateKey, String projectPath) throws IOException { @@ -80,15 +105,7 @@ public RepositoryAuthenticationToken getInstallationToken(String apiUrl, String ObjectMapper objectMapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - URLConnection appConnection = urlProvider.createUrlConnection(getV3Url(apiUrl) + "/app/installations"); - appConnection.setRequestProperty(ACCEPT_HEADER, APP_PREVIEW_ACCEPT_HEADER); - appConnection.setRequestProperty(AUTHORIZATION_HEADER, BEARER_AUTHORIZATION_HEADER_PREFIX + jwtToken); - - AppInstallation[] appInstallations; - try (Reader reader = new InputStreamReader(appConnection.getInputStream())) { - appInstallations = objectMapper.readerFor(AppInstallation[].class).readValue(reader); - } - + List appInstallations = getAppInstallations(objectMapper, getV3Url(apiUrl) + "/app/installations", jwtToken); for (AppInstallation installation : appInstallations) { URLConnection accessTokenConnection = urlProvider.createUrlConnection(installation.getAccessTokensUrl()); diff --git a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java index c8a8ab574..0b3e27fdc 100644 --- a/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java +++ b/src/test/java/com/github/mc1arke/sonarqube/plugin/ce/pullrequest/github/v3/RestApplicationAuthenticationProviderTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 Michael Clarke + * Copyright (C) 2020-2022 Michael Clarke * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -18,8 +18,11 @@ */ package com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.LinkHeaderReader; import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.RepositoryAuthenticationToken; +import com.github.mc1arke.sonarqube.plugin.ce.pullrequest.github.v3.model.AppInstallation; import org.apache.commons.io.IOUtils; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -34,6 +37,7 @@ import java.time.Instant; import java.time.ZoneId; import java.util.Arrays; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -45,6 +49,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class RestApplicationAuthenticationProviderTest { @@ -140,6 +145,32 @@ private void testTokenForUrl(String apiUrl, String fullUrl) throws IOException { requestPropertyArgumentCaptor.getAllValues()); } + @Test + public void testAppInstallationsPagination() throws IOException { + + UrlConnectionProvider urlProvider = mock(UrlConnectionProvider.class); + Clock clock = Clock.fixed(Instant.ofEpochMilli(123456789L), ZoneId.of("UTC")); + + String apiUrl = "apiUrl"; + + int pages=4; + + for(int i=1; i<=pages;i++) { + HttpURLConnection installationsUrlConnection = mock(HttpURLConnection.class); + doReturn(installationsUrlConnection).when(urlProvider).createUrlConnection(eq(apiUrl + "/app/installations?page=" + i)); + when(installationsUrlConnection.getInputStream()).thenReturn(new ByteArrayInputStream( + "[{\"repositories_url\": \"repositories_url\", \"access_tokens_url\": \"tokens_url\"}]" + .getBytes(StandardCharsets.UTF_8))); + when(installationsUrlConnection.getHeaderField("Link")).thenReturn(i == pages ? null: apiUrl + "/app/installations?page=" + (i+1) ); + } + + RestApplicationAuthenticationProvider testCase = new RestApplicationAuthenticationProvider(clock, Optional::ofNullable, urlProvider); + ObjectMapper objectMapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + List token = testCase.getAppInstallations(objectMapper, apiUrl + "/app/installations?page=1", "token"); + assertThat(token).hasSize(pages); + + } + @Test public void testTokenRetrievedPaginatedHappyPath() throws IOException { UrlConnectionProvider urlProvider = mock(UrlConnectionProvider.class);