Git https auth round trip (#1720)

* feature/JENKINS-47247-https-roundtrip-2 * More WIP, might have to undo some of these in a new branch

* feature/JENKINS-47247-https-roundtrip-3 * WIP, breaking at a good resumable point :)

* feature/JENKINS-47247-https-roundtrip-3 * Successful round trip. Hacky, but successful

* feature/JENKINS-47247-https-roundtrip-3 * WIP, need to switch branches

* feature/JENKINS-47247-https-roundtrip-3 * Good steps towards bringing in the bb un/pw code for git

* feature/JENKINS-47247-https-roundtrip-3 * Committing this non-compiling tree so I can share it with TSC team

* feature/JENKINS-47247-https-roundtrip-3 * fix tsify version to 3.0.2 for support for case-sensitivity in TSC

* feature/JENKINS-47247-https-roundtrip-3 * WIP need to do some experiments

* feature/JENKINS-47247-https-roundtrip-3 import issues squashed

* feature/JENKINS-47247-https-roundtrip-3 * more good progress, need to run some tests on master

* feature/JENKINS-47247-https-roundtrip-3 * Still have a couple of loose ends in the editor, need to rebase

* feature/JENKINS-47247-https-roundtrip-3 * Seems to be all working, now time to rebase and clean up

* feature/JENKINS-47247-https-roundtrip-3 * Some cleanup

* feature/JENKINS-47247-https-roundtrip-3 * Fix an issue exposed by GitUtilsTest

* feature/JENKINS-47247-https-roundtrip-3 * Fix some test failures caused by my lazy debugging code :)

* feature/JENKINS-47247-https-roundtrip-3 * Add some tests to GitSCM enough to please coverage nazi, and fix a small clientside issue I noticed during debugging

* feature/JENKINS-47247-https-roundtrip-3 * Clean up some debugging, squash some TODOs

* feature/JENKINS-47247-https-roundtrip-3 * Moar cleanup

* feature/JENKINS-47247-https-roundtrip-3 * Add more unit tests, clean up some things

* feature/JENKINS-47247-https-roundtrip-3 * Add a typedef for TypedError since it can't be TS, and do some more cleanup and docs

* feature/JENKINS-47247-https-roundtrip-3 * Clean up some more TODOs and neaten some promise handling code

* feature/JENKINS-47247-https-roundtrip-3 * Upgrade tsify to 4.0

* feature/JENKINS-47247-https-roundtrip-3 * Add a unit test for GitPWCredentialsManager

* feature/JENKINS-47247-https-roundtrip-3 * Convert TypedError back to TypeScript again now I figured out how to fix the prototype chain

* feature/JENKINS-47247-https-roundtrip-3 * Fix up ATH test for git creation flow, and fix a typo in a button style

* feature/JENKINS-47247-https-roundtrip-3 * needs serious refucktoring. Compiles again but he ded.

* feature/JENKINS-47247-https-roundtrip-3 * Add a couple of cases to GitScm test, improve error reporting a bit

* feature/JENKINS-47247-https-roundtrip-3 * Move existingCredential into state obj, update tests

* feature/JENKINS-47247-https-roundtrip-3 * Clean up a couple of issues and debounce checking existing creds within the component

* feature/JENKINS-47247-https-roundtrip-3 * for some reason a test was relying on this not returning useful error info. I dunno.

* feature/JENKINS-47247-https-roundtrip-3 * Change debounce

* feature/JENKINS-47247-https-roundtrip-3 * Re-install tsify@4 to update shrinkwrap

* feature/JENKINS-47247-https-roundtrip-3 * Explictly optionalise params to UrlBuilder.buildRestUrl

* feature/JENKINS-47247-https-roundtrip-3 * Change generated credentialId to use a hash instead of raw normalized repo url

* feature/JENKINS-47247-https-roundtrip-3 * Fix the blinking issue by doing some more sensible comparisons on props and selections

* feature/JENKINS-47247-https-roundtrip-3 * Remove the transition slider because it doesn't make sense in this context, and clean up some more stuff

* feature/JENKINS-47247-https-roundtrip-3 * Add some type annotations

* feature/JENKINS-47247-https-roundtrip-3 * strip host/query/fragment from repo url during normalization

* feature/JENKINS-47247-https-roundtrip-3 * Tweak icon behaviour on create credential button
This commit is contained in:
Josh McDonald 2018-04-30 11:37:13 +10:00 committed by GitHub
parent d5b008d93b
commit eeebe5087d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 41747 additions and 13902 deletions

View File

@ -1855,6 +1855,15 @@
}
}
},
"string_decoder": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.1.tgz",
"integrity": "sha1-YuIA8DmVWmgQ2N8KM//A8BNmLZg=",
"dev": true,
"requires": {
"safe-buffer": "5.0.1"
}
},
"string-width": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
@ -1866,15 +1875,6 @@
"strip-ansi": "3.0.1"
}
},
"string_decoder": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.1.tgz",
"integrity": "sha1-YuIA8DmVWmgQ2N8KM//A8BNmLZg=",
"dev": true,
"requires": {
"safe-buffer": "5.0.1"
}
},
"stringstream": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
@ -4430,6 +4430,11 @@
"integrity": "sha1-pB6tGm1ggc63n2WwYZAbbY89HQ8=",
"dev": true
},
"string_decoder": {
"version": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=",
"dev": true
},
"string-width": {
"version": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
@ -4450,11 +4455,6 @@
"function-bind": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
}
},
"string_decoder": {
"version": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=",
"dev": true
},
"stringstream": {
"version": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
"integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=",

View File

@ -49,7 +49,7 @@ while true; do
done
EXECUTION="env JENKINS_JAVA_OPTS=\"${JENKINS_JAVA_OPTS}\" ${ATH_SERVER_HOST} ${ATH_SERVER_PORT} BROWSER=phantomjs LOCAL_SNAPSHOTS=${LOCAL_SNAPSHOTS} ${PLUGINS} PLUGINS_DIR=../runtime-plugins/runtime-deps/target/plugins-combined PATH=./node:./node/npm/bin:./node_modules/.bin:${PATH} mvn -Dhudson.model.UsageStatistics.disabled=true -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn -B -Dmaven.test.failure.ignore ${MAVEN_SETTINGS} test ${PROFILES} ${TEST_TO_RUN}"
EXECUTION="env JENKINS_JAVA_OPTS=\"${JENKINS_JAVA_OPTS}\" ${ATH_SERVER_HOST} ${ATH_SERVER_PORT} BROWSER=phantomjs LOCAL_SNAPSHOTS=${LOCAL_SNAPSHOTS} ${PLUGINS} PLUGINS_DIR=../runtime-plugins/runtime-deps/target/plugins-combined PATH=\"./node:./node/npm/bin:./node_modules/.bin:${PATH}\" mvn -Dhudson.model.UsageStatistics.disabled=true -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn -B -Dmaven.test.failure.ignore ${MAVEN_SETTINGS} test ${PROFILES} ${TEST_TO_RUN}"
echo ""
echo "> ${EXECUTION}"

View File

@ -47,27 +47,18 @@ public class GitCreationPage {
return this;
}
public MultiBranchPipeline createPipeline(SSEClientRule sseCLient, String pipelineName, String url, String sshPrivateKey, String user, String pass) throws IOException {
public MultiBranchPipeline createPipelineSSH(SSEClientRule sseCLient, String pipelineName, String url, String sshPrivateKey) throws IOException {
jobApi.deletePipeline(pipelineName);
dashboardPage.clickNewPipelineBtn();
clickGitCreationOption();
wait.until(By.cssSelector("div.text-repository-url input")).sendKeys(url);
wait.until(By.cssSelector("button.button-create-credential")).click();
if(!Strings.isNullOrEmpty(sshPrivateKey)) {
wait.until(By.xpath("//span[text()='SSH Key']")).click();
wait.until(By.cssSelector("textarea.TextArea-control")).sendKeys(sshPrivateKey);
wait.until(By.cssSelector(".Dialog-button-bar button.button-create-credental")).click();
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".create-credential-dialog")));
logger.info("Created credential");
} else if(!Strings.isNullOrEmpty(user) && !Strings.isNullOrEmpty(pass)) {
wait.until(By.xpath("//span[text()='Username & Password']")).click();
wait.until(By.cssSelector("div.text-username input")).sendKeys(user);
wait.until(By.cssSelector("div.text-password input")).sendKeys(pass);
wait.until(By.cssSelector(".Dialog-button-bar button.button-create-credental")).click();
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".create-credential-dialog")));
logger.info("Created user/pass credential");
}
wait.until(By.xpath("//span[text()='SSH Key']")).click();
wait.until(By.cssSelector("textarea.TextArea-control")).sendKeys(sshPrivateKey);
wait.until(By.cssSelector(".Dialog-button-bar button.button-create-credential")).click();
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".create-credential-dialog")));
logger.info("Created credential");
wait.until(By.cssSelector(".button-create-pipeline")).click();
logger.info("Click create pipeline button");
@ -79,4 +70,30 @@ public class GitCreationPage {
pipeline.getActivityPage().checkUrl();
return pipeline;
}
public MultiBranchPipeline createPipelinePW(SSEClientRule sseCLient, String pipelineName, String url, String user, String pass) throws IOException {
jobApi.deletePipeline(pipelineName);
dashboardPage.clickNewPipelineBtn();
clickGitCreationOption();
wait.until(By.cssSelector("div.text-repository-url input")).sendKeys(url);
wait.until(By.xpath("//*[contains(text(), 'Jenkins needs a user credential')]"));
wait.until(By.cssSelector("div.text-username input")).sendKeys(user);
wait.until(By.cssSelector("div.text-password input")).sendKeys(pass);
wait.until(By.cssSelector(".button-create-credential")).click();
wait.until(By.xpath("//*[contains(text(), 'Use existing credential')]"));
logger.info("Created user/pass credential");
wait.until(By.cssSelector(".button-create-pipeline")).click();
logger.info("Click create pipeline button");
MultiBranchPipeline pipeline = multiBranchPipelineFactory.pipeline(pipelineName);
wait.until(ExpectedConditions.urlContains(pipeline.getUrl() + "/activity"), 30000);
sseCLient.untilEvents(SSEEvents.activityComplete(pipeline.getName()));
driver.navigate().refresh();
pipeline.getActivityPage().checkUrl();
return pipeline;
}
}

View File

