Repository: DependencyTrack/dependency-track (java)

Issue:
Add a Jira Publisher to publish Dependency Track alerts to Jira tickets
Add a Jira Publisher to existing [Publishers](https://docs.dependencytrack.org/integrations/notifications/#configuring-publishers) to publish Dependency Track alerts to Jira tickets

This ER is related to #1583 but narrower in scope in hopes that it will be integrated to the codebase quicker.

Pull request:
Add a Jira Publisher to publish Dependency Track alerts to Jira tickets
Closes #2118 

Patch:
diff --git a/docs/_docs/integrations/notifications.md b/docs/_docs/integrations/notifications.md
index 42f385563..a2ff165f5 100644
--- a/docs/_docs/integrations/notifications.md
+++ b/docs/_docs/integrations/notifications.md
@@ -48,6 +48,7 @@ The following notification publishers are included by default :
 | Webhook    | Publishes notifications to a configurable endpoint  |
 | Email      | Sends notifications to an email address             |
 | Console    | Displays notifications on the system console        |
+| Jira       | Publishes notifications to Jira                     |
 
 ### Templating
 
@@ -369,6 +370,7 @@ To override all default templates, you must have the following [Pebble Templates
 <base directory>/templates/notification/publisher/console.peb
 <base directory>/templates/notification/publisher/webhook.peb
 <base directory>/templates/notification/publisher/cswebex.peb
+<base directory>/templates/notification/publisher/jira.peb
 ```
 
 **A restart is needed for the modification to be taken into account.**
@@ -401,9 +403,11 @@ and publisher to use.
 ![create notification](/images/screenshots/notifications-create.png)
 
 Once the alert is created it can be configured. Start with selecting from the list of available notification groups
-to notify on. Then specify the destination. The destination may be a comma speparated list of email addresses (when the Email publisher is used),
-or a URL. In the case of Slack, Mattermost and Microsoft Teams, this will be the incoming webhook URL generated from each respective
-platform. In the case of the Outbound Webhook publisher, this will be a URL to which to publish the notification.
+to notify on. Then specify the destination:
+- For the Email publisher: it's a comma separated list of email addresses
+- For Slack, Mattermost and Microsoft Teams: it's the incoming webhook URL generated from each respective
+- For Jira: it's the URL for the Jira instance. The Jira alert also asks for the Jira ticket type ('Task', 'Story', etc., refer to the Jira documentation for more details) to be created, along with the project key where the issue will be created
+- For the Outbound Webhook publisher: it's a URL to which to publish the notification
 
 ![configure notification](/images/screenshots/notifications-configure.png)
 
diff --git a/docs/index.md b/docs/index.md
index ec5ce42c2..c42d1e269 100755
--- a/docs/index.md
+++ b/docs/index.md
@@ -64,7 +64,7 @@ CI/CD environments.
   * Trust boundary traversal
   * Authentication requirements
 * Includes a comprehensive auditing workflow for triaging results
-* Configurable notifications supporting Slack, Microsoft Teams, Mattermost, Webhooks, and Email
+* Configurable notifications supporting Slack, Microsoft Teams, Mattermost, Webhooks, Email and Jira
 * Supports standardized SPDX license ID’s and tracks license use by component
 * Easy to read metrics for components, projects, and portfolio
 * Native support for Kenna Security, Fortify SSC, ThreadFix, and DefectDojo
diff --git a/docs/odt-odc-comparison.html b/docs/odt-odc-comparison.html
index a30e3a763..8bc29ad88 100644
--- a/docs/odt-odc-comparison.html
+++ b/docs/odt-odc-comparison.html
@@ -136,6 +136,7 @@ sitemap: true
                     <li>Cisco WebEx</li>
                     <li>Webhooks</li>
                     <li>Email</li>
+                    <li>Jira</li>
                 </ul>
             </td>
             <td>None</td>
diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java
index 5c5953843..a91afc4ee 100644
--- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java
+++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java
@@ -35,6 +35,8 @@ public enum ConfigPropertyConstants {
     EMAIL_SMTP_TRUSTCERT("email", "smtp.trustcert", "false", PropertyType.BOOLEAN, "Flag to enable/disable the trust of the certificate presented by the SMTP server"),
     INTERNAL_COMPONENTS_GROUPS_REGEX("internal-components", "groups.regex", null, PropertyType.STRING, "Regex that matches groups of internal components"),
     INTERNAL_COMPONENTS_NAMES_REGEX("internal-components", "names.regex", null, PropertyType.STRING, "Regex that matches names of internal components"),
+    JIRA_USERNAME("jira", "jira.username", null, PropertyType.STRING, "The optional username to authenticate with when creating an Jira issue"),
+    JIRA_PASSWORD("jira", "jira.password", null, PropertyType.ENCRYPTEDSTRING, "The optional password for the username used for authentication"),
     SCANNER_INTERNAL_ENABLED("scanner", "internal.enabled", "true", PropertyType.BOOLEAN, "Flag to enable/disable the internal analyzer"),
     SCANNER_INTERNAL_FUZZY_ENABLED("scanner", "internal.fuzzy.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable non-exact fuzzy matching using the internal analyzer"),
     SCANNER_INTERNAL_FUZZY_EXCLUDE_PURL("scanner", "internal.fuzzy.exclude.purl", "true", PropertyType.BOOLEAN, "Flag to enable/disable fuzzy matching on components that have a Package URL (PURL) defined"),
diff --git a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java
index 8c507ab62..c56ffd5c9 100644
--- a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java
+++ b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java
@@ -21,22 +21,25 @@ package org.dependencytrack.notification.publisher;
 import alpine.common.logging.Logger;
 import alpine.notification.Notification;
 import com.mitchellbosecke.pebble.template.PebbleTemplate;
-import kong.unirest.HttpResponse;
+import kong.unirest.Header;
+import kong.unirest.Headers;
 import kong.unirest.UnirestInstance;
 import org.dependencytrack.common.UnirestFactory;
+import org.dependencytrack.exception.PublisherException;
 
 import javax.json.JsonObject;
+import java.util.stream.Collectors;
 
 public abstract class AbstractWebhookPublisher implements Publisher {
 
     public void publish(final String publisherName, final PebbleTemplate template, final Notification notification, final JsonObject config) {
         final Logger logger = Logger.getLogger(this.getClass());
-        logger.debug("Preparing to publish notification");
+        logger.debug("Preparing to publish " + publisherName + " notification");
         if (config == null) {
             logger.warn("No configuration found. Skipping notification.");
             return;
         }
-        final String destination = config.getString("destination");
+        final String destination = getDestinationUrl(config);
         final String content = prepareTemplate(notification, template);
         if (destination == null || content == null) {
             logger.warn("A destination or template was not found. Skipping notification");
@@ -44,17 +47,43 @@ public abstract class AbstractWebhookPublisher implements Publisher {
         }
         final String mimeType = getTemplateMimeType(config);
         final UnirestInstance ui = UnirestFactory.getUnirestInstance();
-        final HttpResponse response = ui.post(destination)
-                .header("content-type", mimeType)
-                .header("accept", mimeType)
+
+        final var headers = new Headers();
+        headers.add("content-type", mimeType);
+        headers.add("accept", mimeType);
+        final BasicAuthCredentials credentials;
+        try {
+            credentials = getBasicAuthCredentials();
+        } catch (PublisherException e) {
+            logger.warn("An error occurred during the retrieval of credentials needed for notification publication. Skipping notification");
+            return;
+        }
+        if (credentials != null) {
+            headers.setBasicAuth(credentials.user(), credentials.password());
+        }
+
+        final var response = ui.post(destination)
+                .headers(headers.all().stream().collect(Collectors.toMap(Header::getName, Header::getValue)))
                 .body(content)
-                .asEmpty();
+                .asString();
 
-        if (response.getStatus() < 200 || response.getStatus() > 299) {
+        if (!response.isSuccess()) {
             logger.error("An error was encountered publishing notification to " + publisherName);
             logger.error("HTTP Status : " + response.getStatus() + " " + response.getStatusText());
             logger.error("Destination: " + destination);
+            logger.error("Response: " + response.getBody());
             logger.debug(content);
         }
     }
+
+    protected String getDestinationUrl(final JsonObject config) {
+        return config.getString(CONFIG_DESTINATION);
+    }
+
+    protected BasicAuthCredentials getBasicAuthCredentials() {
+        return null;
+    }
+
+    protected record BasicAuthCredentials(String user, String password) {
+    }
 }
diff --git a/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java b/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java
index ce5d4a5b8..3f4b8c92d 100644
--- a/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java
+++ b/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java
@@ -28,7 +28,8 @@ public enum DefaultNotificationPublishers {
     EMAIL("Email", "Sends notifications to an email address", SendMailPublisher.class, "/templates/notification/publisher/email.peb", MediaType.TEXT_PLAIN, true),
     CONSOLE("Console", "Displays notifications on the system console", ConsolePublisher.class, "/templates/notification/publisher/console.peb", MediaType.TEXT_PLAIN, true),
     WEBHOOK("Outbound Webhook", "Publishes notifications to a configurable endpoint", WebhookPublisher.class, "/templates/notification/publisher/webhook.peb", MediaType.APPLICATION_JSON, true),
-    CS_WEBEX("Cisco Webex", "Publishes notifications to a Cisco Webex Teams channel", CsWebexPublisher.class, "/templates/notification/publisher/cswebex.peb", MediaType.APPLICATION_JSON, true);
+    CS_WEBEX("Cisco Webex", "Publishes notifications to a Cisco Webex Teams channel", CsWebexPublisher.class, "/templates/notification/publisher/cswebex.peb", MediaType.APPLICATION_JSON, true),
+    JIRA("Jira", "Creates a Jira issue in a configurable Jira instance and queue", JiraPublisher.class, "/templates/notification/publisher/jira.peb", MediaType.APPLICATION_JSON, true);
 
     private String name;
     private String description;
diff --git a/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java
new file mode 100644
index 000000000..73d33b43e
--- /dev/null
+++ b/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java
@@ -0,0 +1,81 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) Steve Springett. All Rights Reserved.
+ */
+package org.dependencytrack.notification.publisher;
+
+import alpine.common.logging.Logger;
+import alpine.notification.Notification;
+import alpine.security.crypto.DataEncryption;
+import com.mitchellbosecke.pebble.PebbleEngine;
+import org.dependencytrack.exception.PublisherException;
+import org.dependencytrack.persistence.QueryManager;
+
+import javax.json.JsonObject;
+import java.util.Map;
+
+import static org.dependencytrack.model.ConfigPropertyConstants.JIRA_PASSWORD;
+import static org.dependencytrack.model.ConfigPropertyConstants.JIRA_USERNAME;
+
+/**
+ * Class that handles publishing a ticket to a Jira instance when a new notification is received.
+ *
+ * @author Mvld3r
+ * @since 4.7
+ */
+public class JiraPublisher extends AbstractWebhookPublisher implements Publisher {
+    private static final Logger LOGGER = Logger.getLogger(JiraPublisher.class);
+    private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build();
+    private String jiraProjectKey;
+    private String jiraTicketType;
+
+    @Override
+    public void inform(final Notification notification, final JsonObject config) {
+        jiraTicketType = config.getString("jiraTicketType");
+        jiraProjectKey = config.getString("jiraProjectKey");
+        publish(DefaultNotificationPublishers.JIRA.getPublisherName(), getTemplate(config), notification, config);
+    }
+
+    @Override
+    public PebbleEngine getTemplateEngine() {
+        return ENGINE;
+    }
+
+    @Override
+    public String getDestinationUrl(final JsonObject config) {
+        final String baseUrl = super.getDestinationUrl(config);
+        return (baseUrl.endsWith("/") ? baseUrl : baseUrl + '/') + "rest/api/2/issue";
+    }
+
+    @Override
+    public BasicAuthCredentials getBasicAuthCredentials() {
+        try (final QueryManager qm = new QueryManager()) {
+            final String jiraUsername = qm.getConfigProperty(JIRA_USERNAME.getGroupName(), JIRA_USERNAME.getPropertyName()).getPropertyValue();
+            final String encryptedPassword = qm.getConfigProperty(JIRA_PASSWORD.getGroupName(), JIRA_PASSWORD.getPropertyName()).getPropertyValue();
+            final String jiraPassword = (encryptedPassword == null) ? null : DataEncryption.decryptAsString(encryptedPassword);
+            return new BasicAuthCredentials(jiraUsername, jiraPassword);
+        } catch (final Exception e) {
+            throw new PublisherException("An error occurred during the retrieval of Jira credentials", e);
+        }
+    }
+
+    @Override
+    public void enrichTemplateContext(final Map<String, Object> context) {
+        context.put("jiraProjectKey", jiraProjectKey);
+        context.put("jiraTicketType", jiraTicketType);
+    }
+}
diff --git a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java
index c186ed79f..cbec94a34 100644
--- a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java
+++ b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java
@@ -68,6 +68,9 @@ public interface Publisher {
         }
     }
 
+    default void enrichTemplateContext(final Map<String, Object> context) {
+    }
+
     default String prepareTemplate(final Notification notification, final PebbleTemplate template) {
 
         try (QueryManager qm = new QueryManager()) {
@@ -91,34 +94,29 @@ public interface Publisher {
             }
 
             if (NotificationScope.PORTFOLIO.name().equals(notification.getScope())) {
-                if (notification.getSubject() instanceof NewVulnerabilityIdentified) {
-                    final NewVulnerabilityIdentified subject = (NewVulnerabilityIdentified) notification.getSubject();
+                if (notification.getSubject() instanceof final NewVulnerabilityIdentified subject) {
                     context.put("subject", subject);
                     context.put("subjectJson", NotificationUtil.toJson(subject));
-                } else if (notification.getSubject() instanceof NewVulnerableDependency) {
-                    final NewVulnerableDependency subject = (NewVulnerableDependency) notification.getSubject();
+                } else if (notification.getSubject() instanceof final NewVulnerableDependency subject) {
                     context.put("subject", subject);
                     context.put("subjectJson", NotificationUtil.toJson(subject));
-                } else if (notification.getSubject() instanceof AnalysisDecisionChange) {
-                    final AnalysisDecisionChange subject = (AnalysisDecisionChange) notification.getSubject();
+                } else if (notification.getSubject() instanceof final AnalysisDecisionChange subject) {
                     context.put("subject", subject);
                     context.put("subjectJson", NotificationUtil.toJson(subject));
-                } else if (notification.getSubject() instanceof BomConsumedOrProcessed) {
-                    final BomConsumedOrProcessed subject = (BomConsumedOrProcessed) notification.getSubject();
+                } else if (notification.getSubject() instanceof final BomConsumedOrProcessed subject) {
                     context.put("subject", subject);
                     context.put("subjectJson", NotificationUtil.toJson(subject));
-                } else if (notification.getSubject() instanceof VexConsumedOrProcessed) {
-                    final VexConsumedOrProcessed subject = (VexConsumedOrProcessed) notification.getSubject();
+                } else if (notification.getSubject() instanceof final VexConsumedOrProcessed subject) {
                     context.put("subject", subject);
                     context.put("subjectJson", NotificationUtil.toJson(subject));
-                } else if (notification.getSubject() instanceof PolicyViolationIdentified) {
-                    final PolicyViolationIdentified subject = (PolicyViolationIdentified) notification.getSubject();
+                } else if (notification.getSubject() instanceof final PolicyViolationIdentified subject) {
                     context.put("subject", subject);
                     context.put("subjectJson", NotificationUtil.toJson(subject));
                 }
             }
+            enrichTemplateContext(context);
 
-            try (Writer writer = new StringWriter()) {
+            try (final Writer writer = new StringWriter()) {
                 template.evaluate(writer, context);
                 return writer.toString();
             } catch (IOException e) {
diff --git a/src/main/resources/templates/notification/publisher/jira.peb b/src/main/resources/templates/notification/publisher/jira.peb
new file mode 100644
index 000000000..48d0c733a
--- /dev/null
+++ b/src/main/resources/templates/notification/publisher/jira.peb
@@ -0,0 +1,18 @@
+{
+    "fields": {
+       "project": {
+          "key": "{{ jiraProjectKey }}"
+       },
+       "issuetype": {
+          "name": "{{ jiraTicketType }}"
+       },
+       "summary": "[Dependency-Track] [{{ notification.group | escape(strategy="json") }}] {% if notification.group == "NEW_VULNERABILITY" %}[{{ subject.vulnerability.severity }}] New {{ subject.vulnerability.severity | lower }} vulnerability identified: {{ subject.vulnerability.vulnId }}{% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}Vulnerable dependency introduced on project {{ subject.dependency.project.name | escape(strategy="json") }}{% else %}{{ notification.title | escape(strategy="json") }}{% endif %}",
+       {% if notification.group == "NEW_VULNERABILITY" %}
+       "description": "A new vulnerability has been identified on your project(s).\n\\\\\n\\\\\n*Vulnerability description*\n{code:none|bgColor=white|borderStyle=none}{{ subject.vulnerability.description | escape(strategy="json") }}{code}\n\n*VulnID*\n{{ subject.vulnerability.vulnId }}\n\n*Severity*\n{{ subject.vulnerability.severity | lower | capitalize }}\n\n*Component*\n[{{ subject.component | escape(strategy="json") }}|{{ baseUrl }}/components/{{ subject.component.uuid }}]\n\n*Affected project(s)*\n{% for project in subject.affectedProjects %}- [{{ project.name | escape(strategy="json") }}|{{ baseUrl }}/projects/{{ project.uuid }}]\n{% endfor %}"
+       {% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}
+       "description": "A component which contains one or more vulnerabilities has been added to your project.\n\\\\\n\\\\\n*Project*\n[{{ subject.component.project | escape(strategy="json") }}|{{ baseUrl }}/projects/{{ subject.component.project.uuid }}]\n\n*Component*\n[{{ subject.component | escape(strategy="json") }}|{{ baseUrl }}/components/{{ subject.component.uuid }}]\n\n*Vulnerabilities*\n{% for vulnerability in subject.vulnerabilities %}- {{ vulnerability.vulnId }} ({{ vulnerability.severity | lower | capitalize }})\n{% endfor %}"
+       {% else %}
+       "description": "{{ notification.content | escape(strategy="json") }}\n\\\\\n\\\\\n*Level*\n{{ notification.level }}\n\n"
+       {% endif %}
+   }
+}
\ No newline at end of file


Test patch:
diff --git a/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java b/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java
index e7d3eb4ff..f42c20c8e 100644
--- a/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java
+++ b/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java
@@ -33,6 +33,7 @@ public class DefaultNotificationPublishersTest {
         Assert.assertEquals("EMAIL", DefaultNotificationPublishers.EMAIL.name());
         Assert.assertEquals("CONSOLE", DefaultNotificationPublishers.CONSOLE.name());
         Assert.assertEquals("WEBHOOK", DefaultNotificationPublishers.WEBHOOK.name());
+        Assert.assertEquals("JIRA", DefaultNotificationPublishers.JIRA.name());
     }
 
     @Test
@@ -94,4 +95,14 @@ public class DefaultNotificationPublishersTest {
         Assert.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.WEBHOOK.getTemplateMimeType());
         Assert.assertTrue(DefaultNotificationPublishers.WEBHOOK.isDefaultPublisher());
     }
+
+    @Test
+    public void testJira() {
+        Assert.assertEquals("Jira", DefaultNotificationPublishers.JIRA.getPublisherName());
+        Assert.assertEquals("Creates a Jira issue in a configurable Jira instance and queue", DefaultNotificationPublishers.JIRA.getPublisherDescription());
+        Assert.assertEquals(JiraPublisher.class, DefaultNotificationPublishers.JIRA.getPublisherClass());
+        Assert.assertEquals("/templates/notification/publisher/jira.peb", DefaultNotificationPublishers.JIRA.getPublisherTemplateFile());
+        Assert.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.JIRA.getTemplateMimeType());
+        Assert.assertTrue(DefaultNotificationPublishers.JIRA.isDefaultPublisher());
+    }
 }
diff --git a/src/test/java/org/dependencytrack/notification/publisher/JiraPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/JiraPublisherTest.java
new file mode 100644
index 000000000..231f892bc
--- /dev/null
+++ b/src/test/java/org/dependencytrack/notification/publisher/JiraPublisherTest.java
@@ -0,0 +1,87 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) Steve Springett. All Rights Reserved.
+ */
+package org.dependencytrack.notification.publisher;
+
+import alpine.notification.Notification;
+import alpine.notification.NotificationLevel;
+import org.apache.http.HttpHeaders;
+import org.dependencytrack.PersistenceCapableTest;
+import org.dependencytrack.notification.NotificationGroup;
+import org.dependencytrack.notification.NotificationScope;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import java.io.IOException;
+
+import static org.mockserver.integration.ClientAndServer.startClientAndServer;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+public class JiraPublisherTest extends PersistenceCapableTest implements NotificationTestConfigProvider  {
+
+    private static ClientAndServer mockServer;
+
+    @BeforeClass
+    public static void beforeClass() {
+        mockServer = startClientAndServer(1080);
+    }
+
+
+    @AfterClass
+    public static void afterClass() {
+        mockServer.stop();
+    }
+
+    @Test
+    public void testPublish() throws IOException {
+        new MockServerClient("localhost", 1080)
+                .when(
+                        request()
+                                .withMethod("POST")
+                )
+                .respond(
+                        response()
+                                .withStatusCode(200)
+                                .withHeader(HttpHeaders.CONTENT_TYPE, "application/json")
+                );
+        JsonObject config = getConfig(DefaultNotificationPublishers.JIRA, "http://localhost:1080");
+        Notification notification = new Notification();
+        notification.setScope(NotificationScope.PORTFOLIO.name());
+        notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name());
+        notification.setLevel(NotificationLevel.INFORMATIONAL);
+        notification.setTitle("Test Notification");
+        notification.setContent("This is only a test");
+        JiraPublisher publisher = new JiraPublisher();
+        publisher.inform(notification, config);
+    }
+
+    @Override
+    public JsonObjectBuilder getExtraConfig() {
+        return Json.createObjectBuilder()
+                .add("jiraTicketType", "Task")
+                .add("jiraProjectKey", "MyProject");
+    }
+}
+
diff --git a/src/test/java/org/dependencytrack/notification/publisher/NotificationTestConfigProvider.java b/src/test/java/org/dependencytrack/notification/publisher/NotificationTestConfigProvider.java
index 9c04eb622..23ac544ec 100644
--- a/src/test/java/org/dependencytrack/notification/publisher/NotificationTestConfigProvider.java
+++ b/src/test/java/org/dependencytrack/notification/publisher/NotificationTestConfigProvider.java
@@ -23,6 +23,7 @@ import org.dependencytrack.util.NotificationUtil;
 
 import javax.json.Json;
 import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
 import java.io.File;
 import java.io.IOException;
 import java.net.URLDecoder;
@@ -38,7 +39,12 @@ public interface NotificationTestConfigProvider {
                 .add(Publisher.CONFIG_TEMPLATE_MIME_TYPE_KEY, publisher.getTemplateMimeType())
                 .add(Publisher.CONFIG_TEMPLATE_KEY, templateContent)
                 .add(Publisher.CONFIG_DESTINATION, destination)
+                .addAll(getExtraConfig())
                 .build();
     }
 
+    default JsonObjectBuilder getExtraConfig() {
+        return Json.createObjectBuilder();
+    }
+
 }
diff --git a/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java
index 7f74a17de..d6f8fb2c3 100644
--- a/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java
+++ b/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java
@@ -76,7 +76,7 @@ public class NotificationPublisherResourceTest extends ResourceTest {
         Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER));
         JsonArray json = parseJsonArray(response);
         Assert.assertNotNull(json);
-        Assert.assertEquals(7, json.size());
+        Assert.assertEquals(8, json.size());
         Assert.assertEquals("Console", json.getJsonObject(1).getString("name"));
         Assert.assertEquals("Displays notifications on the system console", json.getJsonObject(1).getString("description"));
         Assert.assertEquals("text/plain", json.getJsonObject(1).getString("templateMimeType"));