Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable fetch plan for current app and account #1630

Merged
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,12 @@
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used to facilitate loading a custom private JWK for a GHApp

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an other option aside from adding a new test dependency? How much work does this save?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered relying on jjwt, but the feature does not exist yet into there, and people points back to nimbus-jose: jwtk/jjwt#236 (comment)

I would have to go through the JWK to PEM procedure, which is a burden given my own setup (i.e. my app using this lib relies on a JWK, not a PEM). Also, it would be more error-prone to rely on a given PEM content through environment variable (which is again my setup, and how I suggest to manage custom privateKey here).

I tried copying some classes from Nimbus to fill the gap, but it seems to require quite a bunch of classes. I'm having another try but focusing on the minimal set of methods to get a PrivateKey.

My only justification for this additional lib is existing tests for GitHub App APIs has not dynamic mechanisms for now (neither from local file, or from Env). I may then just drop this code, and be potentially discouraged to contribute here (this is a weak argument as I would be happy to fit into any existing mechanism, the current option would be to copy my own privateKey as a test resources, with a risk of pushing it publicly).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm dropping nimbus, with a mechanism to load a PEM from a Path provided as environment variable (similarly to the pushed code). However, I will not be a user of this mechanism. It will pave the way for next contribution requiring a custom JWK.

<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.5</version>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/org/kohsuke/github/GHAppInstallation.java
Original file line number Diff line number Diff line change
Expand Up @@ -361,4 +361,24 @@ public GHAppCreateTokenBuilder createToken(Map<String, GHPermissionType> permiss
public GHAppCreateTokenBuilder createToken() {
return new GHAppCreateTokenBuilder(root(), String.format("/app/installations/%d/access_tokens", getId()));
}

/**
* Shows whether the user or organization account actively subscribes to a plan listed by the authenticated GitHub
* App. When someone submits a plan change that won't be processed until the end of their billing cycle, you will
* also see the upcoming pending change.
*
* <p>
* GitHub Apps must use a JWT to access this endpoint.
* <p>
* OAuth Apps must use basic authentication with their client ID and client secret to access this endpoint.
*
* @return a GHMarketplaceAccountPlan instance
* @throws IOException
* @see <a href=
* "https://docs.github.com/en/rest/apps/marketplace?apiVersion=2022-11-28#get-a-subscription-plan-for-an-account">Get
* a subscription plan for an account</a>
*/
public GHMarketplaceAccountPlan getMarketplaceAccount() throws IOException {
return new GHMarketplacePlanForAccountBuilder(root(), account.getId()).createRequest();
}
}
23 changes: 23 additions & 0 deletions src/main/java/org/kohsuke/github/GHMarketplaceAccount.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,27 @@ public GHMarketplaceAccountType getType() {
return type;
}

