Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
<artifactId>github</artifactId>
<version>1.31.0</version>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-support</artifactId>
Copy link
Member

Choose a reason for hiding this comment

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

For Timeout I guess. Wonder if this should be moved to the likes of plugin-util-api

</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
Expand Down Expand Up @@ -92,6 +96,24 @@
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-basic-steps</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>git</artifactId>
<classifier>tests</classifier>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</exclusion>
</exclusions>
</dependency>


<!-- JCasC compatibility -->
<dependency>
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.jenkinsci.plugins.github_branch_source;

import org.junit.Test;

import java.time.Duration;
import java.time.Instant;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

public class GithubAppCredentialsAppInstallationTokenTest {

@Test
public void testAppInstallationTokenStale() throws Exception {

GitHubAppCredentials.AppInstallationToken token;
long now;

now = Instant.now().getEpochSecond();
token = new GitHubAppCredentials.AppInstallationToken("", now);
assertThat(token.isStale(), is(false));
assertThat(token.getTokenStaleEpochSeconds(), equalTo(now + GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS));
Copy link
Member

Choose a reason for hiding this comment

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

Observed to flake in #527:

java.lang.AssertionError: 

Expected: <1648072806L>
     but: was <1648072807L>
	at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
	at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:6)
	at org.jenkinsci.plugins.github_branch_source.GithubAppCredentialsAppInstallationTokenTest.testAppInstallationTokenStale(GithubAppCredentialsAppInstallationTokenTest.java:23)

I do not think you can make an assertion like this without some grace period.


now = Instant.now().getEpochSecond();
token = new GitHubAppCredentials.AppInstallationToken("",
now + Duration.ofMinutes(15).getSeconds());
assertThat(token.isStale(), is(false));
assertThat(token.getTokenStaleEpochSeconds(), equalTo(now + GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS));

now = Instant.now().getEpochSecond();
token = new GitHubAppCredentials.AppInstallationToken("",
now + GitHubAppCredentials.AppInstallationToken.STALE_BEFORE_EXPIRATION_SECONDS + 2);
assertThat(token.isStale(), is(false));
assertThat(token.getTokenStaleEpochSeconds(), equalTo(now + GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS));

now = Instant.now().getEpochSecond();
token = new GitHubAppCredentials.AppInstallationToken("",
now + GitHubAppCredentials.AppInstallationToken.STALE_BEFORE_EXPIRATION_SECONDS + Duration
.ofMinutes(7)
.getSeconds());
assertThat(token.isStale(), is(false));
assertThat(token.getTokenStaleEpochSeconds(),
equalTo(now + Duration.ofMinutes(7).getSeconds()));

now = Instant.now().getEpochSecond();
token = new GitHubAppCredentials.AppInstallationToken("",
now + Duration.ofMinutes(90).getSeconds());
assertThat(token.isStale(), is(false));
assertThat(token.getTokenStaleEpochSeconds(),
equalTo(now + GitHubAppCredentials.AppInstallationToken.STALE_AFTER_SECONDS + 1));

long notStaleSeconds = GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS;
try {
// Should revert to 1 second minimum
GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = -10;

now = Instant.now().getEpochSecond();
token = new GitHubAppCredentials.AppInstallationToken("", now);
assertThat(token.isStale(), is(false));
assertThat(token.getTokenStaleEpochSeconds(), equalTo(now + 1));

// Verify goes stale
Thread.sleep(1000);
assertThat(token.isStale(), is(true));
} finally {
GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = notStaleSeconds;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package org.jenkinsci.plugins.github_branch_source;

import com.cloudbees.plugins.credentials.CredentialsProvider;
import com.cloudbees.plugins.credentials.CredentialsScope;
import com.cloudbees.plugins.credentials.CredentialsStore;
import com.cloudbees.plugins.credentials.domains.Domain;
import hudson.logging.LogRecorder;
import hudson.logging.LogRecorderManager;
import hudson.model.Slave;
import hudson.util.Secret;
import jenkins.plugins.git.GitSampleRepoRule;
import jenkins.plugins.git.GitStep;
import org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition;
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;

import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.logging.Formatter;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.SimpleFormatter;
import java.util.stream.Collectors;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.hamcrest.Matchers.*;

public class GithubAppCredentialsTest extends AbstractGitHubWireMockTest {

private static Slave agent;
private static final String myAppCredentialsId = "myAppCredentialsId";
private static CredentialsStore store;
private static GitHubAppCredentials appCredentials;
private static LogRecorder logRecorder;

@Rule
public GitSampleRepoRule sampleRepo = new GitSampleRepoRule();

@BeforeClass
public static void setUpJenkins() throws Exception {
//Add credential (Must have valid private key for Jwt to work, but App doesn't have to actually exist)
store = CredentialsProvider.lookupStores(r.jenkins).iterator().next();
appCredentials = new GitHubAppCredentials(
CredentialsScope.GLOBAL, myAppCredentialsId, "sample", "54321", Secret.fromString(JwtHelperTest.PKCS8_PRIVATE_KEY));
appCredentials.setOwner("cloudbeers");
store.addCredentials(Domain.global(), appCredentials);

LogRecorderManager mgr = r.jenkins.getLog();
logRecorder = new LogRecorder(GitHubAppCredentials.class.getName());
mgr.logRecorders.put(GitHubAppCredentials.class.getName(), logRecorder);
LogRecorder.Target t = new LogRecorder.Target(GitHubAppCredentials.class.getName(), Level.FINER);
logRecorder.targets.add(t);
logRecorder.save();
t.enable();
Copy link
Member

Choose a reason for hiding this comment

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

Did you just mean to use LoggerRule? Or are you doing it the hard way specifically to capture agent logs?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I tried LoggerRule and it doesn't capture agent logs. Otherwise, yes, I'd use LoggerRule.

Copy link
Member

Choose a reason for hiding this comment

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

Got it. Probably something that could be improved in LoggerRule.


// Add agent
agent = r.createOnlineSlave();
agent.setLabelString("my-agent");
}

@Before
public void setUpWireMock() throws Exception {
//Add wiremock responses for App, App Installation, and App Installation Token
githubApi.stubFor(
get(urlEqualTo("/app"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBodyFile("../AppCredentials/files/body-mapping-githubapp-app.json")));
githubApi.stubFor(
get(urlEqualTo("/app/installations"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBodyFile("../AppCredentials/files/body-mapping-githubapp-installations.json")));

final String scenarioName = "credentials-accesstoken";

githubApi.stubFor(
post(urlEqualTo("/app/installations/654321/access_tokens"))
.inScenario(scenarioName)
.whenScenarioStateIs("Started")
.willSetStateTo("1")
.withRequestBody(
equalToJson(
"{\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", true, false))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBody("{\n" +
" \"token\": \"super-secret-token\",\n" +
// This token will go stale at the soonest allowed time but will no be expired for the duration of the test
" \"expires_at\": \"" + printDate(new Date(System.currentTimeMillis() + Duration.ofMinutes(10).toMillis())) + "\"" + // 2019-08-10T05:54:58Z
"}")));

// Force an error to test fallback refreshing from agent
githubApi.stubFor(
post(urlEqualTo("/app/installations/654321/access_tokens"))
.inScenario(scenarioName)
.whenScenarioStateIs("1")
.willSetStateTo("2")
.withRequestBody(
equalToJson(
"{\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", true, false))
.willReturn(
aResponse()
.withStatus(404)
.withStatusMessage("404 Not Found")
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBody("{\"message\": \"File not found\"}")));

// Force an error to test fallback to returning unexpired token
githubApi.stubFor(
post(urlEqualTo("/app/installations/654321/access_tokens"))
.inScenario(scenarioName)
.whenScenarioStateIs("1")
.willSetStateTo("2")
.withRequestBody(
equalToJson(
"{\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", true, false))
.willReturn(
aResponse()
.withStatus(404)
.withStatusMessage("404 Not Found")
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBody("{\"message\": \"File not found\"}")));

// Force an error to test fallback refreshing from agent
githubApi.stubFor(
post(urlEqualTo("/app/installations/654321/access_tokens"))
.inScenario(scenarioName)
.whenScenarioStateIs("2")
.willSetStateTo("3")
.withRequestBody(
equalToJson(
"{\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", true, false))
.willReturn(
aResponse()
.withStatus(404)
.withStatusMessage("404 Not Found")
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBody("{\"message\": \"File not found\"}")));

githubApi.stubFor(
post(urlEqualTo("/app/installations/654321/access_tokens"))
.inScenario(scenarioName)
.whenScenarioStateIs("3")
.willSetStateTo("4")
.withRequestBody(
equalToJson(
"{\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", true, false))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBodyFile("../AppCredentials/files/body-githubapp-create-installation-accesstokens.json")));

}

@Test
public void testAgentRefresh() throws Exception {
long notStaleSeconds = GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS;
try {
appCredentials.setApiUri(githubApi.baseUrl());

// We want to demonstrate successful caching without waiting for a the default 1 minute
// Must set this to a large enough number to avoid flaky test
GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = 15;

final String jenkinsfile = String.join(
"\n",
"// run checkout several times",
"node ('my-agent') {",
// First Checkout on agent should use cached token passed via remoting
" checkout scm",
// Multiple checkouts in quick succession should cached token
" checkout scm",
" sleep " + (GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS + 2),
// Checkout after token expires refreshes via remoting - error on controller is not catastrophic
" checkout scm",
// Checkout after error will refresh again on controller
" checkout scm",
// Multiple checkouts in quick succession should use cached token
" checkout scm",
"}");


sampleRepo.init();
sampleRepo.write("Jenkinsfile", jenkinsfile);
sampleRepo.git("add", "Jenkinsfile");
sampleRepo.git("commit", "--message=init");

WorkflowJob job = r.createProject(WorkflowJob.class, "repo-master");
GitStep gitStep = new GitStep(sampleRepo.toString());
gitStep.setCredentialsId(myAppCredentialsId);
gitStep.setPoll(false);
job.setDefinition(new CpsScmFlowDefinition(gitStep.createSCM(), "Jenkinsfile"));
WorkflowRun run = job.scheduleBuild2(0).waitForStart();
r.waitForCompletion(run);

System.out.println(JenkinsRule.getLog(run));

List<String> credentialsLog = getOutputLines();

//Verify correct messages from GitHubAppCredential logger indicating token was retrieved on agent
assertThat("Creds should cache on master, pass to agent, and refresh agent from master once",
credentialsLog, contains(
// (agent log added out of order, see below)
"Keeping cached GitHub App Installation Token for app ID 54321 on agent: token is stale but has not expired",
// node ('my-agent') {
"Generating App Installation Token for app ID 54321",
"Token will become stale after 15 seconds",
"Generated App Installation Token for app ID 54321",
"Retrieved GitHub App Installation Token for app ID 54321",
// checkout scm
// checkout scm
// sleep
// (^^^ No token generation for these three steps)
// checkout scm
"Generating App Installation Token for app ID 54321",
// (error forced by wiremock)
"Failed to retrieve GitHub App installation token for app ID 54321",
"Keeping cached GitHub App Installation Token for app ID 54321: token is stale but has not expired",
// (error forced by wiremock - failed refresh on the agent)
"Generating App Installation Token for app ID 54321 for agent",
"Failed to retrieve GitHub App installation token for app ID 54321",
// (agent log added out of order) "Keeping cached GitHub App Installation Token for app ID 54321 on agent: token is stale but has not expired",
// checkout scm - refresh on controller
"Generating App Installation Token for app ID 54321",
"Token will become stale after 15 seconds",
"Generated App Installation Token for app ID 54321",
"Retrieved GitHub App Installation Token for app ID 54321"
// checkout scm
// (No token generation)
));
} finally {
GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = notStaleSeconds;
logRecorder.doClear();
}
}

private List<String> getOutputLines() {
final Formatter formatter = new SimpleFormatter();
List<LogRecord> result = new ArrayList<>(logRecorder.getLogRecords());
result.addAll(logRecorder.getSlaveLogRecords().get(agent.toComputer()));
Collections.reverse(result);
return result.stream()
.map(formatter::formatMessage)
.collect(Collectors.toList());
}

static String printDate(Date dt) {
return DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(dt.getTime()).truncatedTo(
ChronoUnit.SECONDS));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class JwtHelperTest {
public ExpectedException expectedException = ExpectedException.none();

// https://stackoverflow.com/a/22176759/4951015
private static final String PKCS8_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" +
public static final String PKCS8_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" +
// Windows line ending
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD7vHsVwyDV8cj7\r\n" +
// This should also work
Expand Down Expand Up @@ -54,7 +54,7 @@ public class JwtHelperTest {
"Nw9bewRvqjySBlDJ9/aNSeEY\n" +
"-----END PRIVATE KEY-----";

private static final String PKCS8_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\n" +
public static final String PKCS8_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\n" +
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+7x7FcMg1fHI++ckeFlp\n" +
"eq5YH/3uc5ngYLZtDwoJ4rXEmw+RVN999NQz2OWuwLalQTsBJxNeC/V0u6L+tVPs\n" +
"dZuGkJqkf3T0GgNmsodnGD7M1jUDKeNgKwyH4Ow4Uq77ESSh7Gz15T46nnNLnS6o\n" +
Expand Down
Loading