From 1907b80938189bfeb9b625c38ef3efb175bcdcf7 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Thu, 16 Jul 2026 21:39:31 +0200 Subject: [PATCH 1/2] Add a Test Connection button to TestingBot credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Test Connection" validate button to the credentials form that calls the TestingBot REST API (getUserInfo) with the entered key/secret and reports whether they authenticate — e.g. "Connection successful — signed in as ( plan)" or a clear error. This is the credential verification Sauce/BrowserStack offer and we lacked. The doVerifyCredentials descriptor method is @POST (CSRF-safe), gated to admins or users who can configure the surrounding item, and resolves the secret via Secret.fromString so it works whether the form submits plaintext or the encrypted value. The REST client is closed via try-with-resources. --- .../testingbot/TestingBotCredentials.java | 33 +++++++++++++++++++ .../TestingBotCredentials/credentials.jelly | 3 ++ 2 files changed, 36 insertions(+) diff --git a/src/main/java/testingbot/TestingBotCredentials.java b/src/main/java/testingbot/TestingBotCredentials.java index ea306de..59091ac 100644 --- a/src/main/java/testingbot/TestingBotCredentials.java +++ b/src/main/java/testingbot/TestingBotCredentials.java @@ -23,6 +23,8 @@ import com.cloudbees.plugins.credentials.domains.Domain; import com.cloudbees.plugins.credentials.domains.DomainRequirement; import com.cloudbees.plugins.credentials.impl.BaseStandardCredentials; +import com.testingbot.models.TestingbotUser; +import com.testingbot.testingbotrest.TestingbotREST; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; @@ -305,6 +307,37 @@ public final FormValidation doCheckId(@QueryParameter String value, @AncestorInP } return FormValidation.ok(); } + + /** + * Verifies a key/secret against the TestingBot API and reports whether they authenticate. + * Gated so only users who can configure the surrounding item (or admins) can probe. + */ + @POST + public FormValidation doVerifyCredentials(@QueryParameter String key, @QueryParameter String secret, + @AncestorInPath ModelObject context) { + boolean allowed = Jenkins.get().hasPermission(Jenkins.ADMINISTER) + || (context instanceof Item && ((Item) context).hasPermission(Item.CONFIGURE)); + if (!allowed) { + return FormValidation.error("You do not have permission to verify credentials."); + } + if (Util.fixEmptyAndTrim(key) == null || Util.fixEmptyAndTrim(secret) == null) { + return FormValidation.error("Enter both a key and a secret first."); + } + // The password field may submit the encrypted Secret when editing an existing credential; + // fromString transparently yields the plaintext either way. + String plainSecret = Secret.fromString(secret).getPlainText(); + try (TestingbotREST rest = new TestingbotREST(key.trim(), plainSecret)) { + TestingbotUser user = rest.getUserInfo(); + if (user == null || Util.fixEmptyAndTrim(user.getEmail()) == null) { + return FormValidation.error("Could not verify these credentials with TestingBot."); + } + String plan = Util.fixEmptyAndTrim(user.getPlan()); + return FormValidation.ok("Connection successful — signed in as %s%s", + user.getEmail(), plan != null ? " (" + plan + " plan)" : ""); + } catch (Exception e) { + return FormValidation.error("TestingBot authentication failed: " + e.getMessage()); + } + } } public static class NameProvider extends CredentialsNameProvider { diff --git a/src/main/resources/testingbot/TestingBotCredentials/credentials.jelly b/src/main/resources/testingbot/TestingBotCredentials/credentials.jelly index a7221db..2c0b607 100644 --- a/src/main/resources/testingbot/TestingBotCredentials/credentials.jelly +++ b/src/main/resources/testingbot/TestingBotCredentials/credentials.jelly @@ -8,6 +8,9 @@ + + From e978a64ebca7edadf1cf126a25250b20a10cd4d1 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Thu, 16 Jul 2026 21:51:44 +0200 Subject: [PATCH 2/2] Catch the REST client's typed exceptions, not broad Exception (review on #36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doVerifyCredentials caught Exception, masking unexpected bugs. The testingbotrest client signals failures via two specific (unchecked) types — TestingbotUnauthorized Exception (bad key/secret) and TestingbotApiException (which wraps network/parse IOException) — so catch exactly those and let any other runtime exception propagate. Note: getUserInfo()/close() declare no checked exceptions, so narrowing to checked types (as literally worded) isn't possible; these typed unchecked exceptions are the client's declared failure signals. --- src/main/java/testingbot/TestingBotCredentials.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/testingbot/TestingBotCredentials.java b/src/main/java/testingbot/TestingBotCredentials.java index 59091ac..c4755f8 100644 --- a/src/main/java/testingbot/TestingBotCredentials.java +++ b/src/main/java/testingbot/TestingBotCredentials.java @@ -24,7 +24,9 @@ import com.cloudbees.plugins.credentials.domains.DomainRequirement; import com.cloudbees.plugins.credentials.impl.BaseStandardCredentials; import com.testingbot.models.TestingbotUser; +import com.testingbot.testingbotrest.TestingbotApiException; import com.testingbot.testingbotrest.TestingbotREST; +import com.testingbot.testingbotrest.TestingbotUnauthorizedException; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; @@ -334,7 +336,9 @@ public FormValidation doVerifyCredentials(@QueryParameter String key, @QueryPara String plan = Util.fixEmptyAndTrim(user.getPlan()); return FormValidation.ok("Connection successful — signed in as %s%s", user.getEmail(), plan != null ? " (" + plan + " plan)" : ""); - } catch (Exception e) { + } catch (TestingbotUnauthorizedException | TestingbotApiException e) { + // The REST client's typed failures: bad key/secret, and network/parse errors (it wraps + // IOException into TestingbotApiException). Unexpected runtime exceptions propagate. return FormValidation.error("TestingBot authentication failed: " + e.getMessage()); } }