/**
* Shows whether the user or organization account actively subscribes to a plan listed by the authenticated GitHub
* App. When someone submits a plan change that won't be processed until the end of their billing cycle, you will
* also see the upcoming pending change.
*
* <p>
* You use the returned builder to set various properties, then call
* {@link GHMarketplacePlanForAccountBuilder#createRequest()} to finally fetch the plan related this this account.
*
* <p>
* GitHub Apps must use a JWT to access this endpoint.
* <p>
* OAuth Apps must use basic authentication with their client ID and client secret to access this endpoint.
*
* @return a GHMarketplaceListAccountBuilder instance
* @see <a href=
* "https://developer.github.com/v3/apps/marketplace/#list-all-github-accounts-user-or-organization-on-a-specific-plan">List
* all GitHub accounts (user or organization) on a specific plan</a>
*/
public GHMarketplacePlanForAccountBuilder getPlan() {
return new GHMarketplacePlanForAccountBuilder(root(), this.id);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.kohsuke.github;

import java.io.IOException;

// TODO: Auto-generated Javadoc
/**
* Returns the plan associated with current account.
*
* @author Benoit Lacelle
* @see GHMarketplacePlan#listAccounts()
* @see GitHub#listMarketplacePlans()
*/
public class GHMarketplacePlanForAccountBuilder extends GitHubInteractiveObject {
private final Requester builder;
private final long accountId;

/**
* Instantiates a new GH marketplace list account builder.
*
* @param root
* the root
* @param accountId
* the account id
*/
GHMarketplacePlanForAccountBuilder(GitHub root, long accountId) {
super(root);
this.builder = root.createRequest();
this.accountId = accountId;
}

/**
* Fetch the plan associated with the account specified on construction.
* <p>
* GitHub Apps must use a JWT to access this endpoint.
*
* @return a GHMarketplaceAccountPlan
* @throws IOException
* on error
*/
public GHMarketplaceAccountPlan createRequest() throws IOException {
return builder.withUrlPath(String.format("/marketplace_listing/accounts/%d", this.accountId))
.fetch(GHMarketplaceAccountPlan.class);
}

}
70 changes: 53 additions & 17 deletions src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.kohsuke.github;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.crypto.impl.RSAKeyUtils;
import com.nimbusds.jose.jwk.RSAKey;
import io.jsonwebtoken.Jwts;
import org.apache.commons.io.IOUtils;
import org.kohsuke.github.authorization.AuthorizationProvider;
Expand All @@ -13,17 +16,25 @@
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.text.ParseException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Set;

// TODO: Auto-generated Javadoc
/**
* The Class AbstractGHAppInstallationTest.
*/
public class AbstractGHAppInstallationTest extends AbstractGitHubWireMockTest {

private static String ENV_GITHUB_APP_ID = "GITHUB_APP_ID";
blacelle marked this conversation as resolved.
Show resolved Hide resolved
private static String ENV_GITHUB_APP_TOKEN = "GITHUB_APP_TOKEN";
private static String ENV_GITHUB_APP_ORG = "GITHUB_APP_ORG";
private static String ENV_GITHUB_APP_REPO = "GITHUB_APP_REPO";

private static String TEST_APP_ID_1 = "82994";
private static String TEST_APP_ID_2 = "83009";
private static String TEST_APP_ID_3 = "89368";
Expand All @@ -44,17 +55,31 @@ public class AbstractGHAppInstallationTest extends AbstractGitHubWireMockTest {
* Instantiates a new abstract GH app installation test.
*/
protected AbstractGHAppInstallationTest() {
String appId = System.getenv(ENV_GITHUB_APP_ID);
String appToken = System.getenv(ENV_GITHUB_APP_TOKEN);
try {
jwtProvider1 = new JWTTokenProvider(TEST_APP_ID_1,
new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_1).getFile()));
jwtProvider2 = new JWTTokenProvider(TEST_APP_ID_2,
new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_2).getFile()).toPath());
jwtProvider3 = new JWTTokenProvider(TEST_APP_ID_3,
new String(
Files.readAllBytes(
new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_3).getFile()).toPath()),
StandardCharsets.UTF_8));
} catch (GeneralSecurityException | IOException e) {
if (appId != null && appToken != null) {
RSAKey rsaJWK;
try {
rsaJWK = RSAKey.parse(appToken);
} catch (IllegalStateException | ParseException e) {
throw new IllegalStateException("Issue parsing privateKey", e);
}

jwtProvider1 = new JWTTokenProvider(appId, RSAKeyUtils.toRSAPrivateKey(rsaJWK));
jwtProvider2 = new JWTTokenProvider(appId, RSAKeyUtils.toRSAPrivateKey(rsaJWK));
jwtProvider3 = new JWTTokenProvider(appId, RSAKeyUtils.toRSAPrivateKey(rsaJWK));
} else {
jwtProvider1 = new JWTTokenProvider(TEST_APP_ID_1,
new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_1).getFile()));
jwtProvider2 = new JWTTokenProvider(TEST_APP_ID_2,
new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_2).getFile()).toPath());
jwtProvider3 = new JWTTokenProvider(TEST_APP_ID_3,
new String(Files.readAllBytes(
new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_3).getFile()).toPath()),
StandardCharsets.UTF_8));
}
} catch (GeneralSecurityException | IOException | JOSEException e) {
throw new RuntimeException("These should never fail", e);
}
}
Expand Down Expand Up @@ -89,17 +114,28 @@ private String createJwtToken(String keyFileResouceName, String appId) {
* Signals that an I/O exception has occurred.
*/
protected GHAppInstallation getAppInstallationWithToken(String jwtToken) throws IOException {
if (jwtToken.startsWith("Bearer ")) {
jwtToken = jwtToken.substring("Bearer ".length());
}

GitHub gitHub = getGitHubBuilder().withJwtToken(jwtToken)
.withEndpoint(mockGitHub.apiServer().baseUrl())
.build();

GHAppInstallation appInstallation = gitHub.getApp()
.listInstallations()
.toList()
.stream()
.filter(it -> it.getAccount().login.equals("hub4j-test-org"))
.findFirst()
.get();
GHApp app = gitHub.getApp();

GHAppInstallation appInstallation;
if (Set.of(TEST_APP_ID_1, TEST_APP_ID_2, TEST_APP_ID_3).contains(Long.toString(app.getId()))) {
Copy link
Contributor Author

@blacelle blacelle Mar 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This switch leads to invalid recordings: I've done it to prevent recording the N installations of my real-world app. However, when re-running the tests without my custom JWK, the test will list all installations, which will match no stub (as we recorded the other branch of the if, which was focusing on a dedicated repo.)

This remain useful, however one has to edit the recordings manually (e.g. copy-pasting the installations listing from another test).

@bitwiseman Would you mind granting me access to the test repo/app ? It may be useful on related matters not requiring markplace-listing.

List<GHAppInstallation> installations = app.listInstallations().toList();
appInstallation = installations.stream()
.filter(it -> it.getAccount().login.equals("hub4j-test-org"))
.findFirst()
.get();
} else {
// We may be processing a custom JWK, for a custom GHApp: fetch a relevant repository dynamically
appInstallation = app.getInstallationByRepository(System.getenv(ENV_GITHUB_APP_ORG),
System.getenv(ENV_GITHUB_APP_REPO));
}

// TODO: this is odd
// appInstallation
Expand Down
13 changes: 13 additions & 0 deletions src/test/java/org/kohsuke/github/GHAppInstallationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,17 @@ public void testListRepositoriesNoPermissions() throws IOException {
appInstallation.listRepositories().toList().isEmpty());
}

/**
* Test list repositories no permissions.
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
@Test
public void testGetMarketplaceAccount() throws IOException {
GHAppInstallation appInstallation = getAppInstallationWithToken(jwtProvider3.getEncodedAuthorization());

GHMarketplacePlanTest.testMarketplaceAccount(appInstallation.getMarketplaceAccount());
}

}
22 changes: 12 additions & 10 deletions src/test/java/org/kohsuke/github/GHMarketplacePlanTest.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.kohsuke.github;

import org.hamcrest.Matchers;
import org.junit.Test;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

import static org.hamcrest.Matchers.*;
Expand Down Expand Up @@ -40,7 +42,7 @@ protected GitHubBuilder getGitHubBuilder() {
public void listMarketplacePlans() throws IOException {
List<GHMarketplacePlan> plans = gitHub.listMarketplacePlans().toList();
assertThat(plans.size(), equalTo(3));
plans.forEach(this::testMarketplacePlan);
plans.forEach(GHMarketplacePlanTest::testMarketplacePlan);
}

/**
Expand All @@ -55,7 +57,7 @@ public void listAccounts() throws IOException {
assertThat(plans.size(), equalTo(3));
List<GHMarketplaceAccountPlan> marketplaceUsers = plans.get(0).listAccounts().createRequest().toList();
assertThat(marketplaceUsers.size(), equalTo(2));
marketplaceUsers.forEach(this::testMarketplaceAccount);
marketplaceUsers.forEach(GHMarketplacePlanTest::testMarketplaceAccount);
}

/**
Expand All @@ -75,7 +77,7 @@ public void listAccountsWithDirection() throws IOException {
.createRequest()
.toList();
assertThat(marketplaceUsers.size(), equalTo(2));
marketplaceUsers.forEach(this::testMarketplaceAccount);
marketplaceUsers.forEach(GHMarketplacePlanTest::testMarketplaceAccount);
}

}
Expand All @@ -98,12 +100,12 @@ public void listAccountsWithSortAndDirection() throws IOException {
.createRequest()
.toList();
assertThat(marketplaceUsers.size(), equalTo(2));
marketplaceUsers.forEach(this::testMarketplaceAccount);
marketplaceUsers.forEach(GHMarketplacePlanTest::testMarketplaceAccount);
}

}

private void testMarketplacePlan(GHMarketplacePlan plan) {
static void testMarketplacePlan(GHMarketplacePlan plan) {
// Non-nullable fields
assertThat(plan.getUrl(), notNullValue());
assertThat(plan.getAccountsUrl(), notNullValue());
Expand All @@ -118,10 +120,10 @@ private void testMarketplacePlan(GHMarketplacePlan plan) {
assertThat(plan.getMonthlyPriceInCents(), greaterThanOrEqualTo(0L));

// list
assertThat(plan.getBullets().size(), equalTo(2));
assertThat(plan.getBullets().size(), Matchers.in(Arrays.asList(2, 3)));
}

private void testMarketplaceAccount(GHMarketplaceAccountPlan account) {
static void testMarketplaceAccount(GHMarketplaceAccountPlan account) {
// Non-nullable fields
assertThat(account.getLogin(), notNullValue());
assertThat(account.getUrl(), notNullValue());
Expand All @@ -146,7 +148,7 @@ private void testMarketplaceAccount(GHMarketplaceAccountPlan account) {
testMarketplacePendingChange(account.getMarketplacePendingChange());
}

private void testMarketplacePurchase(GHMarketplacePurchase marketplacePurchase) {
static void testMarketplacePurchase(GHMarketplacePurchase marketplacePurchase) {
// Non-nullable fields
assertThat(marketplacePurchase.getBillingCycle(), notNullValue());
assertThat(marketplacePurchase.getNextBillingDate(), notNullValue());
Expand All @@ -165,11 +167,11 @@ private void testMarketplacePurchase(GHMarketplacePurchase marketplacePurchase)
if (marketplacePurchase.getPlan().getPriceModel() == GHMarketplacePriceModel.PER_UNIT)
assertThat(marketplacePurchase.getUnitCount(), notNullValue());
else
assertThat(marketplacePurchase.getUnitCount(), nullValue());
assertThat(marketplacePurchase.getUnitCount(), Matchers.either(nullValue()).or(is(1L)));

}

private void testMarketplacePendingChange(GHMarketplacePendingChange marketplacePendingChange) {
static void testMarketplacePendingChange(GHMarketplacePendingChange marketplacePendingChange) {
// Non-nullable fields
assertThat(marketplacePendingChange.getEffectiveDate(), notNullValue());
testMarketplacePlan(marketplacePendingChange.getPlan());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"id": 65550,
"slug": "cleanthat",
"node_id": "MDM6QXBwNjU1NTA=",
"owner": {
"login": "solven-eu",
"id": 34552197,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjM0NTUyMTk3",
"avatar_url": "https://avatars.githubusercontent.com/u/34552197?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/solven-eu",
"html_url": "https://github.com/solven-eu",
"followers_url": "https://api.github.com/users/solven-eu/followers",
"following_url": "https://api.github.com/users/solven-eu/following{/other_user}",
"gists_url": "https://api.github.com/users/solven-eu/gists{/gist_id}",
"starred_url": "https://api.github.com/users/solven-eu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/solven-eu/subscriptions",
"organizations_url": "https://api.github.com/users/solven-eu/orgs",
"repos_url": "https://api.github.com/users/solven-eu/repos",
"events_url": "https://api.github.com/users/solven-eu/events{/privacy}",
"received_events_url": "https://api.github.com/users/solven-eu/received_events",
"type": "Organization",
"site_admin": false
},
"name": "CleanThat",
"description": "Cleanthat cleans branches automatically to fix/improve your code.\r\n\r\nFeatures :\r\n- Fix branches a pull_requests head\r\n- Open pull_request to fix protected branches\r\n- Format `.md`, `.java`, `.scala`, `.json`, `.yaml` with the help of [Spotless](https://github.com/diffplug/spotless)\r\n- Refactor `.java` files to improve code-style, security and stability",
"external_url": "https://github.com/solven-eu/cleanthat",
"html_url": "https://github.com/apps/cleanthat",
"created_at": "2020-05-19T13:45:43Z",
"updated_at": "2023-01-27T06:10:21Z",
"permissions": {
"checks": "write",
"contents": "write",
"metadata": "read",
"pull_requests": "write"
},
"events": [
"pull_request",
"push"
],
"installations_count": 280
}
Loading