diff --git a/core/src/main/resources/lib/form/secretTextarea.jelly b/core/src/main/resources/lib/form/secretTextarea.jelly new file mode 100644 index 000000000000..5d476d0c0f86 --- /dev/null +++ b/core/src/main/resources/lib/form/secretTextarea.jelly @@ -0,0 +1,109 @@ + + + + + for editing multi-line secrets. + +Example usage: + + + + + + + + + + + + + ]]> + + Used for databinding. Must be compatible with hudson.util.Secret for round-trip ciphertext. + + + Name to use for form input name. Calculated from @field by default. + + + Value of the secret. Calculated from instance[@field] by default. + This value must be of type hudson.util.Secret. + The value will be encrypted when sent to the client if the client has Item.CONFIGURE permissions. + + + Placeholder text for input field when displayed. + + + + + + + + + + + +
+
+
+ + + ${%NoStoredValue} + + + + + + + + + + + ${%Concealed} + + + +
+
+ +
+
+
+ +
diff --git a/core/src/main/resources/lib/form/secretTextarea.properties b/core/src/main/resources/lib/form/secretTextarea.properties new file mode 100644 index 000000000000..ebd0491095e4 --- /dev/null +++ b/core/src/main/resources/lib/form/secretTextarea.properties @@ -0,0 +1,29 @@ +# +# The MIT License +# +# Copyright (c) 2019 CloudBees, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +Add=Add +Replace=Replace +EnterSecret=Enter New Secret Below +Concealed=Concealed for Confidentiality +NoStoredValue=No Stored Value diff --git a/core/src/main/resources/lib/form/secretTextarea/secret.css b/core/src/main/resources/lib/form/secretTextarea/secret.css new file mode 100644 index 000000000000..4960c64aed6f --- /dev/null +++ b/core/src/main/resources/lib/form/secretTextarea/secret.css @@ -0,0 +1,77 @@ +/* + * The MIT License + * + * Copyright (c) 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +.secret-header { + border: 1px solid #ccc; + border-radius: 3px; + background: #f9f9f9; + display: flex; + justify-content: space-around; +} + +.secret-header:not(:only-child) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.secret-header > div { + flex-grow: 1; + display: inline-flex; + align-items: center; + padding: 1.5em 1.75em; +} + +.secret-legend > svg { + margin-right: 1em; +} + +.secret-update { + justify-content: flex-end; +} + +.secret-input { + border: solid 1px #ccc; + border-top: none; + border-radius: 0 0 3px 3px; +} + +.secret-input textarea { + width: 100%; + font-family: monospace; + border: none; + padding: 1em; +} + +.secret input[type='button'] { + background: #4b99d0; + color: #fff; + border-radius: 4px; + border: none; + padding: 1em 2em; +} + +.secret input[type='button']:hover { + background: #5092be; + cursor: pointer; +} diff --git a/core/src/main/resources/lib/form/secretTextarea/secret.js b/core/src/main/resources/lib/form/secretTextarea/secret.js new file mode 100644 index 000000000000..c2b312144075 --- /dev/null +++ b/core/src/main/resources/lib/form/secretTextarea/secret.js @@ -0,0 +1,77 @@ +/* + * The MIT License + * + * Copyright (c) 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +Behaviour.specify('.secret', 'secret-button', 0, function (e) { + var secretUpdateBtn = e.querySelector('.secret-update-btn'); + if (secretUpdateBtn === null) return; + + var id = 'secret-' + (iota++); + var name = e.getAttribute('data-name'); + var placeholder = e.getAttribute('data-placeholder'); + var prompt = e.getAttribute('data-prompt'); + + var appendSecretInput = function () { + var textarea = document.createElement('textarea'); + textarea.setAttribute('id', id); + textarea.setAttribute('name', name); + if (placeholder !== null && placeholder !== '') { + textarea.setAttribute('placeholder', placeholder); + } + var secretInput = document.createElement('div'); + secretInput.setAttribute('class', 'secret-input'); + secretInput.appendChild(textarea); + e.appendChild(secretInput); + } + + var clearSecretValue = function () { + var secretValue = e.querySelector('input[type="hidden"]'); + if (secretValue !== null) { + secretValue.parentNode.removeChild(secretValue); + } + } + + var replaceUpdateButton = function () { + var secretLabel = document.createElement('label'); + secretLabel.setAttribute('for', id); + secretLabel.appendChild(document.createTextNode(prompt)); + secretUpdateBtn.parentNode.replaceChild(secretLabel, secretUpdateBtn); + } + + var removeSecretLegendLabel = function () { + var secretLegend = e.querySelector('.secret-legend'); + var secretLegendText = secretLegend.querySelector('span'); + if (secretLegendText !== null) { + secretLegend.removeChild(secretLegendText); + } + } + + secretUpdateBtn.onclick = function () { + appendSecretInput(); + clearSecretValue(); + replaceUpdateButton(); + removeSecretLegendLabel(); + // fix UI bug when DOM changes + Event.fire(window, 'jenkins:bottom-sticker-adjust'); + }; +}); diff --git a/test/src/test/java/lib/form/SecretTextareaTest.java b/test/src/test/java/lib/form/SecretTextareaTest.java new file mode 100644 index 000000000000..93cc37bda57d --- /dev/null +++ b/test/src/test/java/lib/form/SecretTextareaTest.java @@ -0,0 +1,175 @@ +/* + * The MIT License + * + * Copyright (c) 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package lib.form; + +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlHiddenInput; +import com.gargoylesoftware.htmlunit.html.HtmlTextInput; +import hudson.model.AbstractProject; +import hudson.model.Project; +import hudson.tasks.BuildStepDescriptor; +import hudson.tasks.Builder; +import hudson.util.Secret; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.xml.sax.SAXException; + +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; + +public class SecretTextareaTest { + + private Project project; + private WebClient wc; + + @Rule public JenkinsRule j = new JenkinsRule(); + + @Before + public void setUp() throws IOException { + project = j.createFreeStyleProject(); + project.getBuildersList().add(TestBuilder.newDefault()); + wc = j.createWebClient(); + } + + @Test + public void addEmptySecret() throws Exception { + j.configRoundtrip(project); + assertTestBuilderDataBoundEqual(TestBuilder.newDefault()); + } + + @Test + public void addSecret() throws Exception { + setProjectSecret("testValue"); + assertTestBuilderDataBoundEqual(TestBuilder.fromString("testValue")); + } + + @Test + public void addSecretAndUpdateDescription() throws Exception { + setProjectSecret("Original Value"); + assertTestBuilderDataBoundEqual(TestBuilder.fromString("Original Value")); + HtmlForm configForm = goToConfigForm(); + HtmlTextInput description = configForm.getInputByName("_.description"); + description.setText("New description"); + j.submit(configForm); + assertTestBuilderDataBoundEqual(TestBuilder.fromStringWithDescription("Original Value", "New description")); + } + + @Test + public void addSecretAndUpdateSecretWithEmptyValue() throws Exception { + setProjectSecret("First"); + assertTestBuilderDataBoundEqual(TestBuilder.fromString("First")); + HtmlForm configForm = goToConfigForm(); + String hiddenValue = getHiddenSecretValue(configForm); + assertNotNull(hiddenValue); + assertNotEquals("First", hiddenValue); + assertEquals("First", Secret.fromString(hiddenValue).getPlainText()); + clickSecretUpdateButton(configForm); + j.submit(configForm); + assertTestBuilderDataBoundEqual(TestBuilder.fromString("")); + } + + private void assertTestBuilderDataBoundEqual(TestBuilder other) throws Exception { + j.assertEqualDataBoundBeans(other, project.getBuildersList().get(TestBuilder.class)); + } + + private void setProjectSecret(String secret) throws Exception { + HtmlForm configForm = goToConfigForm(); + clickSecretUpdateButton(configForm); + configForm.getTextAreaByName("_.secret").setText(secret); + j.submit(configForm); + } + + private HtmlForm goToConfigForm() throws IOException, SAXException { + return wc.getPage(project, "configure").getFormByName("config"); + } + + private static void clickSecretUpdateButton(HtmlForm configForm) throws IOException { + configForm.getOneHtmlElementByAttribute("input", "class", "secret-update-btn").click(); + } + + private static String getHiddenSecretValue(HtmlForm configForm) { + HtmlHiddenInput hiddenSecret = configForm.getInputByName("_.secret"); + return hiddenSecret == null ? null : hiddenSecret.getValueAttribute(); + } + + public static class TestBuilder extends Builder { + private final Secret secret; + private String description = ""; + + private static TestBuilder newDefault() { + return new TestBuilder(null); + } + + private static TestBuilder fromString(String secret) { + return new TestBuilder(Secret.fromString(secret)); + } + + private static TestBuilder fromStringWithDescription(String secret, String description) { + TestBuilder b = fromString(secret); + b.setDescription(description); + return b; + } + + @DataBoundConstructor + public TestBuilder(Secret secret) { + this.secret = secret; + } + + public Secret getSecret() { + return secret; + } + + public String getDescription() { + return description; + } + + @DataBoundSetter + public void setDescription(String description) { + this.description = description; + } + + @TestExtension + public static class DescriptorImpl extends BuildStepDescriptor { + @Override + public String getDisplayName() { + return "Test Secret"; + } + + @Override + public boolean isApplicable(Class jobType) { + return true; + } + } + } +} \ No newline at end of file diff --git a/test/src/test/resources/lib/form/SecretTextareaTest/TestBuilder/config.jelly b/test/src/test/resources/lib/form/SecretTextareaTest/TestBuilder/config.jelly new file mode 100644 index 000000000000..dfd396de7dda --- /dev/null +++ b/test/src/test/resources/lib/form/SecretTextareaTest/TestBuilder/config.jelly @@ -0,0 +1,33 @@ + + + + + + + + + + +