@ -30,7 +30,8 @@ import java.util.Properties;
public class GitCreationTest extends BlueOceanAcceptanceTest {
private Logger logger = Logger.getLogger(GitCreationTest.class);
@Inject @Named("live")
@Inject
@Named("live")
Properties liveProperties;
@Inject
@ -41,7 +42,8 @@ public class GitCreationTest extends BlueOceanAcceptanceTest {
@Inject
WaitUtil wait;
@Inject @Rule
@Inject
@Rule
public SSEClientRule sseClient;
@Test
@ -56,7 +58,7 @@ public class GitCreationTest extends BlueOceanAcceptanceTest {
Assert.assertNotNull(pipelineName);
logger.info("PipelineNameHttps: " + pipelineName);
logger.info("git repo - " + gitUrl);
AbstractPipeline pipeline = gitCreationPage.createPipeline(sseClient, pipelineName, gitUrl, null, user, pass);
AbstractPipeline pipeline = gitCreationPage.createPipelinePW(sseClient, pipelineName, gitUrl, user, pass);
pipeline.getActivityPage().testNumberRunsComplete(1);
}
@ -73,10 +75,7 @@ public class GitCreationTest extends BlueOceanAcceptanceTest {
logger.info("git repo - " + gitUrl);
String key = IOUtils.toString(new FileInputStream(privateKeyFile));
MultiBranchPipeline pipeline = gitCreationPage.createPipeline(sseClient, pipelineName, gitUrl, key, null, null);
MultiBranchPipeline pipeline = gitCreationPage.createPipelineSSH(sseClient, pipelineName, gitUrl, key);
pipeline.getActivityPage().testNumberRunsComplete(1);
}
}

View File

@ -90,8 +90,7 @@ public class BitbucketServerTest implements WebDriverMixin {
wait.click(By.cssSelector(".button-next-step"));
wait.sendKeys(By.cssSelector(".text-username input"),"admin");
wait.sendKeys(By.cssSelector(".text-password input"),"admin");
// TODO: fix spelling of credental to credential
wait.click(By.cssSelector(".button-create-credental"));
wait.click(By.cssSelector(".button-create-credential"));
LOGGER.info("Bitbucket server created successfully");
// Select project
creationPage.selectOrganization(BB_PROJECT_NAME);

View File

@ -1,4 +1,4 @@
"use strict";
'use strict';
process.env.SKIP_BLUE_IMPORTS = 'YES';
@ -20,84 +20,85 @@ const tsProject = ts.createProject('./tsconfig.json');
const config = {
react: {
sources: ["src/**/*.{js,jsx}", "!**/__mocks__/**"],
dest: "dist"
sources: ['src/**/*.{js,jsx}', '!**/__mocks__/**'],
dest: 'dist',
},
ts: {
sources: ["src/**/*.{ts,tsx}"],
dest: "dist",
destBundle: "target/tstemp"
sources: ['src/**/*.{ts,tsx}'],
dest: 'dist',
destBundle: 'target/tstemp',
},
less: {
sources: "src/less/core.less",
sources: 'src/less/core.less',
watch: 'src/less/**/*.{less,css}',
dest: "dist/assets/css",
dest: 'dist/assets/css',
},
copy: {
less_assets: {
sources: "src/less/**/*.svg",
dest: "dist/assets/css"
sources: 'src/less/**/*.svg',
dest: 'dist/assets/css',
},
},
};
// Watch all
gulp.task("watch", ["build"], () => {
gulp.watch(config.react.sources, ["compile-react"]);
gulp.watch(config.less.watch, ["less"]);
gulp.task('watch', ['build'], () => {
gulp.watch(config.react.sources, ['compile-react']);
gulp.watch(config.less.watch, ['less']);
});
// Default to all
gulp.task("default", ["validate"]);
gulp.task('default', ['validate']);
// Build all
gulp.task("build", ["compile-typescript", "compile-react", "less", "copy"]);
gulp.task('build', ['compile-typescript', 'compile-react', 'less', 'copy']);
// Compile react sources
gulp.task("compile-react", () =>
gulp.src(config.react.sources)
gulp.task('compile-react', () =>
gulp
.src(config.react.sources)
.pipe(sourcemaps.init())
.pipe(babel(config.react.babel))
.pipe(sourcemaps.write("."))
.pipe(gulp.dest(config.react.dest)));
.pipe(sourcemaps.write('.'))
.pipe(gulp.dest(config.react.dest))
);
gulp.task("compile-typescript", () =>
gulp.src(config.ts.sources)
gulp.task('compile-typescript', () =>
gulp
.src(config.ts.sources)
.pipe(tsProject())
.pipe(gulp.dest(config.ts.dest)));
gulp.task('copy-src', () =>
gulp.src("src/js/**/*")
.pipe(gulp.dest(config.ts.destBundle+'/js')));
gulp.task("compile-typescript-bundle", ['copy-src'], () =>
gulp.src(config.ts.sources)
.pipe(tsProject())
.pipe(gulp.dest(config.ts.destBundle)));
.pipe(gulp.dest(config.ts.dest))
);
gulp.task("less", () =>
gulp.src(config.less.sources)
gulp.task('copy-src', () => gulp.src('src/js/**/*').pipe(gulp.dest(config.ts.destBundle + '/js')));
gulp.task('compile-typescript-bundle', ['copy-src'], () =>
gulp
.src(config.ts.sources)
.pipe(tsProject())
.pipe(gulp.dest(config.ts.destBundle))
);
gulp.task('less', () =>
gulp
.src(config.less.sources)
.pipe(sourcemaps.init())
.pipe(less())
.pipe(rename("blueocean-core-js.css"))
.pipe(sourcemaps.write("."))
.pipe(gulp.dest(config.less.dest)));
.pipe(rename('blueocean-core-js.css'))
.pipe(sourcemaps.write('.'))
.pipe(gulp.dest(config.less.dest))
);
gulp.task("copy", ["copy-less-assets"]);
gulp.task('copy', ['copy-less-assets']);
gulp.task("copy-less-assets", () =>
gulp.src(config.copy.less_assets.sources)
.pipe(copy(config.copy.less_assets.dest, { prefix: 2 })));
gulp.task('copy-less-assets', () => gulp.src(config.copy.less_assets.sources).pipe(copy(config.copy.less_assets.dest, { prefix: 2 })));
// Validate contents
gulp.task("validate", ["lint", "test"], () => {
const paths = [
config.react.dest,
];
gulp.task('validate', ['lint', 'test'], () => {
const paths = [config.react.dest];
for (const path of paths) {
try {
@ -109,29 +110,27 @@ gulp.task("validate", ["lint", "test"], () => {
}
});
var builder = require('@jenkins-cd/js-builder');
builder.src([
config.ts.destBundle,
'less']);
builder.src([config.ts.destBundle, 'less']);
//
// Create the main bundle.
//
builder.bundle('target/tstemp/js/index.js', 'blueocean-core-js.js')
builder
.bundle('target/tstemp/js/index.js', 'blueocean-core-js.js')
.onStartup('./target/tstemp/js/bundleStartup.js')
.inDir('target/classes/io/jenkins/blueocean')
.less('src/less/blueocean-core-js.less')
.import('react@any', {
aliases: ['react/lib/React'] // in case a module requires react through the back door
aliases: ['react/lib/React'], // in case a module requires react through the back door
})
.import('react-dom@any')
.import("react-router@any")
.export("@jenkins-cd/js-extensions")
.export("@jenkins-cd/logging")
.import('react-router@any')
.export('@jenkins-cd/js-extensions')
.export('@jenkins-cd/logging')
.export('mobx');
//megaultrahax
gulp.tasks['js_bundle_blueocean-core-js_bundle_1'].dep=['compile-typescript-bundle'];
gulp.tasks['js_bundle_blueocean-core-js_bundle_1'].dep = ['compile-typescript-bundle'];

File diff suppressed because it is too large Load Diff

View File

@ -96,7 +96,7 @@
"run-sequence": "1.2.2",
"sinon": "1.17.6",
"ts-jest": "19.0.2",
"tsify": "3.0.4",
"tsify": "4.0.0",
"typescript": "2.7.2",
"watchify": "3.7.0"
},

View File

@ -280,7 +280,7 @@ export function toClassicJobPage(currentPageUrl, isMultibranch = false) {
* @param runId (optional) identifies an individual run
* @returns a URL string
*/
export function buildRestUrl(organizationName, pipelineFullName, branchName, runId) {
export function buildRestUrl(organizationName: string, pipelineFullName?: string, branchName?: string, runId?: string) {
const jenkinsUrl = AppConfig.getJenkinsRootURL();
let url = `${jenkinsUrl}/blue/rest/organizations/${encodeURIComponent(organizationName)}`;

View File

@ -8,7 +8,7 @@ export function execute(done, config) {
// have a special handling for core-js, must load the web bundle...
const Extensions = require('@jenkins-cd/js-extensions');
const appRoot = document.getElementsByTagName('head')[0].getAttribute('data-appurl');
Extensions.init({
extensionData: window.$blueocean.jsExtensions,
classMetadataProvider: (type, cb) => {

View File

@ -1,6 +1,6 @@
import React, { Component, PropTypes } from 'react';
import { LiveStatusIndicator as LiveStatusIndicatorJdl } from '@jenkins-cd/design-language';
import { TimeHarmonizer} from '../components/TimeHarmonizer';
import { TimeHarmonizer } from '../components/TimeHarmonizer';
import { logging } from '../logging';
const logger = logging.logger('io.jenkins.blueocean.core.LiveStatusIndicator');

View File

@ -1,4 +1,4 @@
import { RestPaths as rest } from './rest';
export const Paths = {
rest
rest,
};

View File

@ -8,7 +8,6 @@ import { assert } from 'chai';
import { Utils } from '../../src/js/utils';
import { User } from '../../src/js/User';
describe('User', () => {
describe('permissions', () => {
it('User has pipeline permissions', () => {

View File

@ -5,10 +5,8 @@ import { shallow } from 'enzyme';
import { Utils } from '../../../src/js/utils';
import { ReplayButton } from '../../../src/js/components/ReplayButton';
jest.mock('../../../src/js/i18n/i18n');
describe('ReplayButton', () => {
let pipeline;

View File

@ -7,7 +7,6 @@ import { Utils } from '../../../src/js/utils';
jest.unmock('../../../src/js/index');
jest.mock('../../../src/js/i18n/i18n');
import { RunButton } from '../../../src/js/components/RunButton';
describe('RunButton', () => {

View File

@ -7,29 +7,18 @@ import WithContext from '@jenkins-cd/design-language/dist/js/stories/WithContext
import moment from 'moment';
import { TimeHarmonizer, TimeHarmonizerUtil } from '../../../src/js/components/TimeHarmonizer';
jest.mock('../../../src/js/i18n/i18n');
@TimeHarmonizer
class UselessComponent extends Component {
render() {
const {
isRunning,
startTime,
endTime,
durationInMillis,
getTimes,
getDuration,
getI18nTitle,
result,
} = this.props;
const { isRunning, startTime, endTime, durationInMillis, getTimes, getDuration, getI18nTitle, result } = this.props;
const processedTimes = getTimes({
startTime,
endTime,
durationInMillis,
result
result,
});
return (
@ -37,37 +26,35 @@ class UselessComponent extends Component {
<h3>Input</h3>
<dl>
<dt>startTime</dt>
<dd className="in-startTime">{ String(startTime) }</dd>
<dd className="in-startTime">{String(startTime)}</dd>
<dt>endTime</dt>
<dd className="in-endTime">{ String(endTime) }</dd>
<dd className="in-endTime">{String(endTime)}</dd>
<dt>durationInMillis</dt>
<dd className="in-durationInMillis">{ String(durationInMillis) }</dd>
<dd className="in-durationInMillis">{String(durationInMillis)}</dd>
<dt>isRunning</dt>
<dd className="in-isRunning">{ String(isRunning) }</dd>
<dd className="in-isRunning">{String(isRunning)}</dd>
</dl>
<h3>Synced</h3>
<dl>
<dt>startTime</dt>
<dd className="syn-startTime">{ String(processedTimes.startTime) }</dd>
<dd className="syn-startTime">{String(processedTimes.startTime)}</dd>
<dt>endTime</dt>
<dd className="syn-endTime">{ String(processedTimes.endTime) }</dd>
<dd className="syn-endTime">{String(processedTimes.endTime)}</dd>
<dt>durationInMillis</dt>
<dd className="syn-durationInMillis">{ String(processedTimes.durationInMillis) }</dd>
<dd className="syn-durationInMillis">{String(processedTimes.durationInMillis)}</dd>
<dt>getDuration</dt>
<dd className="syn-getDuration">{ String(getDuration()) }</dd>
<dd className="syn-getDuration">{String(getDuration())}</dd>
<dt>getI18nTitle</dt>
<dd className="syn-getI18nTitle">{ String(getI18nTitle(result)) }</dd>
<dd className="syn-getI18nTitle">{String(getI18nTitle(result))}</dd>
</dl>
</div>
)
);
}
}
describe('TimeHarmonizer', () => {
it('/ renders', () => {
const wrapper = shallow(
<UselessComponent/>
);
const wrapper = shallow(<UselessComponent />);
});
const time1Europe = '2017-01-25T10:28:34.755+0100';
@ -90,9 +77,7 @@ describe('TimeHarmonizer', () => {
TimeHarmonizerUtil.timeManager.currentTime = oldClock;
});
it('/ renders with time props', () => {
TimeHarmonizerUtil.timeManager.currentTime = () => moment(time1ZuluPlus5m);
let timeRelatedProps = {
@ -103,7 +88,7 @@ describe('TimeHarmonizer', () => {
result: 'running',
};
let wrapper = mount(<UselessComponent {...timeRelatedProps}/>);
let wrapper = mount(<UselessComponent {...timeRelatedProps} />);
// Input
@ -138,15 +123,14 @@ describe('TimeHarmonizer', () => {
});
it('/ renders with time props and context without drift', () => {
TimeHarmonizerUtil.timeManager.currentTime = () => moment(time2ZuluPlus10m);
let ctx = {
config: {
getServerBrowserTimeSkewMillis: () => {
return 0;
}
}
},
},
};
let timeRelatedProps = {
@ -159,7 +143,7 @@ describe('TimeHarmonizer', () => {
let wrapper = mount(
<WithContext context={ctx}>
<UselessComponent {...timeRelatedProps}/>
<UselessComponent {...timeRelatedProps} />
</WithContext>
);
@ -196,15 +180,14 @@ describe('TimeHarmonizer', () => {
});
it('/ renders with time props and context with drift and endTime', () => {
TimeHarmonizerUtil.timeManager.currentTime = () => moment(time2ZuluPlus10m);
let ctx = {
config: {
getServerBrowserTimeSkewMillis: () => {
return -5 * 60 * 60 * 1000; // -5 hours
}
}
},
},
};
let timeRelatedProps = {
@ -217,7 +200,7 @@ describe('TimeHarmonizer', () => {
let wrapper = mount(
<WithContext context={ctx}>
<UselessComponent {...timeRelatedProps}/>
<UselessComponent {...timeRelatedProps} />
</WithContext>
);

View File

@ -4,7 +4,6 @@ import nock from 'nock';
import { TestUtils } from '../../src/js/testutils';
import { Fetch } from '../../src/js/fetch';
describe('Fetch', () => {
describe('fetchJSON', () => {
let nockServer = null;
@ -22,75 +21,59 @@ describe('Fetch', () => {
describe('2xx success', () => {
it('with simple object response body', () => {
nockServer
.get('/success/simple')
.reply(200, { foo: 'bar' });
nockServer.get('/success/simple').reply(200, { foo: 'bar' });
requestUrl += '/success/simple';
return Fetch.fetchJSON(requestUrl)
.then(
response => {
assert.isOk(response);
assert.equal(response.foo, 'bar');
}
);
return Fetch.fetchJSON(requestUrl).then(response => {
assert.isOk(response);
assert.equal(response.foo, 'bar');
});
});
it('with empty response body', () => {
nockServer
.get('/success/empty')
.reply(200, null);
nockServer.get('/success/empty').reply(200, null);
requestUrl += '/success/empty';
return Fetch.fetchJSON(requestUrl)
.then(
response => {
assert.isOk(response);
}
);
return Fetch.fetchJSON(requestUrl).then(response => {
assert.isOk(response);
});
});
});
describe('4xx failure', () => {
it('with simple object response body', () => {
nockServer
.get('/failure/simple')
.reply(400, { message: 'validation' });
nockServer.get('/failure/simple').reply(400, { message: 'validation' });
requestUrl += '/failure/simple';
return Fetch.fetchJSON(requestUrl)
.then(
() => {
assert.fail(null, null, 'should not call success handler');
},
error => {
assert.isOk(error);
assert.isOk(error.responseBody);
assert.equal(error.responseBody.message, 'validation');
}
);
return Fetch.fetchJSON(requestUrl).then(
() => {
assert.fail(null, null, 'should not call success handler');
},
error => {
assert.isOk(error);
assert.isOk(error.responseBody);
assert.equal(error.responseBody.message, 'validation');
}
);
});
it('with empty response body', () => {
nockServer
.get('/failure/empty')
.reply(400, null);
nockServer.get('/failure/empty').reply(400, null);
requestUrl += '/failure/empty';
return Fetch.fetchJSON(requestUrl)
.then(
() => {
assert.fail(null, null, 'should not call success handler');
},
error => {
assert.isOk(error);
assert.isNull(error.responseBody);
}
);
return Fetch.fetchJSON(requestUrl).then(
() => {
assert.fail(null, null, 'should not call success handler');
},
error => {
assert.isOk(error);
assert.isNull(error.responseBody);
}
);
});
});
@ -98,6 +81,5 @@ describe('Fetch', () => {
// TODO: dedupe?
// TODO: preloader
// TODO: loading indicator
});
});

View File

@ -12,7 +12,6 @@ function setAppUrl(url) {
}
}
describe('urlconfig', () => {
beforeEach(() => {
UrlConfig.enableReload();

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,7 @@
"skin-deep": "0.16.0",
"ts-jest": "19.0.2",
"ts-node": "5.0.1",
"tsify": "3.0.4",
"tsify": "4.0.0",
"typescript": "2.7.2"
},
"dependencies": {
@ -68,6 +68,7 @@
"immutable": "3.8.1",
"isomorphic-fetch": "2.2.1",
"keymirror": "0.1.1",
"lodash.debounce": "4.0.8",
"mobx": "2.6.0",
"mobx-react": "3.5.7",
"mobx-utils": "1.1.2",

View File

@ -189,7 +189,7 @@ export class CreateCredentialDialog extends React.Component {
const disabled = this.state.creationPending;
const buttons = [
<button className="button-create-credental" disabled={disabled} onClick={() => this._onCreateClick()}>
<button className="button-create-credential" disabled={disabled} onClick={() => this._onCreateClick()}>
{t('creation.git.create_credential.button_create')}
</button>,
<button className="btn-secondary" disabled={disabled} onClick={() => this._onCloseClick()}>

View File

@ -1,7 +0,0 @@
import { CredentialsApi } from './CredentialsApi';
import { CredentialsManager } from './CredentialsManager';
const api = new CredentialsApi();
const manager = new CredentialsManager(api);
export { manager as credentialsManager };

View File

@ -2,12 +2,10 @@ import React, { PropTypes } from 'react';
import { observer } from 'mobx-react';
import debounce from 'lodash.debounce';
import Extensions from '@jenkins-cd/js-extensions';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import { Alerts, Dropdown, FormElement, TextInput } from '@jenkins-cd/design-language';
import { FormElement, TextInput } from '@jenkins-cd/design-language';
import FlowStep from '../flow2/FlowStep';
import { CreateCredentialDialog } from '../credentials/CreateCredentialDialog';
import { CreatePipelineOutcome } from './GitCreationApi';
import STATE from './GitCreationState';
@ -33,13 +31,6 @@ export function isSshRepositoryUrl(url) {
return false;
}
function isNonSshRepositoryUrl(url) {
if (!validateUrl(url)) {
return false;
}
return !isSshRepositoryUrl(url) && /[^@:]+:\/\/.*/.test(url);
}
/**
* Component that accepts repository URL and credentials to initiate
* creation of a new pipeline.
@ -54,19 +45,13 @@ export default class GitConnectStep extends React.Component {
repositoryErrorMsg: null,
credentialErrorMsg: null,
selectedCredential: null,
showCreateCredentialDialog: false,
};
t = this.props.flowManager.translate;
}
componentWillMount() {
const { noCredentialsOption } = this.props.flowManager;
this._selectedCredentialChange(noCredentialsOption);
}
_bindDropdown(dropdown) {
this.dropdown = dropdown;
this._selectedCredentialChange(this.props.flowManager.noCredentialsOption);
}
_repositoryUrlChange(value) {
@ -96,6 +81,12 @@ export default class GitConnectStep extends React.Component {
}, 200);
_selectedCredentialChange(credential) {
const oldId = this.state.selectedCredential && this.state.selectedCredential.id;
const newId = credential && credential.id;
if (oldId === newId) {
return;
}
this.setState({
selectedCredential: credential,
});
@ -111,30 +102,9 @@ export default class GitConnectStep extends React.Component {
return null;
}
_onCreateCredentialClick() {
this.setState({
showCreateCredentialDialog: true,
});
}
_onCreateCredentialClosed(credential) {
const newState = {
showCreateCredentialDialog: false,
};
if (credential) {
newState.selectedCredential = credential;
}
this.setState(newState);
// TODO: control this more cleanly via a future 'selectedOption' prop on Dropdown
if (this.dropdown) {
this.dropdown.setState({
selectedOption: credential,
});
}
}
_onCreateCredentialClosed = credential => {
this._selectedCredentialChange(credential || this.props.flowManager.noCredentialsOption);
};
_performValidation() {
if (!validateUrl(this.state.repositoryUrl)) {
@ -159,8 +129,8 @@ export default class GitConnectStep extends React.Component {
}
render() {
const { noCredentialsOption } = this.props.flowManager;
const { flowManager } = this.props;
const { repositoryUrl } = this.state;
const repositoryErrorMsg = this._getRepositoryErrorMsg(flowManager.outcome);
const credentialErrorMsg = this._getCredentialErrorMsg(flowManager.outcome);
@ -180,60 +150,18 @@ export default class GitConnectStep extends React.Component {
<TextInput className="text-repository-url" onChange={val => this._repositoryUrlChange(val)} />
</FormElement>
<ReactCSSTransitionGroup
transitionName="slide-down"
transitionAppear
transitionAppearTimeout={300}
transitionEnterTimeout={300}
transitionLeaveTimeout={300}
>
{isSshRepositoryUrl(this.state.repositoryUrl) && (
<Extensions.Renderer
extensionPoint="jenkins.credentials.selection"
onComplete={credential => this._onCreateCredentialClosed(credential)}
type="git"
repositoryUrl={this.state.repositoryUrl}
/>
)}
<Extensions.Renderer
extensionPoint="jenkins.credentials.selection"
className="credentials-selection-git"
onComplete={this._onCreateCredentialClosed}
type="git"
repositoryUrl={repositoryUrl}
/>
{isNonSshRepositoryUrl(this.state.repositoryUrl) && (
<div>
<div style={{ marginTop: 16, marginBottom: 10 }}>
<Alerts
type="Warning"
message={
<div style={{ marginTop: 6, marginBottom: 6 }}>
Saving Pipelines is unsupported using http/https repositories. Please use SSH instead.
</div>
}
/>
</div>
<FormElement title={t('creation.git.step1.credentials')} errorMessage={credentialErrorMsg}>
<Dropdown
ref={dropdown => this._bindDropdown(dropdown)}
className="dropdown-credentials"
options={flowManager.credentials}
defaultOption={noCredentialsOption}
labelField="displayName"
onChange={opt => this._selectedCredentialChange(opt)}
/>
<button className="button-create-credential btn-secondary" onClick={() => this._onCreateCredentialClick()}>
{t('creation.git.step1.create_credential_button')}
</button>
</FormElement>
</div>
)}
</ReactCSSTransitionGroup>
{this.state.showCreateCredentialDialog && (
<CreateCredentialDialog flowManager={flowManager} onClose={cred => this._onCreateCredentialClosed(cred)} />
)}
{isSshRepositoryUrl(this.state.repositoryUrl) &&
{isSshRepositoryUrl(repositoryUrl) &&
credentialErrorMsg && <FormElement className="public-key-display" errorMessage={t('creation.git.step1.credentials_publickey_invalid')} />}
<button className="button-create-pipeline" onClick={() => this._beginCreation()} disabled={!validateUrl(this.state.repositoryUrl)}>
<button className="button-create-pipeline" onClick={() => this._beginCreation()} disabled={!validateUrl(repositoryUrl)}>
{createButtonLabel}
</button>
</FlowStep>

View File

@ -45,7 +45,8 @@ class CredentialsPicker extends React.Component {
} else if (type === 'bitbucket-cloud' || type === 'bitbucket-server') {
children = <BbCredentialsPicker scmId={scmSource.id} apiUrl={scmSource.apiUrl} />;
} else if (type === 'git') {
children = <GitCredentialsPicker scmId={scmSource.id} />;
const repositoryUrl = this.props.repositoryUrl || scmSource.apiUrl;
children = <GitCredentialsPicker repositoryUrl={repositoryUrl} />;
} else {
children = <div>No credential picker could be found for type={type}</div>;
}
@ -63,6 +64,7 @@ CredentialsPicker.propTypes = {
dialog: PropTypes.bool,
pipeline: PropTypes.object,
repositoryUrl: PropTypes.string,
existingFailed: PropTypes.bool,
scmSource: PropTypes.shape({
id: PropTypes.string,
apiUrl: PropTypes.string,

View File

@ -1,14 +0,0 @@
/**
* Utlity class for "typing" of server errors
*/
class TypedError {
constructor(type, serverError) {
const { code, message, errors } = serverError || {};
this.type = type;
this.code = code;
this.message = message;
this.errors = errors;
}
}
export default TypedError;

View File

@ -0,0 +1,47 @@
/**
* Utlity class for "typing" of server errors
*/
export interface ServerError {
code?: number,
message?: string,
errors?: Array<string>
}
export class TypedError extends Error {
type: string;
code: number;
errors: Array<string>;
constructor(type?: string, serverError?: ServerError) {
super();
// When subclassing Error and targetting ES5, super() wrecks our prototype chain
this.constructor = TypedError;
if ((Object as any).setPrototypeOf) {
(Object as any).setPrototypeOf(this, TypedError.prototype);
} else {
(this as any).__proto__ = TypedError.prototype;
}
this.name = 'TypedError';
return this.populate(type || 'TypedError', serverError);
}
populate(type: string, serverError?: ServerError) {
const {
code = -1,
message = undefined,
errors = []
} = serverError || {};
this.type = type;
this.code = code;
this.message = message || String(serverError || type);
this.errors = errors;
return this;
}
}

View File

@ -1,5 +1,5 @@
import { Fetch, UrlConfig, Utils, AppConfig } from '@jenkins-cd/blueocean-core-js';
import TypedError from '../TypedError';
import { TypedError } from '../TypedError';
export const LoadError = {
TOKEN_NOT_FOUND: 'TOKEN_NOT_FOUND',

View File

@ -2,7 +2,7 @@ import { action, observable } from 'mobx';
import PromiseDelayUtils from '../../util/PromiseDelayUtils';
import BbCredentialsApi from './BbCredentialsApi';
import BbCredentialState from './BbCredentialsState';
import BbCredentialsState from './BbCredentialsState';
import { LoadError, SaveError } from './BbCredentialsApi';
const MIN_DELAY = 500;
@ -28,7 +28,7 @@ class BbCredentialsManager {
@action
findExistingCredential() {
this.stateId = BbCredentialState.PENDING_LOADING_CREDS;
this.stateId = BbCredentialsState.PENDING_LOADING_CREDS;
return this._credentialsApi
.findExistingCredential(this.apiUrl)
.then(...delayBoth(MIN_DELAY))
@ -38,13 +38,13 @@ class BbCredentialsManager {
@action
_findExistingCredentialFailure(error) {
if (error.type === LoadError.TOKEN_NOT_FOUND) {
this.stateId = BbCredentialState.NEW_REQUIRED;
this.stateId = BbCredentialsState.NEW_REQUIRED;
} else if (error.type === LoadError.TOKEN_INVALID) {
this.stateId = BbCredentialState.INVALID_CREDENTIAL;
this.stateId = BbCredentialsState.INVALID_CREDENTIAL;
} else if (error.type === LoadError.TOKEN_REVOKED) {
this.stateId = BbCredentialState.REVOKED_CREDENTIAL;
this.stateId = BbCredentialsState.REVOKED_CREDENTIAL;
} else {
this.stateId = BbCredentialState.UNEXPECTED_ERROR_CREDENTIAL;
this.stateId = BbCredentialsState.UNEXPECTED_ERROR_CREDENTIAL;
}
}
@ -62,7 +62,7 @@ class BbCredentialsManager {
@action
_createCredentialSuccess(credential) {
this.pendingValidation = false;
this.stateId = BbCredentialState.SAVE_SUCCESS;
this.stateId = BbCredentialsState.SAVE_SUCCESS;
return credential;
}
@ -71,7 +71,7 @@ class BbCredentialsManager {
this.pendingValidation = false;
if (error.type === SaveError.INVALID_CREDENTIAL) {
this.stateId = BbCredentialState.INVALID_CREDENTIAL;
this.stateId = BbCredentialsState.INVALID_CREDENTIAL;
} else {
throw error;
}

View File

@ -162,7 +162,7 @@ class BbCredentialsPicker extends React.Component {
<PasswordInput className="text-password" onChange={val => this._passwordChange(val)} />
</FormElement>
</FormElement>
<Button className="button-create-credental" status={status} onClick={() => this._createCredential()}>
<Button className="button-create-credential" status={status} onClick={() => this._createCredential()}>
Connect
</Button>
</div>

View File

@ -3,7 +3,7 @@ import { Enum } from '../../creation/flow2/Enum';
/**
* Valid stateId's for BitBucketCredentialsStep
*/
const BbCredentialState = new Enum({
export const BbCredentialsState = new Enum({
PENDING_LOADING_CREDS: 'PENDING_LOADING_CREDS',
NEW_REQUIRED: 'new_required',
SAVE_SUCCESS: 'save_success',
@ -12,4 +12,4 @@ const BbCredentialState = new Enum({
UNEXPECTED_ERROR_CREDENTIAL: 'unexpected_error_credential',
});
export default BbCredentialState;
export default BbCredentialsState;

View File

@ -1,151 +1,41 @@
import React, { PropTypes } from 'react';
import { FormElement } from '@jenkins-cd/design-language';
import { Fetch, AppConfig, i18nTranslator } from '@jenkins-cd/blueocean-core-js';
import { Button } from '../../creation/github/Button';
const t = i18nTranslator('blueocean-dashboard');
import { GitCredentialsPickerSSH } from './GitCredentialsPickerSSH';
import { GitCredentialsPickerPassword } from './GitCredentialsPickerPassword';
function copySelectionText() {
let copysuccess; // var to check whether execCommand successfully executed
try {
copysuccess = document.execCommand('copy'); // copy selected text to clipboard
} catch (_) {
copysuccess = false;
function isSshRepositoryUrl(url) {
if (typeof url !== 'string' || url.trim().length === 0) {
return false;
}
return copysuccess;
if (/^ssh:\/\/.*/.test(url)) {
// is ssh:// protocol
return true;
}
if (/^[^@:]+@.*/.test(url)) {
// No protocol, but has a "user@host[...]" format
return true;
}
return false;
}
function clearSelection() {
if (window.getSelection) {
window.getSelection().removeAllRanges();
} else if (document.selection) {
document.selection.empty();
}
}
/**
* Just a wrapper to decide between the SSH component and username/pw component based on repositoryUrl
*/
const GitCredentialsPicker = props => {
const { repositoryUrl } = props;
class GitCredentialsPicker extends React.Component {
constructor(props) {
super(props);
this.state = {
credential: null,
credentialError: null,
};
this.restOrgPrefix = AppConfig.getRestRoot() + '/organizations/' + AppConfig.getOrganizationName();
if (!repositoryUrl) {
return null; // Repo URL decides wether we show certificate or un/pw
}
componentWillMount() {
const { onStatus, dialog, onComplete } = this.props;
if (onStatus) {
onStatus('promptLoading');
}
Fetch.fetchJSON(this.restOrgPrefix + '/user/publickey/').then(credential => {
this.setState({ credential });
if (onStatus) {
onStatus('promptReady');
}
if (!dialog) {
onComplete(credential);
}
});
if (isSshRepositoryUrl(repositoryUrl)) {
return <GitCredentialsPickerSSH {...props} />;
}
copyPublicKeyToClipboard() {
const textBox = this.publicKeyElement;
textBox.select();
copySelectionText();
clearSelection();
textBox.blur();
}
testCredentialAndCloseDialog() {
const { onComplete, repositoryUrl, pipeline, requirePush, branch } = this.props;
const body = {
repositoryUrl,
pipeline,
credentialId: this.state.credential.id,
};
if (requirePush) {
body.requirePush = true;
body.branch = branch || 'master';
}
const fetchOptions = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
this.setState({ connectStatus: { result: 'running' } });
return Fetch.fetchJSON(this.restOrgPrefix + '/scm/git/validate', { fetchOptions })
.then(() => {
this.setState({
credentialError: null,
connectStatus: {
result: 'success',
reset: false,
},
});
onComplete(this.state.credential);
})
.catch(error => {
const message = error.responseBody ? error.responseBody.message : 'An unknown error occurred';
this.setState({
credentialError: message && t('creation.git.step1.credentials_publickey_invalid'),
connectStatus: {
reset: true,
},
});
});
}
closeDialog() {
this.context.router.goBack();
}
render() {
if (!this.state.credential) {
return null;
}
return (
<div className="credentials-picker-git">
<p className="instructions">
{t('creation.git.credentials.register_ssh_key_instructions')}{' '}
<a target="jenkins-docs" href="https://jenkins.io/doc/book/blueocean/creating-pipelines/#creating-a-pipeline-for-a-git-repository">
learn more
</a>.
</p>
<FormElement>
<textarea
className="TextArea-control"
ref={e => {
this.publicKeyElement = e;
}}
readOnly
onChange={e => e}
value={this.state.credential.publickey}
/>
</FormElement>
<a
href="#"
className="copy-key-link"
onClick={e => {
this.copyPublicKeyToClipboard();
e.preventDefault();
}}
>
{t('creation.git.credentials.copy_to_clipboard')}
</a>
{this.props.dialog && (
<FormElement errorMessage={this.state.credentialError} className="action-buttons">
<Button status={this.state.connectStatus} onClick={() => this.testCredentialAndCloseDialog()}>
{t('creation.git.credentials.connect_and_validate')}
</Button>
<Button onClick={() => this.closeDialog()} className="btn-secondary">
{t('creation.git.create_credential.button_close')}
</Button>
</FormElement>
)}
</div>
);
}
}
return <GitCredentialsPickerPassword {...props} />;
};
GitCredentialsPicker.propTypes = {
onStatus: PropTypes.func,

View File

@ -0,0 +1,283 @@
import * as React from 'react';
import {observer} from 'mobx-react';
import {i18nTranslator} from '@jenkins-cd/blueocean-core-js';
import {GitPWCredentialsManager, ManagerState, Credential} from './GitPWCredentialsManager';
import * as debounce from 'lodash.debounce';
import {Button} from '../../creation/github/Button';
import {
FormElement,
PasswordInput,
TextInput,
RadioButtonGroup,
} from '@jenkins-cd/design-language';
const t = i18nTranslator('blueocean-dashboard');
interface Props {
onStatus?: (status: string) => void,
onComplete?: (selectedCredential: Credential | undefined, cause?: string) => void,
repositoryUrl: string,
branch?: string,
existingFailed?: boolean, // if true we shouldn't bother looking it up
requirePush?: boolean,
}
interface State {
usernameValue: string | null,
usernameErrorMsg: string | null,
passwordValue: string | null,
passwordErrorMsg: string | null,
selectedRadio: RadioOption
}
enum RadioOption {
USE_EXISTING = 'useExisting',
CREATE_NEW = 'createNew',
}
const radioOptions = Object.values(RadioOption);
function getErrorMessage(state: ManagerState) {
if (state === ManagerState.INVALID_CREDENTIAL) {
return t('creation.git.create_credential.invalid_username_password');
} else if (state === ManagerState.REVOKED_CREDENTIAL) {
return t('creation.git.create_credential.revoked_credential');
} else if (state === ManagerState.UNEXPECTED_ERROR_CREDENTIAL) {
return t('creation.git.create_credential.unexpected_error');
}
return null;
}
/**
* Component to handle lookup / creation of username+password credentials for git repositories over http(s)
*/
@observer
export class GitCredentialsPickerPassword extends React.Component<Props, State> {
credentialsManager: GitPWCredentialsManager;
constructor(props) {
super(props);
this.credentialsManager = new GitPWCredentialsManager();
this.state = {
usernameValue: null,
usernameErrorMsg: null,
passwordValue: null,
passwordErrorMsg: null,
selectedRadio: RadioOption.USE_EXISTING,
};
const {repositoryUrl, branch, existingFailed = false} = this.props;
this._repositoryChanged(repositoryUrl, branch, existingFailed);
}
componentWillMount() {
if (this.props.onStatus) {
this.props.onStatus('promptLoading');
}
}
componentWillReceiveProps(nextProps: Props) {
const {repositoryUrl, branch, existingFailed} = nextProps;
if (branch !== this.props.branch
|| repositoryUrl !== this.props.repositoryUrl
|| existingFailed !== this.props.existingFailed) {
this._repositoryChanged(repositoryUrl, branch, !!existingFailed);
}
}
componentDidMount() {
const {existingFailed, onStatus} = this.props;
if (existingFailed) {
if (onStatus) {
onStatus('promptReady');
}
}
}
_repositoryChanged = debounce((repositoryUrl: string, branch: string | undefined, existingFailed: boolean) => {
const {onStatus, onComplete} = this.props;
const credentialsManager = this.credentialsManager;
credentialsManager.configure(repositoryUrl, branch);
if (!existingFailed) {
credentialsManager.findExistingCredential()
.then(credential => {
if (credential && onComplete) {
this.setState({selectedRadio: RadioOption.USE_EXISTING});
if (onComplete) {
onComplete(credential, 'autoSelected');
}
} else if (onStatus) {
onStatus('promptReady');
}
});
}
}, 500);
_createCredential() {
const valid = this._performValidation();
if (!valid) {
return;
}
const {usernameValue, passwordValue} = this.state;
const {requirePush = false} = this.props;
this.credentialsManager
.createCredential(usernameValue, passwordValue, requirePush)
.catch(error => {
return undefined; // Error details handled by manager state
})
.then(credential => {
this.setState({
selectedRadio: RadioOption.USE_EXISTING,
usernameValue: null,
passwordValue: null,
});
if (this.props.onComplete) {
// Notify even if credential undefined, so owner knows "unselected"
this.props.onComplete(credential, 'userSelected');
}
})
}
_performValidation() {
let result = true;
if (!this.state.usernameValue) {
this.setState({
usernameErrorMsg: t('creation.git.create_credential.username_error'),
});
result = false;
}
if (!this.state.passwordValue) {
this.setState({
passwordErrorMsg: t('creation.git.create_credential.password_error'),
});
result = false;
}
return result;
}
_usernameChange(value) {
this.setState({
usernameValue: value,
});
this._updateUsernameErrorMsg(false);
}
_updateUsernameErrorMsg = debounce(reset => {
if (reset || (this.state.usernameErrorMsg && this.state.usernameValue)) {
this.setState({
usernameErrorMsg: null,
});
}
}, 200);
_passwordChange(value) {
this.setState({
passwordValue: value,
});
this._updatePasswordErrorMsg(false);
}
_updatePasswordErrorMsg = debounce(reset => {
if (reset || (this.state.passwordErrorMsg && this.state.passwordValue)) {
this.setState({
passwordErrorMsg: null,
});
}
}, 200);
_radioLabel = (option) => {
const {existingCredential} = this.credentialsManager;
const displayName = existingCredential && existingCredential.displayName || '';
switch (option) {
case RadioOption.CREATE_NEW:
return t('creation.git.create_credential.option_create_new');
case RadioOption.USE_EXISTING:
return t('creation.git.create_credential.option_existing', [displayName]);
}
return "";
};
_radioChanged = (selectedRadio: RadioOption) => {
const {onComplete} = this.props;
if (onComplete) {
onComplete(selectedRadio === RadioOption.USE_EXISTING ? this.credentialsManager.existingCredential : undefined);
}
this.setState({selectedRadio});
};
render() {
const managerState: ManagerState = this.credentialsManager.state;
if (managerState === ManagerState.PENDING_LOADING_CREDS) {
return null;
}
const errorMessage = getErrorMessage(managerState);
const isPendingValidation = managerState == ManagerState.PENDING_VALIDATION;
const connectButtonStatus = {result: null as string | null};
const {selectedRadio} = this.state;
const {existingCredential} = this.credentialsManager;
const hasExistingCredential = !!existingCredential;
const useExistingCredential = hasExistingCredential && selectedRadio === RadioOption.USE_EXISTING;
const disableForm = isPendingValidation || useExistingCredential;
if (isPendingValidation) {
connectButtonStatus.result = 'running';
} else if (managerState === ManagerState.SAVE_SUCCESS && useExistingCredential) {
connectButtonStatus.result = 'success';
}
const labelInstructions = t('creation.git.create_credential.pw_instructions');
const labelUsername = t('creation.git.create_credential.username_title');
const labelPassword = t('creation.git.create_credential.password_title');
const labelButton = t('creation.git.create_credential.button_create');
return (
<div className="credentials-picker-git">
<p className="instructions">{labelInstructions}</p>
{hasExistingCredential && (
<RadioButtonGroup options={radioOptions}
labelFunction={this._radioLabel}
defaultOption={RadioOption.USE_EXISTING}
onChange={this._radioChanged} />
)}
<FormElement className="credentials-new" errorMessage={errorMessage} verticalLayout>
<FormElement title={labelUsername} errorMessage={this.state.usernameErrorMsg}>
<TextInput disabled={disableForm} className="text-username" onChange={val => this._usernameChange(val)} />
</FormElement>
<FormElement title={labelPassword} errorMessage={this.state.passwordErrorMsg}>
<PasswordInput disabled={disableForm} className="text-password" onChange={val => this._passwordChange(val)} />
</FormElement>
</FormElement>
<Button disabled={disableForm} className="button-create-credential" status={connectButtonStatus} onClick={() => this._createCredential()}>
{labelButton}
</Button>
</div>
);
}
}

View File

@ -0,0 +1,166 @@
import React, { PropTypes, Component } from 'react';
import { FormElement } from '@jenkins-cd/design-language';
import { Fetch, AppConfig, i18nTranslator } from '@jenkins-cd/blueocean-core-js';
import { Button } from '../../creation/github/Button';
const t = i18nTranslator('blueocean-dashboard');
function copySelectionText() {
let copysuccess; // var to check whether execCommand successfully executed
try {
copysuccess = document.execCommand('copy'); // copy selected text to clipboard
} catch (_) {
copysuccess = false;
}
return copysuccess;
}
function clearSelection() {
if (window.getSelection) {
window.getSelection().removeAllRanges();
} else if (document.selection) {
document.selection.empty();
}
}
/**
* Public key credentials UI for git repos via ssh:// or git://
*/
export class GitCredentialsPickerSSH extends Component {
constructor(props) {
super(props);
this.state = {
credential: null,
credentialError: null,
};
this.restOrgPrefix = AppConfig.getRestRoot() + '/organizations/' + AppConfig.getOrganizationName();
}
componentWillMount() {
const { onStatus, dialog, onComplete } = this.props;
if (onStatus) {
onStatus('promptLoading');
}
Fetch.fetchJSON(this.restOrgPrefix + '/user/publickey/').then(credential => {
this.setState({ credential });
if (onStatus) {
onStatus('promptReady');
}
if (!dialog) {
onComplete(credential);
}
});
}
copyPublicKeyToClipboard() {
const textBox = this.publicKeyElement;
textBox.select();
copySelectionText();
clearSelection();
textBox.blur();
}
testCredentialAndCloseDialog() {
const { onComplete, repositoryUrl, pipeline, requirePush, branch } = this.props;
const body = {
repositoryUrl,
pipeline,
credentialId: this.state.credential.id,
};
if (requirePush) {
body.requirePush = true;
body.branch = branch || 'master';
}
const fetchOptions = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
this.setState({ connectStatus: { result: 'running' } });
return Fetch.fetchJSON(this.restOrgPrefix + '/scm/git/validate', { fetchOptions })
.then(() => {
this.setState({
credentialError: null,
connectStatus: {
result: 'success',
reset: false,
},
});
onComplete(this.state.credential);
})
.catch(error => {
const message = error.responseBody ? error.responseBody.message : 'An unknown error occurred';
this.setState({
credentialError: message && t('creation.git.step1.credentials_publickey_invalid'),
connectStatus: {
reset: true,
},
});
});
}
closeDialog() {
this.context.router.goBack();
}
render() {
if (!this.state.credential) {
return null;
}
return (
<div className="credentials-picker-git">
<p className="instructions">
{t('creation.git.credentials.register_ssh_key_instructions')}{' '}
<a target="jenkins-docs" href="https://jenkins.io/doc/book/blueocean/creating-pipelines/#creating-a-pipeline-for-a-git-repository">
learn more
</a>.
</p>
<FormElement>
<textarea
className="TextArea-control"
ref={e => {
this.publicKeyElement = e;
}}
readOnly
onChange={e => e}
value={this.state.credential.publickey}
/>
</FormElement>
<a
href="#"
className="copy-key-link"
onClick={e => {
this.copyPublicKeyToClipboard();
e.preventDefault();
}}
>
{t('creation.git.credentials.copy_to_clipboard')}
</a>
{this.props.dialog && (
<FormElement errorMessage={this.state.credentialError} className="action-buttons">
<Button status={this.state.connectStatus} onClick={() => this.testCredentialAndCloseDialog()}>
{t('creation.git.credentials.connect_and_validate')}
</Button>
<Button onClick={() => this.closeDialog()} className="btn-secondary">
{t('creation.git.create_credential.button_close')}
</Button>
</FormElement>
)}
</div>
);
}
}
GitCredentialsPickerSSH.propTypes = {
onStatus: PropTypes.func,
onComplete: PropTypes.func,
requirePush: PropTypes.bool,
branch: PropTypes.string,
scmId: PropTypes.string,
dialog: PropTypes.bool,
repositoryUrl: PropTypes.string,
pipeline: PropTypes.object,
};
GitCredentialsPickerSSH.contextTypes = {
router: React.PropTypes.object,
};

View File

@ -0,0 +1,114 @@
import {
Fetch,
UrlConfig,
Utils,
AppConfig,
UrlBuilder,
} from '@jenkins-cd/blueocean-core-js';
import {TypedError} from "../TypedError";
import {
LoadError,
SaveError,
} from '../bitbucket/BbCredentialsApi';
export interface GitPWCredentialsApiPublic {
findExistingCredential(repositoryUrl);
createCredential(repositoryUrl, userName, password, branchName, requirePush);
}
/**
* Api class to interact with GitScm class when working with username+password credentials for http(s) repos
*/
export class GitPWCredentialsApi implements GitPWCredentialsApiPublic {
_fetch: Function;
organization: string;
constructor() {
this._fetch = Fetch.fetchJSON;
this.organization = AppConfig.getOrganizationName();
}
findExistingCredential(repositoryUrl) {
const root = UrlConfig.getJenkinsRootURL();
const credUrl = Utils.cleanSlashes(`${root}/blue/rest/organizations/${this.organization}/scm/git/?repositoryUrl=${repositoryUrl}`);
// Create error in sync code for better stack trace
const possibleError = new TypedError();
return this._fetch(credUrl)
.then(
result => this._findExistingCredentialSuccess(result),
error => {
const {responseBody} = error;
if (responseBody.message.indexOf('Existing credential failed') >= 0) {
throw possibleError.populate(LoadError.TOKEN_REVOKED, responseBody);
}
throw possibleError.populate(LoadError.TOKEN_INVALID, responseBody);
});
}
_findExistingCredentialSuccess(gitScm) {
const credentialId = gitScm && gitScm.credentialId;
if (!credentialId) {
throw new TypedError(LoadError.TOKEN_NOT_FOUND);
}
return this._getCredential(credentialId);
}
_getCredential(credentialId) {
const orgUrl = UrlBuilder.buildRestUrl(this.organization);
const credentialUrl = `${orgUrl}credentials/user/domains/blueocean-git-domain/credentials/${encodeURIComponent(credentialId)}/`;
return this._fetch(credentialUrl);
}
createCredential(repositoryUrl, userName, password, branchName, requirePush) {
const path = UrlConfig.getJenkinsRootURL();
const validateCredUrl = Utils.cleanSlashes(`${path}/blue/rest/organizations/${this.organization}/scm/git/validate`);
const requestBody: any = {
userName,
password,
repositoryUrl,
};
if (branchName) {
requestBody.branch = branchName;
}
if (requirePush) {
requestBody.repositoryUrl = true; // Only set if true!
}
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
};
// Create error in sync code for better stack trace
const possibleError = new TypedError();
return this._fetch(validateCredUrl, {fetchOptions})
.catch(error => {
const {code = -1} = error.responseBody || {};
if (code === 401) {
throw possibleError.populate(SaveError.INVALID_CREDENTIAL, error);
}
throw possibleError.populate(SaveError.UNKNOWN_ERROR, error);
});
}
}

View File

@ -0,0 +1,120 @@
import {action, observable} from "mobx";
import {
LoadError,
SaveError,
} from '../bitbucket/BbCredentialsApi';
import {GitPWCredentialsApi} from './GitPWCredentialsApi';
import PromiseDelayUtils from '../../util/PromiseDelayUtils';
const MIN_DELAY = 500;
const {delayBoth} = PromiseDelayUtils;
export enum ManagerState {
PENDING_LOADING_CREDS = 'PENDING_LOADING_CREDS',
EXISTING_FOUND = 'EXISTING_FOUND',
NEW_REQUIRED = 'NEW_REQUIRED',
SAVE_SUCCESS = 'SAVE_SUCCESS',
INVALID_CREDENTIAL = 'INVALID_CREDENTIAL',
REVOKED_CREDENTIAL = 'REVOKED_CREDENTIAL',
UNEXPECTED_ERROR_CREDENTIAL = 'UNEXPECTED_ERROR_CREDENTIAL',
PENDING_VALIDATION = 'PENDING_VALIDATION',
}
export interface Credential { // FIXME: Canonical types in core-js
id: string,
displayName: string
}
/**
* Acts as a mobx store and api intermediary on behalf of GitCredentialsPickerPassword
*/
export class GitPWCredentialsManager {
repositoryUrl?: string;
branch: string = 'master';
@observable state: ManagerState = ManagerState.PENDING_LOADING_CREDS;
@observable existingCredential?: Credential;
private api: GitPWCredentialsApi;
constructor(api?: GitPWCredentialsApi) {
this.api = api || new GitPWCredentialsApi();
}
configure(repositoryUrl: string, branch?: string) {
this.repositoryUrl = repositoryUrl;
if (typeof branch === 'string') {
this.branch = branch;
} else {
this.branch = 'master';
}
}
@action
findExistingCredential() {
this.state = ManagerState.PENDING_LOADING_CREDS;
return this.api
.findExistingCredential(this.repositoryUrl)
.then(r => {
return r;
})
.then(...delayBoth(MIN_DELAY))
.then(r => {
return r;
})
.then(action((credential: Credential) => {
this.state = ManagerState.EXISTING_FOUND;
this.existingCredential = credential;
return credential;
}))
.catch(action((error: any) => {
if (error.type === LoadError.TOKEN_NOT_FOUND) {
this.state = ManagerState.NEW_REQUIRED;
} else if (error.type === LoadError.TOKEN_INVALID) {
this.state = ManagerState.INVALID_CREDENTIAL;
} else if (error.type === LoadError.TOKEN_REVOKED) {
this.state = ManagerState.REVOKED_CREDENTIAL;
} else {
this.state = ManagerState.UNEXPECTED_ERROR_CREDENTIAL;
}
}));
}
@action
createCredential(userName, password, requirePush: boolean) {
this.state = ManagerState.PENDING_VALIDATION;
this.existingCredential = undefined;
const repositoryUrl = this.repositoryUrl;
const branchName = this.branch;
return this.api
.createCredential(repositoryUrl, userName, password, branchName, requirePush)
.then(...delayBoth(MIN_DELAY))
.then(action(() => {
// Need to look up the existing credential because create service doesn't return the new id
// Need to use _api directly rather than this.findExistingCredential because we don't want
// the state to change until it's done
return this.api.findExistingCredential(repositoryUrl);
}))
.then(action((credential: Credential) => {
this.state = ManagerState.SAVE_SUCCESS;
this.existingCredential = credential;
return credential
}))
.catch(action((error: any) => {
if (error.type === SaveError.INVALID_CREDENTIAL) {
this.state = ManagerState.INVALID_CREDENTIAL;
} else {
this.state = ManagerState.UNEXPECTED_ERROR_CREDENTIAL;
}
throw error;
}));
}
}

View File

@ -1,7 +1,7 @@
import { Fetch, UrlConfig, Utils, AppConfig } from '@jenkins-cd/blueocean-core-js';
import GithubApiUtils from '../../creation/github/api/GithubApiUtils';
import TypedError from '../TypedError';
import { TypedError } from '../TypedError';
export const LoadError = {
TOKEN_NOT_FOUND: 'TOKEN_NOT_FOUND',

View File

@ -11,6 +11,10 @@
width: 100%;
}
.credentials-selection-git {
margin-bottom: 10px;
}
.dropdown-credentials {
margin-right: 10px;

View File

@ -38,7 +38,7 @@ creation.core.error.unexpected.try_again=An unknown error has occurred. You may
# create credential dialog
creation.git.create_credential.button_close=Cancel
creation.git.create_credential.button_create=Create credential
creation.git.create_credential.button_create=Create Credential
creation.git.create_credential.error_msg=An error occurred while creating the credential. You may try again.
creation.git.create_credential.credential_type=Credential Type
creation.git.create_credential.credential_type_ssh_key=SSH Key
@ -50,6 +50,12 @@ creation.git.create_credential.sshkey_title=SSH Private Key
creation.git.create_credential.username_error=Please enter a valid username
creation.git.create_credential.username_title=Username
creation.git.create_credential.title=Add credential
creation.git.create_credential.pw_instructions=Jenkins needs a user credential to authorize itself with git.
creation.git.create_credential.option_existing=Use existing credential: {0}
creation.git.create_credential.option_create_new=Create new credential
creation.git.create_credential.invalid_username_password=Invalid username and/or password
creation.git.create_credential.revoked_credential=Existing username / password invalid. Please enter new credentials.
creation.git.create_credential.unexpected_error=Git credential validation failed with unexpected error. Please try again.
# creation for git
creation.git.step1.create_button=Create Pipeline
@ -60,7 +66,7 @@ creation.git.step1.credentials_error_invalid=Please select a valid credential.
creation.git.step1.credentials_publickey_invalid=Unable to connect. Please make sure the Git server allows this SSH key.
creation.git.step1.credentials_placeholder=System Default
creation.git.step1.instructions=Any repository containing a Jenkinsfile will be built automatically. Not sure what we are talking about?
creation.git.step1.instructions_link=Learn more about Jenkinsfile's.
creation.git.step1.instructions_link=Learn more about Jenkinsfiles.
creation.git.step1.repo_error_invalid=Please enter a valid URL.
creation.git.step1.repo_error_required=Please enter a URL.
creation.git.step1.repo_title=Repository URL
@ -77,6 +83,7 @@ creation.git.step3.title_pipeline_create=Creating Pipeline...
creation.git.credentials.register_ssh_key_instructions=You need to register this public SSH key with your Git server to continue -
creation.git.credentials.copy_to_clipboard=Copy to clipboard
creation.git.credentials.connect_and_validate=Connect
## github enterprise add server dialog
creation.githubent.add_server.button_cancel=Cancel
creation.githubent.add_server.button_create=Add Server

View File

@ -0,0 +1,260 @@
import { assert } from 'chai';
import { mockExtensionsForI18n } from '../../mock-extensions-i18n';
import { GitPWCredentialsManager, ManagerState } from '../../../../main/js/credentials/git/GitPWCredentialsManager';
import { TypedError } from '../../../../main/js/credentials/TypedError';
import { LoadError, SaveError } from '../../../../main/js/credentials/bitbucket/BbCredentialsApi';
mockExtensionsForI18n();
// Using jest expect - https://facebook.github.io/jest/docs/en/expect.html
describe('GitPWCredentialsManager', () => {
const repoUrl = 'https://example.org/git/project.git';
let manager;
let apiMock;
beforeEach(() => {
apiMock = new GitPWCredentialsApiMock();
manager = new GitPWCredentialsManager(apiMock);
manager.configure(repoUrl, 'master');
});
describe('findExistingCredential', () => {
it('behaves when not found', () => {
expect.assertions(5);
expect(manager.state).toBe(ManagerState.PENDING_LOADING_CREDS);
expect(manager.existingCredential).not.toBeDefined();
return manager.findExistingCredential()
.then(credential => {
expect(manager.state).toBe(ManagerState.NEW_REQUIRED);
expect(manager.existingCredential).not.toBeDefined();
expect(credential).not.toBeDefined();
});
});
it('behaves when found', () => {
apiMock.findExistingCredentialShouldSucceed = true;
expect.assertions(7);
expect(manager.state).toBe(ManagerState.PENDING_LOADING_CREDS);
expect(manager.existingCredential).not.toBeDefined();
return manager.findExistingCredential()
.then(credential => {
expect(manager.state).toBe(ManagerState.EXISTING_FOUND);
expect(credential).toBeDefined();
expect(credential.credentialId).toBe(apiMock.credentialId);
expect(manager.existingCredential).toBeDefined();
expect(manager.existingCredential.credentialId).toBe(apiMock.credentialId);
});
});
});
describe('createCredential', () => {
it('updates state when create succeeds', () => {
apiMock.findExistingCredentialShouldSucceed = true;
expect.assertions(10);
expect(manager.state).toBe(ManagerState.PENDING_LOADING_CREDS);
expect(manager.existingCredential).not.toBeDefined();
const promise = manager.createCredential('userNameX', 'passwordX');
expect(manager.state).toBe(ManagerState.PENDING_VALIDATION);
expect(manager.existingCredential).not.toBeDefined();
return promise.then(credential => {
expect(manager.state).toBe(ManagerState.SAVE_SUCCESS);
expect(credential).toBeDefined();
expect(credential.credentialId).toBe(apiMock.credentialId);
expect(manager.existingCredential).toBeDefined();
expect(manager.existingCredential.credentialId).toBe(apiMock.credentialId);
expect(apiMock.capturedCreateParams).toMatchObject({
repositoryUrl: repoUrl,
userName: 'userNameX',
password: 'passwordX',
branchName: 'master',
requirePush: undefined,
});
});
});
it('passes requirePush', () => {
apiMock.findExistingCredentialShouldSucceed = true;
expect.assertions(6);
expect(manager.state).toBe(ManagerState.PENDING_LOADING_CREDS);
const promise = manager.createCredential('userNameX', 'passwordX', true);
expect(manager.state).toBe(ManagerState.PENDING_VALIDATION);
return promise.then(credential => {
expect(manager.state).toBe(ManagerState.SAVE_SUCCESS);
expect(credential).toBeDefined();
expect(credential.credentialId).toBe(apiMock.credentialId);
expect(apiMock.capturedCreateParams).toMatchObject({
repositoryUrl: repoUrl,
userName: 'userNameX',
password: 'passwordX',
branchName: 'master',
requirePush: true,
});
});
});
it('clears existing while creating', () => {
apiMock.createCredentialShouldSucceed = true;
apiMock.findExistingCredentialShouldSucceed = true;
expect.assertions(13);
return manager.findExistingCredential()
.then(credential => {
expect(manager.state).toBe(ManagerState.EXISTING_FOUND);
expect(credential).toBeDefined();
expect(credential.credentialId).toBe(apiMock.credentialId);
expect(manager.existingCredential).toBeDefined();
expect(manager.existingCredential.credentialId).toBe(apiMock.credentialId);
apiMock.credentialId = "newId";
const nextPromise = manager.createCredential('userNameX', 'passwordX', true);
expect(manager.state).toBe(ManagerState.PENDING_VALIDATION);
expect(manager.existingCredential).not.toBeDefined();
return nextPromise;
})
.then(credential => {
expect(manager.state).toBe(ManagerState.SAVE_SUCCESS);
expect(credential).toBeDefined();
expect(credential.credentialId).toBe(apiMock.credentialId);
expect(manager.existingCredential).toBeDefined();
expect(manager.existingCredential.credentialId).toBe(apiMock.credentialId);
expect(apiMock.capturedCreateParams).toMatchObject({
repositoryUrl: repoUrl,
userName: 'userNameX',
password: 'passwordX',
branchName: 'master',
requirePush: true,
});
});
});
it('updates state when create fails with not found', () => {
apiMock.createCredentialShouldSucceed = false;
expect.assertions(7);
expect(manager.state).toBe(ManagerState.PENDING_LOADING_CREDS);
const promise = manager.createCredential('userNameX', 'passwordX', true);
expect(manager.state).toBe(ManagerState.PENDING_VALIDATION);
return promise.catch(error => {
expect(error).toBeDefined();
expect(error).toBeInstanceOf(TypedError);
expect(manager.state).toBe(ManagerState.INVALID_CREDENTIAL);
expect(manager.existingCredential).not.toBeDefined();
expect(apiMock.capturedCreateParams).toMatchObject({
repositoryUrl: repoUrl,
userName: 'userNameX',
password: 'passwordX',
branchName: 'master',
requirePush: true,
});
});
});
it('updates state when create fails with mystery meat', () => {
apiMock.createCredentialShouldSucceed = false;
apiMock.createCredentialShouldHaveWeirdFailure = true;
expect.assertions(7);
expect(manager.state).toBe(ManagerState.PENDING_LOADING_CREDS);
const promise = manager.createCredential('userNameX', 'passwordX', true);
expect(manager.state).toBe(ManagerState.PENDING_VALIDATION);
return promise.catch(error => {
expect(error).toBeDefined();
expect(error).toBeInstanceOf(TypedError);
expect(manager.state).toBe(ManagerState.UNEXPECTED_ERROR_CREDENTIAL);
expect(manager.existingCredential).not.toBeDefined();
expect(apiMock.capturedCreateParams).toMatchObject({
repositoryUrl: repoUrl,
userName: 'userNameX',
password: 'passwordX',
branchName: 'master',
requirePush: true,
});
});
});
});
});
// Helpers
function later(promiseResolver) {
return new Promise((resolve, reject) => {
process.nextTick(() => {
try {
resolve(promiseResolver());
}
catch (err) {
reject(err);
}
});
});
}
class GitPWCredentialsApiMock /* FIXME: implements GitPWCredentialsApiPublic */ {
findExistingCredentialShouldSucceed = false;
createCredentialShouldSucceed = true;
createCredentialShouldHaveWeirdFailure = false;
credentialId = 'someCredentialId';
capturedCreateParams = {};
findExistingCredential(repositoryUrl) {
return later(() => {
if (this.findExistingCredentialShouldSucceed) {
return {
credentialId: this.credentialId,
};
}
throw new TypedError(LoadError.TOKEN_NOT_FOUND);
});
}
createCredential(repositoryUrl, userName, password, branchName, requirePush) {
this.capturedCreateParams = { repositoryUrl, userName, password, branchName, requirePush };
return later(() => {
if (this.createCredentialShouldSucceed) {
return {};
}
if (this.createCredentialShouldHaveWeirdFailure) {
throw new TypedError(SaveError.UNKNOWN_ERROR);
}
throw new TypedError(SaveError.INVALID_CREDENTIAL);
});
}
}

View File

@ -24,6 +24,7 @@
package io.jenkins.blueocean.blueocean_git_pipeline;
import com.cloudbees.plugins.credentials.common.StandardCredentials;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import hudson.model.User;
import io.jenkins.blueocean.commons.ServiceException;
import io.jenkins.blueocean.credential.CredentialsUtils;
@ -61,17 +62,26 @@ abstract class GitReadSaveRequest {
this.contents = contents == null ? null : contents.clone(); // grr findbugs
}
@CheckForNull StandardCredentials getCredential() {
@CheckForNull
StandardCredentials getCredential() {
StandardCredentials credential = null;
User user = User.current();
if (user == null) {
throw new ServiceException.UnauthorizedException("Not authenticated");
}
// Get committer info and credentials
if (GitUtils.isSshUrl(gitSource.getRemote()) || GitUtils.isLocalUnixFileUrl(gitSource.getRemote())) {
// Get committer info and credentials
User user = User.current();
if (user == null) {
throw new ServiceException.UnauthorizedException("Not authenticated");
}
credential = UserSSHKeyManager.getOrCreate(user);
} else {
throw new ServiceException.UnauthorizedException("Editing only supported for repositories using SSH");
String credentialId = GitScm.makeCredentialId(gitSource.getRemote());
if (credentialId != null) {
credential = CredentialsUtils.findCredential(credentialId,
StandardCredentials.class,
new BlueOceanDomainRequirement());
}
}
return credential;
}

View File

@ -23,15 +23,20 @@
*/
package io.jenkins.blueocean.blueocean_git_pipeline;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import hudson.Extension;
import hudson.model.Item;
import hudson.model.User;
import hudson.remoting.Base64;
import io.jenkins.blueocean.commons.ServiceException;
import io.jenkins.blueocean.credential.CredentialsUtils;
import io.jenkins.blueocean.rest.impl.pipeline.ScmContentProvider;
import io.jenkins.blueocean.rest.impl.pipeline.credential.BlueOceanDomainRequirement;
import io.jenkins.blueocean.rest.impl.pipeline.scm.GitContent;
import java.io.IOException;
import javax.annotation.Nonnull;
import jenkins.branch.MultiBranchProject;
import jenkins.plugins.git.GitSCMSource;
import jenkins.scm.api.SCMSource;
@ -46,7 +51,8 @@ import org.kohsuke.stapler.StaplerRequest;
*/
@Extension
public class GitReadSaveService extends ScmContentProvider {
@Nonnull private static ReadSaveType TYPE = ReadSaveType.DEFAULT;
@Nonnull
private static ReadSaveType TYPE = ReadSaveType.DEFAULT;
/**
* Type of git interaction to use
@ -90,20 +96,20 @@ public class GitReadSaveService extends ScmContentProvider {
}
static GitReadSaveRequest makeSaveRequest(
Item item,String branch, String commitMessage,
String sourceBranch, String filePath, byte[] contents) {
Item item, String branch, String commitMessage,
String sourceBranch, String filePath, byte[] contents) {
String defaultBranch = "master";
GitSCMSource gitSource = null;
if (item instanceof MultiBranchProject<?,?>) {
MultiBranchProject<?,?> mbp = (MultiBranchProject<?,?>)item;
if (item instanceof MultiBranchProject<?, ?>) {
MultiBranchProject<?, ?> mbp = (MultiBranchProject<?, ?>) item;
for (SCMSource s : mbp.getSCMSources()) {
if (s instanceof GitSCMSource) {
gitSource = (GitSCMSource)s;
gitSource = (GitSCMSource) s;
}
}
}
switch(TYPE) {
switch (TYPE) {
case CLONE:
return new GitCloneReadSaveRequest(
gitSource,
@ -137,11 +143,11 @@ public class GitReadSaveService extends ScmContentProvider {
private GitReadSaveRequest makeSaveRequest(Item item, StaplerRequest req) {
String branch = req.getParameter("branch");
return makeSaveRequest(item,
branch,
req.getParameter("commitMessage"),
ObjectUtils.defaultIfNull(req.getParameter("sourceBranch"), branch),
req.getParameter("path"),
Base64.decode(req.getParameter("contents"))
branch,
req.getParameter("commitMessage"),
ObjectUtils.defaultIfNull(req.getParameter("sourceBranch"), branch),
req.getParameter("path"),
Base64.decode(req.getParameter("contents"))
);
}
@ -149,11 +155,11 @@ public class GitReadSaveService extends ScmContentProvider {
JSONObject content = json.getJSONObject("content");
String branch = content.getString("branch");
return makeSaveRequest(item,
branch,
content.getString("message"),
content.has("sourceBranch") ? content.getString("sourceBranch") : branch,
content.getString("path"),
Base64.decode(content.getString("base64Data"))
branch,
content.getString("message"),
content.has("sourceBranch") ? content.getString("sourceBranch") : branch,
content.getString("path"),
Base64.decode(content.getString("base64Data"))
);
}
@ -166,13 +172,15 @@ public class GitReadSaveService extends ScmContentProvider {
}
GitReadSaveRequest r = makeSaveRequest(item, req);
try {
String encoded = Base64.encode(r.read());
return new GitFile(
new GitContent(r.filePath, user.getId(), r.gitSource.getRemote(), r.filePath, 0, "sha", encoded, "", r.branch, r.sourceBranch, true, "")
);
final byte[] reqData = r.read();
String encoded = Base64.encode(reqData);
final GitContent content = new GitContent(r.filePath, user.getId(), r.gitSource.getRemote(), r.filePath, 0, "sha", encoded, "", r.branch, r.sourceBranch, true, "");
final GitFile gitFile = new GitFile(content);
return gitFile;
} catch (ServiceException.UnauthorizedException e) {
//if (r.gitSource.getRemote().matches("([^@:]+@.*|ssh://.*)"))
throw new ServiceException.PreconditionRequired("Invalid credential", e);
} catch (IOException e) {
throw new ServiceException.UnexpectedErrorException("Unable to get file content", e);
@ -204,4 +212,29 @@ public class GitReadSaveService extends ScmContentProvider {
public boolean support(@Nonnull Item item) {
return getApiUrl(item) != null;
}
@Nonnull
protected StandardUsernamePasswordCredentials getCredentialForUser(@Nonnull Item item, @Nonnull String repositoryUrl) {
User user = User.current();
if (user == null) { //ensure this session has authenticated user
throw new ServiceException.UnauthorizedException("No logged in user found");
}
String credentialId = GitScm.makeCredentialId(repositoryUrl);
StandardUsernamePasswordCredentials credential = null;
if (credentialId != null) {
credential = CredentialsUtils.findCredential(credentialId,
StandardUsernamePasswordCredentials.class,
new BlueOceanDomainRequirement());
}
if (credential == null) {
throw new ServiceException.UnauthorizedException("No credential found for " + credentialId + " for user " + user.getDisplayName());
}
return credential;
}
}

View File

@ -2,15 +2,24 @@ package io.jenkins.blueocean.blueocean_git_pipeline;
import com.cloudbees.plugins.credentials.CredentialsMatchers;
import com.cloudbees.plugins.credentials.CredentialsProvider;
import com.cloudbees.plugins.credentials.CredentialsScope;
import com.cloudbees.plugins.credentials.common.StandardCredentials;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import com.cloudbees.plugins.credentials.domains.DomainRequirement;
import com.cloudbees.plugins.credentials.domains.DomainSpecification;
import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import hudson.Extension;
import hudson.model.User;
import hudson.util.HttpResponses;
import io.jenkins.blueocean.commons.ErrorMessage;
import io.jenkins.blueocean.commons.ServiceException;
import io.jenkins.blueocean.credential.CredentialsUtils;
import io.jenkins.blueocean.rest.Reachable;
import io.jenkins.blueocean.rest.hal.Link;
import io.jenkins.blueocean.rest.impl.pipeline.credential.BlueOceanDomainRequirement;
import io.jenkins.blueocean.rest.impl.pipeline.credential.BlueOceanDomainSpecification;
import io.jenkins.blueocean.rest.impl.pipeline.scm.AbstractScm;
import io.jenkins.blueocean.rest.impl.pipeline.scm.Scm;
import io.jenkins.blueocean.rest.impl.pipeline.scm.ScmFactory;
@ -23,23 +32,105 @@ import jenkins.plugins.git.GitSCMFileSystem;
import jenkins.scm.api.SCMSourceOwner;
import net.sf.json.JSONException;
import net.sf.json.JSONObject;
import org.apache.commons.codec.digest.DigestUtils;
import org.eclipse.jgit.lib.Repository;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.json.JsonBody;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Locale;
public class GitScm extends AbstractScm {
public static final String ID = "git";
static final String CREDENTIAL_DOMAIN_NAME = "blueocean-git-domain";
static final String CREDENTIAL_DESCRIPTION_PW = "Git username/password";
protected final Reachable parent;
public GitScm(Reachable parent) {
this.parent = parent;
}
/**
* Create the credentialId for a specific repositoryUrl (which will be normalized)
* @param repositoryUrl
* @return credentialId string
*/
public static String makeCredentialId(String repositoryUrl) {
final String normalizedUrl = normalizeServerUrl(repositoryUrl);
try {
final java.net.URI uri = new URI(normalizedUrl);
// Require a host
String host = uri.getHost();
if (host == null || host.length() == 0) {
return null;
}
// Only http(s) urls have a default credential ID keyed to the repo right now
String scheme = uri.getScheme();
if (scheme != null && scheme.startsWith("http")) {
return String.format("%s:%s", ID, DigestUtils.sha256Hex(normalizedUrl));
}
} catch (URISyntaxException e) {
// Fall through
}
// Bad URL, or not a http(s) url
return null;
}
/**
* Normalize the URL to protocol, port, and host to use as part of the credential id
* @param repositoryUrl
* @return a normalized url without path, query, or fragment components
*/
private static String normalizeServerUrl(String repositoryUrl) {
if (repositoryUrl == null) {
return "";
}
try {
java.net.URI uri = new URI(repositoryUrl).normalize();
String scheme = uri.getScheme();
String host = uri.getHost() == null ? null : uri.getHost().toLowerCase(Locale.ENGLISH);
int port = uri.getPort();
if ("http".equals(scheme) && port == 80) {
port = -1;
} else if ("https".equals(scheme) && port == 443) {
port = -1;
} else if ("ssh".equals(scheme) && port == 22) {
port = -1;
} else if ("git".equals(scheme) && port == 9418) {
port = -1;
}
return new URI(
scheme,
uri.getUserInfo(),
host,
port,
null,
null,
null
).toASCIIString().replaceAll("/$", "");
} catch (URISyntaxException e) {
// Bad URL
return "";
}
}
@Override
public Link getLink() {
return parent.getLink().rel("git");
@ -57,11 +148,46 @@ public class GitScm extends AbstractScm {
return "";
}
protected StaplerRequest getStaplerRequest() {
StaplerRequest request = Stapler.getCurrentRequest();
Preconditions.checkNotNull(request, "Must be called in HTTP request context");
return request;
}
@Override
public String getCredentialId() {
// Only return the generated id if we actually have a credential that matches it
StandardCredentials credential = getCredentialForCurrentRequest();
if (credential != null) {
return credential.getId();
}
return null;
}
protected StandardCredentials getCredentialForCurrentRequest() {
final StaplerRequest request = getStaplerRequest();
String credentialId = null;
if (request.hasParameter("credentialId")) {
credentialId = request.getParameter("credentialId");
} else {
if (!request.hasParameter("repositoryUrl")) {
// No linked credential unless a specific repo
return null;
}
String repositoryUrl = request.getParameter("repositoryUrl");
credentialId = makeCredentialId(repositoryUrl);
}
if (credentialId == null) {
return null;
}
return CredentialsUtils.findCredential(credentialId, StandardCredentials.class, new BlueOceanDomainRequirement());
}
@Override
public Container<ScmOrganization> getOrganizations() {
return null;
@ -74,9 +200,14 @@ public class GitScm extends AbstractScm {
@Override
public HttpResponse validateAndCreate(@JsonBody JSONObject request) {
boolean requirePush = request.has("requirePush");
// --[ Grab repo url and SCMSource ]----------------------------------------------------------
final String repositoryUrl;
final AbstractGitSCMSource scmSource;
if (request.has("repositoryUrl")) {
scmSource = null;
repositoryUrl = request.getString("repositoryUrl");
@ -90,60 +221,130 @@ public class GitScm extends AbstractScm {
} else {
return HttpResponses.errorJSON("No repository found for: " + fullName);
}
} catch(JSONException e) {
} catch (JSONException e) {
return HttpResponses.errorJSON("No repositoryUrl or pipeline.fullName specified in request.");
} catch(RuntimeException e) {
} catch (RuntimeException e) {
return HttpResponses.errorWithoutStack(ServiceException.INTERNAL_SERVER_ERROR, e.getMessage());
}
}
try {
String credentialId = request.getString("credentialId");
User user = User.current();
if (user == null) {
throw new ServiceException.UnauthorizedException("Not authenticated");
}
final StandardCredentials creds = CredentialsMatchers.firstOrNull(
CredentialsProvider.lookupCredentials(
StandardCredentials.class,
Jenkins.getInstance(),
Jenkins.getAuthentication(),
(List<DomainRequirement>) null),
CredentialsMatchers.allOf(CredentialsMatchers.withId(credentialId))
);
// --[ Grab user ]-------------------------------------------------------------------------------------
if (creds == null) {
throw new ServiceException.NotFoundException("No credentials found for: " + credentialId);
}
User user = User.current();
if (user == null) {
throw new ServiceException.UnauthorizedException("Not authenticated");
}
// --[ Get credential id from request or create from repo url ]----------------------------------------
String credentialId = null;
if (request.has("credentialId")) {
credentialId = request.getString("credentialId");
}
if (credentialId == null) {
credentialId = makeCredentialId(repositoryUrl);
}
if (credentialId == null) {
// Still null? Must be a bad repoURL
throw new ServiceException.BadRequestException("Invalid URL \"" + repositoryUrl + "\"");
}
// --[ Load or create credentials ]--------------------------------------------------------------------
// Create new is only for username + password
if (request.has("userName") || request.has("password")) {
createPWCredentials(credentialId, user, request, repositoryUrl);
}
final StandardCredentials creds = CredentialsMatchers.firstOrNull(
CredentialsProvider.lookupCredentials(
StandardCredentials.class,
Jenkins.getInstance(),
Jenkins.getAuthentication(),
(List<DomainRequirement>) null),
CredentialsMatchers.allOf(CredentialsMatchers.withId(credentialId))
);
if (creds == null) {
throw new ServiceException.NotFoundException("No credentials found for: " + credentialId);
}
try {
if (requirePush) {
String branch = request.getString("branch");
new GitBareRepoReadSaveRequest(scmSource, branch, null, branch, null, null)
.invokeOnScm(new GitSCMFileSystem.FSFunction<Void>() {
@Override
public Void invoke(Repository repository) throws IOException, InterruptedException {
GitUtils.validatePushAccess(repository, repositoryUrl, creds);
return null;
}
});
@Override
public Void invoke(Repository repository) throws IOException, InterruptedException {
GitUtils.validatePushAccess(repository, repositoryUrl, creds);
return null;
}
});
} else {
List<ErrorMessage.Error> errors = GitUtils.validateCredentials(repositoryUrl, creds);
if (!errors.isEmpty()) {
throw new ServiceException.UnauthorizedException(errors.get(0).getMessage());
}
}
} catch(Exception e) {
return HttpResponses.errorWithoutStack(ServiceException.PRECONDITION_REQUIRED, e.getMessage());
} catch (Exception e) {
String message = e.getMessage();
if (message != null && message.contains("TransportException")) {
throw new ServiceException.PreconditionRequired("Repository URL unreachable: " + repositoryUrl);
}
return HttpResponses.errorWithoutStack(ServiceException.PRECONDITION_REQUIRED, message);
}
return HttpResponses.okJSON();
}
private void createPWCredentials(String credentialId, User user, @JsonBody JSONObject request, String repositoryUrl) {
StandardUsernamePasswordCredentials existingCredential =
CredentialsUtils.findCredential(credentialId,
StandardUsernamePasswordCredentials.class,
new BlueOceanDomainRequirement());
String requestUsername = request.getString("userName");
String requestPassword = request.getString("password");
// Un-normalized repositoryUrl so the description matches user input.
String description = String.format("%s for %s", CREDENTIAL_DESCRIPTION_PW, repositoryUrl);
final StandardUsernamePasswordCredentials newCredential =
new UsernamePasswordCredentialsImpl(CredentialsScope.USER,
credentialId,
description,
requestUsername,
requestPassword);
try {
if (existingCredential == null) {
CredentialsUtils.createCredentialsInUserStore(newCredential,
user,
CREDENTIAL_DOMAIN_NAME,
ImmutableList.<DomainSpecification>of(new BlueOceanDomainSpecification()));
} else {
CredentialsUtils.updateCredentialsInUserStore(existingCredential,
newCredential,
user,
CREDENTIAL_DOMAIN_NAME,
ImmutableList.<DomainSpecification>of(new BlueOceanDomainSpecification()));
}
} catch (IOException e) {
throw new ServiceException.UnexpectedErrorException("Could not persist credential", e);
}
}
@Extension
public static class GitScmFactory extends ScmFactory {
@Override
public Scm getScm(@Nonnull String id, @Nonnull Reachable parent) {
if(id.equals(ID)){
if (id.equals(ID)) {
return new GitScm(parent);
}
return null;
@ -155,4 +356,6 @@ public class GitScm extends AbstractScm {
return new GitScm(parent);
}
}
}

View File

@ -39,6 +39,11 @@ import static org.junit.Assert.*;
*/
@RunWith(Parameterized.class)
public class GitScmTest extends PipelineBaseTest {
public static final String HTTPS_GITHUB_NO_JENKINSFILE = "https://github.com/vivek/test-no-jenkins-file.git";
public static final String HTTPS_GITHUB_PUBLIC = "https://github.com/cloudbeers/multibranch-demo.git";
public static final String HTTPS_GITHUB_PUBLIC_HASH = "996e1f714b08e971ec79e3bea686287e66441f043177999a13dbc546d8fe402a";
// ^ is DigestUtils.sha256Hex(normalizedUrl)
@Rule
public GitSampleRepoRule sampleRepo = new GitSampleRepoRule();
@ -99,8 +104,8 @@ public class GitScmTest extends PipelineBaseTest {
.post("/organizations/" + getOrgName() + "/pipelines/")
.data(ImmutableMap.of("name", "demo",
"$class", "io.jenkins.blueocean.blueocean_git_pipeline.GitPipelineCreateRequest",
"scmConfig", ImmutableMap.of("uri", "https://github.com/vivek/test-no-jenkins-file.git",
"credentialId", credentialId)
"scmConfig", ImmutableMap.of("uri", HTTPS_GITHUB_NO_JENKINSFILE,
"credentialId", credentialId)
)).build(Map.class);
assertEquals("demo", r.get("name"));
@ -354,6 +359,135 @@ public class GitScmTest extends PipelineBaseTest {
assertNull(getOrgRoot().getItem("demo"));
}
@Test
public void shouldNotProvideIdForMissingCredentials() throws Exception {
User user = login();
String scmPath = "/organizations/" + getOrgName() + "/scm/git/";
String repoPath = scmPath + "?repositoryUrl=" + HTTPS_GITHUB_PUBLIC;
Map resp = new RequestBuilder(baseUrl)
.status(200)
.jwtToken(getJwtToken(j.jenkins,user.getId(), user.getId()))
.get(repoPath)
.build(Map.class);
assertEquals(null, resp.get("credentialId"));
}
@Test
public void shouldBePoliteAboutBadUrl() throws Exception {
User user = login();
String scmPath = "/organizations/" + getOrgName() + "/scm/git/";
// Let's say the user has only started typing a url
String repoPath = scmPath + "?repositoryUrl=htt";
Map resp = new RequestBuilder(baseUrl)
.status(200)
.jwtToken(getJwtToken(j.jenkins,user.getId(), user.getId()))
.get(repoPath)
.build(Map.class);
assertEquals(null, resp.get("credentialId"));
}
@Test
public void shouldCreateCredentialsWithDefaultId() throws Exception {
User user = login();
String scmPath = "/organizations/" + getOrgName() + "/scm/git/";
// First create a credential
String scmValidatePath = scmPath + "validate";
// We're relying on github letting you do a git-ls for repos with bad creds so long as they're public
Map params = ImmutableMap.of(
"userName", "someguy",
"password", "password",
"repositoryUrl", HTTPS_GITHUB_PUBLIC
);
Map resp = new RequestBuilder(baseUrl)
.status(200)
.jwtToken(getJwtToken(j.jenkins,user.getId(), user.getId()))
.data(params)
.put(scmValidatePath)
.build(Map.class);
assertEquals("ok", resp.get("status"));
// Now get the default credentialId
String repoPath = scmPath + "?repositoryUrl=" + HTTPS_GITHUB_PUBLIC;
Map resp2 = new RequestBuilder(baseUrl)
.status(200)
.jwtToken(getJwtToken(j.jenkins,user.getId(), user.getId()))
.get(repoPath)
.build(Map.class);
assertEquals("git:" + HTTPS_GITHUB_PUBLIC_HASH, resp2.get("credentialId"));
}
/**
* Check that we get an error when using an invalid URL
* @throws Exception
*/
@Test
public void shouldNotCreateCredentialsForBadUrl1() throws Exception {
User user = login();
String scmPath = "/organizations/" + getOrgName() + "/scm/git/";
// First create a credential
String scmValidatePath = scmPath + "validate";
// We're relying on github letting you do a git-ls for repos with bad creds so long as they're public
Map params = ImmutableMap.of(
"userName", "someguy",
"password", "password",
"repositoryUrl", "htt"
);
Map resp = new RequestBuilder(baseUrl)
.status(400)
.jwtToken(getJwtToken(j.jenkins,user.getId(), user.getId()))
.data(params)
.put(scmValidatePath)
.build(Map.class);
assertTrue(resp.get("message").toString().toLowerCase().contains("invalid url"));
}
/**
* Check that we get an error when using a valid but non-answering URL
* @throws Exception
*/
@Test
public void shouldNotCreateCredentialsForBadUrl2() throws Exception {
User user = login();
String scmPath = "/organizations/" + getOrgName() + "/scm/git/";
// First create a credential
String scmValidatePath = scmPath + "validate";
// We're relying on github letting you do a git-ls for repos with bad creds so long as they're public
Map params = ImmutableMap.of(
"userName", "someguy",
"password", "password",
"repositoryUrl", "http://example.org/has/no/repos.git"
);
Map resp = new RequestBuilder(baseUrl)
.status(428)
.jwtToken(getJwtToken(j.jenkins,user.getId(), user.getId()))
.data(params)
.put(scmValidatePath)
.build(Map.class);
assertTrue(resp.get("message").toString().toLowerCase().contains("url unreachable"));
}
private String createMbp(User user) throws UnirestException {
Map<String,Object> resp = new RequestBuilder(baseUrl)
.status(201)
@ -395,8 +529,8 @@ public class GitScmTest extends PipelineBaseTest {
sampleRepo.write("file", "subsequent content1");
sampleRepo.git("commit", "--all", "--message=tweaked1");
}
private String getOrgName() {
return OrganizationFactory.getInstance().list().iterator().next().getName();
@ -408,9 +542,9 @@ public class GitScmTest extends PipelineBaseTest {
@TestExtension
public static class TestOrganizationFactoryImpl extends OrganizationFactoryImpl {
public static String orgRoot;
private OrganizationImpl instance;
public TestOrganizationFactoryImpl() {
@ -426,7 +560,7 @@ public class GitScmTest extends PipelineBaseTest {
} catch (IOException e) {
throw new RuntimeException("Test setup failed!", e);
}
}
else {
instance = new OrganizationImpl("jenkins", Jenkins.getInstance());

View File

@ -0,0 +1,43 @@
package io.jenkins.blueocean.blueocean_git_pipeline;
import org.junit.Assert;
import org.junit.Test;
/**
* Unit tests fot GitScm that don't run via network
*/
public class GitScmTest2 {
@Test
public void shouldMakeCredentialIdForHttp() {
String result = GitScm.makeCredentialId("http://example.org/git/foo.git");
Assert.assertEquals("git:http://example.org/git/foo.git", result);
}
@Test
public void shouldMakeCredentialIdForHttps() {
String result = GitScm.makeCredentialId("https://example.org/git/foo.git");
Assert.assertEquals("git:https://example.org/git/foo.git", result);
}
@Test
public void shouldNotMakeCredentialIdForSsh() {
String result = GitScm.makeCredentialId("ssh://example.org/git/foo.git");
Assert.assertEquals(null, result);
}
@Test
public void shouldNotMakeCredentialIdForGit() {
String result = GitScm.makeCredentialId("git://example.org/git/foo.git");
Assert.assertEquals(null, result);
}
@Test
public void shouldNotMakeCredentialIdForBadUrls() {
Assert.assertEquals(null, GitScm.makeCredentialId("this is not a url worth mentioning"));
Assert.assertEquals(null, GitScm.makeCredentialId("git://"));
Assert.assertEquals(null, GitScm.makeCredentialId("http://"));
Assert.assertEquals(null, GitScm.makeCredentialId(""));
Assert.assertEquals(null, GitScm.makeCredentialId(null));
}
}

View File

@ -14,6 +14,7 @@ import org.junit.Rule;
import org.junit.Test;
import java.io.File;
import java.io.Serializable;
import java.util.Map;
public class GitUtilsTest extends PipelineBaseTest {
@ -110,12 +111,12 @@ public class GitUtilsTest extends PipelineBaseTest {
String id = (String)resp.get("id");
Assert.assertTrue(id != null);
resp = put("/organizations/jenkins/scm/git/validate/",
ImmutableMap.of(
"repositoryUrl", "git@github.com:vivek/capability-annotation.git",
"credentialId", id,
"requirePush", true,
"branch", "master")
, 428);
final Map<String, Object> body = ImmutableMap.of(
"repositoryUrl", "git@github.com:vivek/capability-annotation.git",
"credentialId", id,
"requirePush", true,
"branch", "master");
put("/organizations/jenkins/scm/git/validate/", body, 428);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,7 @@
"nock": "8.0.0",
"react-addons-test-utils": "15.4.2",
"ts-jest": "19.0.2",
"tsify": "3.0.4",
"tsify": "4.0.0",
"typescript": "2.7.2"
},
"dependencies": {

View File

@ -460,10 +460,10 @@ class PipelineLoader extends React.Component {
const pipeline = pipelineService.getPipeline(this.href);
const { scmSource } = pipeline;
if (!scmSource || !scmSource.id || (scmSource.id === 'git' && !isSshRepositoryUrl(scmSource.apiUrl))) {
this.showLoadingError('', 'Saving Pipelines is unsupported using http/https repositories. Please use SSH instead.', 'No save access');
return;
}
// if (!scmSource || !scmSource.id || (scmSource.id === 'git' && !isSshRepositoryUrl(scmSource.apiUrl))) {
// this.showLoadingError('', 'Saving Pipelines is unsupported using http/https repositories. Please use SSH instead.', 'No save access');
// return;
// }
// if showing this dialog with a credential, the write test failed
// except for git, where we need to prompt with the user's public key so they can continue
@ -483,6 +483,8 @@ class PipelineLoader extends React.Component {
// hide the dialog until it reports as ready (i.e. credential fetch is done)
const dialogClassName = `dialog-token ${loading ? 'loading' : ''}`;
//FIXME: should show a message about existing credentials failing
this.setState({
dialog: (
<Dialog title={title} className={dialogClassName} buttons={[]} onDismiss={() => this.cancel()}>
@ -496,6 +498,7 @@ class PipelineLoader extends React.Component {
requirePush
branch={branch}
dialog
existingFailed
/>
</Dialog>
),
@ -613,6 +616,7 @@ class PipelineLoader extends React.Component {
if (branch || repo) {
title += ' / ' + (branch || repo);
}
return (
<div className="pipeline-page">
<Extensions.Renderer extensionPoint="pipeline.editor.css" />

View File

@ -1,5 +1,5 @@
import { Fetch, UrlBuilder } from '@jenkins-cd/blueocean-core-js';
import TypedError from './TypedError';
import { TypedError } from './TypedError';
const Base64 = {
encode: data => btoa(data),

View File

@ -1,7 +1,7 @@
/**
* Utlity class for "typing" of server errors
*/
class TypedError {
export class TypedError {
constructor(type, serverError) {
this.type = type;
@ -12,5 +12,3 @@ class TypedError {
}
}
}
export default TypedError;

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,7 @@
"ncp": "2.0.0",
"reactify": "1.1.1",
"ts-jest": "19.0.2",
"tsify": "3.0.4",
"tsify": "4.0.0",
"typescript": "2.7.2",
"zombie": "4.2.1"
},

File diff suppressed because it is too large Load Diff

View File

@ -93,7 +93,7 @@
"react-addons-test-utils": "15.4.2",
"run-sequence": "1.2.2",
"ts-jest": "19.0.2",
"tsify": "3.0.4",
"tsify": "4.0.0",
"typescript": "2.7.2",
"watchify": "3.7.0"
},

View File

@ -36,11 +36,12 @@ exports.install = function(builder) {
// a bit painful.
//
process.env.NODE_ENV = builder.args.argvValue('--NODE_ENV', 'production');
builder.onPreBundle(function (bundler) { // See https://github.com/jenkinsci/js-builder#onprebundle-listeners
builder.onPreBundle(function(bundler) {
// See https://github.com/jenkinsci/js-builder#onprebundle-listeners
bundler.transform(require('envify'));
});
builder.onPreBundle(function (bundler) {
builder.onPreBundle(function(bundler) {
var basedir = bundler._mdeps.basedir; // TODO is there a better way to get this info?
var packageJson = require(basedir + '/package.json');
var bundle = this;
@ -67,12 +68,11 @@ exports.install = function(builder) {
.import('@jenkins-cd/blueocean-core-js@any')
.import('@jenkins-cd/logging')
.import('react@any', {
aliases: ['react/lib/React'] // in case a module requires react through the back door
aliases: ['react/lib/React'], // in case a module requires react through the back door
})
.import('react-dom@any')
.import('react-addons-css-transition-group@any')
.import('redux@any')
;
.import('redux@any');
}
if (!packageJson.name.startsWith('@jenkins-cd')) {

View File

@ -9,7 +9,7 @@ export function execute(done, config) {
const Extensions = require('@jenkins-cd/js-extensions');
const appRoot = document.getElementsByTagName('head')[0].getAttribute('data-appurl');
const CoreJs = modules.requireModule('jenkins-cd-blueocean-core-js:jenkins-cd-blueocean-core-js@any');
Extensions.init({
extensionData: window.$blueocean.jsExtensions,
classMetadataProvider: (type, cb) => {