diff --git a/api/src/main/openapi/components/schemas/secrets/create-secret-request.yaml b/api/src/main/openapi/components/schemas/secrets/create-secret-request.yaml index 94bec7ee41..9ea3274a0c 100644 --- a/api/src/main/openapi/components/schemas/secrets/create-secret-request.yaml +++ b/api/src/main/openapi/components/schemas/secrets/create-secret-request.yaml @@ -24,7 +24,7 @@ properties: value: type: string minLength: 1 - maxLength: 1024 + maxLength: 4096 required: - name - value \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/secrets/update-secret-request.yaml b/api/src/main/openapi/components/schemas/secrets/update-secret-request.yaml index bc47bf2dde..c9d2a90ef9 100644 --- a/api/src/main/openapi/components/schemas/secrets/update-secret-request.yaml +++ b/api/src/main/openapi/components/schemas/secrets/update-secret-request.yaml @@ -26,4 +26,4 @@ properties: description: >- The new value. Omit this field to retain the current value. minLength: 1 - maxLength: 1024 \ No newline at end of file + maxLength: 4096 \ No newline at end of file diff --git a/apiserver/pom.xml b/apiserver/pom.xml index ed107ef9d7..2bfefabf58 100644 --- a/apiserver/pom.xml +++ b/apiserver/pom.xml @@ -590,6 +590,7 @@ --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED + --add-opens java.base/java.util.concurrent=ALL-UNNAMED -javaagent:${settings.localRepository}/org/mockito/mockito-core/${lib.mockito.version}/mockito-core-${lib.mockito.version}.jar -Xshare:off diff --git a/apiserver/src/main/java/org/dependencytrack/common/MdcKeys.java b/apiserver/src/main/java/org/dependencytrack/common/MdcKeys.java index 60c5cf5367..61d23da241 100644 --- a/apiserver/src/main/java/org/dependencytrack/common/MdcKeys.java +++ b/apiserver/src/main/java/org/dependencytrack/common/MdcKeys.java @@ -38,6 +38,11 @@ public final class MdcKeys { public static final String MDC_KAFKA_RECORD_PARTITION = "kafkaRecordPartition"; public static final String MDC_KAFKA_RECORD_OFFSET = "kafkaRecordOffset"; public static final String MDC_KAFKA_RECORD_KEY = "kafkaRecordKey"; + public static final String MDC_NOTIFICATION_GROUP = "notificationGroup"; + public static final String MDC_NOTIFICATION_ID = "notificationId"; + public static final String MDC_NOTIFICATION_LEVEL = "notificationLevel"; + public static final String MDC_NOTIFICATION_RULE_NAME = "notificationRuleName"; + public static final String MDC_NOTIFICATION_SCOPE = "notificationScope"; public static final String MDC_PLUGIN = "plugin"; public static final String MDC_PROJECT_NAME = "projectName"; public static final String MDC_PROJECT_UUID = "projectUuid"; diff --git a/apiserver/src/main/java/org/dependencytrack/dev/DevServicesInitializer.java b/apiserver/src/main/java/org/dependencytrack/dev/DevServicesInitializer.java index 0be86b07b1..53ae257c4a 100644 --- a/apiserver/src/main/java/org/dependencytrack/dev/DevServicesInitializer.java +++ b/apiserver/src/main/java/org/dependencytrack/dev/DevServicesInitializer.java @@ -154,20 +154,6 @@ public void contextInitialized(final ServletContextEvent event) { } final var topicsToCreate = new ArrayList<>(List.of( - new NewTopic(KafkaTopics.NOTIFICATION_ANALYZER.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_BOM.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_CONFIGURATION.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_DATASOURCE_MIRRORING.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_FILE_SYSTEM.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_INTEGRATION.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_NEW_VULNERABILITY.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_NEW_VULNERABLE_DEPENDENCY.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_POLICY_VIOLATION.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_PROJECT_AUDIT_CHANGE.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_PROJECT_CREATED.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_PROJECT_VULN_ANALYSIS_COMPLETE.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_REPOSITORY.name(), 1, (short) 1), - new NewTopic(KafkaTopics.NOTIFICATION_VEX.name(), 1, (short) 1), new NewTopic(KafkaTopics.REPO_META_ANALYSIS_COMMAND.name(), 1, (short) 1), new NewTopic(KafkaTopics.REPO_META_ANALYSIS_RESULT.name(), 1, (short) 1), new NewTopic(KafkaTopics.VULN_ANALYSIS_COMMAND.name(), 1, (short) 1), diff --git a/apiserver/src/main/java/org/dependencytrack/dex/DexEngineInitializer.java b/apiserver/src/main/java/org/dependencytrack/dex/DexEngineInitializer.java index 20bc430cbd..47f99fd882 100644 --- a/apiserver/src/main/java/org/dependencytrack/dex/DexEngineInitializer.java +++ b/apiserver/src/main/java/org/dependencytrack/dex/DexEngineInitializer.java @@ -26,9 +26,22 @@ import org.dependencytrack.common.EncryptedPageTokenEncoder; import org.dependencytrack.common.datasource.DataSourceRegistry; import org.dependencytrack.common.health.HealthCheckRegistry; +import org.dependencytrack.dex.activity.DeleteFilesActivity; import org.dependencytrack.dex.engine.api.DexEngine; import org.dependencytrack.dex.engine.api.DexEngineConfig; import org.dependencytrack.dex.engine.api.DexEngineFactory; +import org.dependencytrack.dex.engine.api.TaskType; +import org.dependencytrack.dex.engine.api.TaskWorkerOptions; +import org.dependencytrack.dex.engine.api.request.CreateTaskQueueRequest; +import org.dependencytrack.notification.PublishNotificationActivity; +import org.dependencytrack.notification.PublishNotificationWorkflow; +import org.dependencytrack.notification.templating.pebble.PebbleNotificationTemplateRendererFactory; +import org.dependencytrack.persistence.jdbi.ConfigPropertyDao; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.proto.internal.workflow.v1.DeleteFilesArgument; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationActivityArg; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationWorkflowArg; +import org.dependencytrack.secret.management.SecretManager; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; import org.jspecify.annotations.Nullable; @@ -38,10 +51,19 @@ import javax.sql.DataSource; import java.io.IOException; import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.ServiceLoader; +import java.util.regex.Pattern; +import java.util.stream.StreamSupport; import static java.util.Objects.requireNonNull; +import static org.dependencytrack.dex.api.payload.PayloadConverters.protoConverter; +import static org.dependencytrack.dex.api.payload.PayloadConverters.voidConverter; +import static org.dependencytrack.model.ConfigPropertyConstants.GENERAL_BASE_URL; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; /** * @since 5.7.0 @@ -74,14 +96,69 @@ public void contextInitialized(ServletContextEvent event) { final var healthCheckRegistry = (HealthCheckRegistry) servletContext.getAttribute(HealthCheckRegistry.class.getName()); requireNonNull(healthCheckRegistry, "healthCheckRegistry has not been initialized"); + final var pluginManager = (PluginManager) servletContext.getAttribute(PluginManager.class.getName()); + requireNonNull(pluginManager, "pluginManager has not been initialized"); + + final var secretManager = (SecretManager) servletContext.getAttribute(SecretManager.class.getName()); + requireNonNull(pluginManager, "secretManager has not been initialized"); + + final var templateRendererFactory = new PebbleNotificationTemplateRendererFactory( + Map.of("baseUrl", () -> withJdbiHandle( + handle -> handle + .attach(ConfigPropertyDao.class) + .getOptionalValue(GENERAL_BASE_URL) + .orElse(null)))); + final var engineFactory = ServiceLoader.load(DexEngineFactory.class).findFirst().orElseThrow(); engine = engineFactory.create(engineConfig); - // Register workflows and activities here. + engine.registerWorkflow( + new PublishNotificationWorkflow(), + protoConverter(PublishNotificationWorkflowArg.class), + voidConverter(), + Duration.ofMinutes(1)); + engine.registerActivity( + new DeleteFilesActivity(pluginManager), + protoConverter(DeleteFilesArgument.class), + voidConverter(), + Duration.ofMinutes(1)); + engine.registerActivity( + new PublishNotificationActivity( + pluginManager, + secretManager::getSecretValue, + templateRendererFactory), + protoConverter(PublishNotificationActivityArg.class), + voidConverter(), + Duration.ofMinutes(1)); + + ensureTaskQueues(engine, List.of( + new CreateTaskQueueRequest(TaskType.WORKFLOW, "default", 1000), + new CreateTaskQueueRequest(TaskType.ACTIVITY, "default", 1000), + new CreateTaskQueueRequest(TaskType.ACTIVITY, "notifications", 25))); + + for (final String workerName : getWorkflowWorkerNames(config)) { + if (!isTaskWorkerEnabled(config, TaskType.WORKFLOW, workerName)) { + LOGGER.info("Not registering workflow worker '{}' because it is disabled", workerName); + continue; + } + LOGGER.info("Registering workflow worker '{}'", workerName); + + final TaskWorkerOptions workerOptions = + getTaskWorkerOptions(config, TaskType.WORKFLOW, workerName); + engine.registerTaskWorker(workerOptions); + } - // Create task queues here. + for (final String workerName : getActivityWorkerNames(config)) { + if (!isTaskWorkerEnabled(config, TaskType.ACTIVITY, workerName)) { + LOGGER.info("Not registering activity worker '{}' because it is disabled", workerName); + continue; + } + LOGGER.info("Registering activity worker '{}'", workerName); - // Register task workers here. + final TaskWorkerOptions workerOptions = + getTaskWorkerOptions(config, TaskType.ACTIVITY, workerName); + engine.registerTaskWorker(workerOptions); + } LOGGER.info("Starting durable execution engine"); healthCheckRegistry.addCheck(new DexEngineHealthCheck(engine)); @@ -177,6 +254,77 @@ private DexEngineConfig createEngineConfig() { return engineConfig; } + private void ensureTaskQueues(DexEngine engine, Collection requests) { + for (final var request : requests) { + final boolean created = engine.createTaskQueue(request); + if (created) { + LOGGER.info( + "Created {} task queue '{}' with capacity {}", + request.type().name().toLowerCase(), + request.name(), + request.capacity()); + } + } + } + + private static final Pattern WORKFLOW_WORKER_PROPERTY_PATTERN = + Pattern.compile("^dt\\.dex-engine\\.workflow-worker\\..+\\..+$"); + + private static List getWorkflowWorkerNames(Config config) { + return StreamSupport.stream(config.getPropertyNames().spliterator(), false) + .filter(name -> WORKFLOW_WORKER_PROPERTY_PATTERN.matcher(name).matches()) + .map(name -> name.split("\\.", 5)[3]) + .distinct() + .toList(); + } + + private static final Pattern ACTIVITY_WORKER_PROPERTY_PATTERN = + Pattern.compile("^dt\\.dex-engine\\.activity-worker\\..+\\..+$"); + + private static List getActivityWorkerNames(Config config) { + return StreamSupport.stream(config.getPropertyNames().spliterator(), false) + .filter(name -> ACTIVITY_WORKER_PROPERTY_PATTERN.matcher(name).matches()) + .map(name -> name.split("\\.", 5)[3]) + .distinct() + .toList(); + } + + private static boolean isTaskWorkerEnabled(Config config, TaskType taskType, String name) { + return config + .getOptionalValue( + switch (taskType) { + case ACTIVITY -> "dt.dex-engine.activity-worker.%s.enabled".formatted(name); + case WORKFLOW -> "dt.dex-engine.workflow-worker.%s.enabled".formatted(name); + }, + boolean.class) + .orElse(true); + } + + private static TaskWorkerOptions getTaskWorkerOptions(Config config, TaskType type, String name) { + final var prefix = switch (type) { + case ACTIVITY -> "dt.dex-engine.activity-worker.%s.".formatted(name); + case WORKFLOW -> "dt.dex-engine.workflow-worker.%s.".formatted(name); + }; + + final var queueName = config.getValue(prefix + "queue-name", String.class); + final var maxConcurrency = config.getValue(prefix + "max-concurrency", int.class); + final var minPollInterval = config + .getOptionalValue(prefix + "min-poll-interval-ms", long.class) + .map(Duration::ofMillis) + .orElse(null); + final IntervalFunction pollBackoffFunction = getBackoffFunction(config, prefix).orElse(null); + + var options = new TaskWorkerOptions(type, name, queueName, maxConcurrency); + if (minPollInterval != null) { + options = options.withMinPollInterval(minPollInterval); + } + if (pollBackoffFunction != null) { + options = options.withPollBackoffFunction(pollBackoffFunction); + } + + return options; + } + private static Optional getBackoffFunction(Config config, String prefix) { final Optional initialDelayMillis = config.getOptionalValue(prefix + ".initial-delay-ms", long.class); final Optional multiplier = config.getOptionalValue(prefix + ".multiplier", double.class); diff --git a/apiserver/src/main/java/org/dependencytrack/dex/activity/DeleteFilesActivity.java b/apiserver/src/main/java/org/dependencytrack/dex/activity/DeleteFilesActivity.java new file mode 100644 index 0000000000..18df5ff898 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/dex/activity/DeleteFilesActivity.java @@ -0,0 +1,80 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.dex.activity; + +import org.dependencytrack.dex.api.Activity; +import org.dependencytrack.dex.api.ActivityContext; +import org.dependencytrack.dex.api.ActivitySpec; +import org.dependencytrack.dex.api.failure.TerminalApplicationFailureException; +import org.dependencytrack.filestorage.api.FileStorage; +import org.dependencytrack.filestorage.proto.v1.FileMetadata; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.proto.internal.workflow.v1.DeleteFilesArgument; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @since 5.7.0 + */ +@ActivitySpec(name = "delete-files") +public final class DeleteFilesActivity implements Activity { + + private static final Logger LOGGER = LoggerFactory.getLogger(DeleteFilesActivity.class); + + private final PluginManager pluginManager; + + public DeleteFilesActivity(PluginManager pluginManager) { + this.pluginManager = pluginManager; + } + + @Override + public @Nullable Void execute( + ActivityContext ctx, + @Nullable DeleteFilesArgument argument) throws Exception { + if (argument == null) { + throw new TerminalApplicationFailureException("No argument provided"); + } + if (argument.getFileMetadataCount() == 0) { + return null; + } + + final Map> fileMetadataByProvider = + argument.getFileMetadataList().stream() + .collect(Collectors.groupingBy(FileMetadata::getProviderName)); + + for (final String providerName : fileMetadataByProvider.keySet()) { + try (final var fileStorage = pluginManager.getExtension(FileStorage.class, providerName)) { + + // TODO: Call fileStorage#deleteMany here once available. + for (final FileMetadata fileMetadata : fileMetadataByProvider.get(providerName)) { + LOGGER.debug("Deleting file {}", fileMetadata.getLocation()); + fileStorage.delete(fileMetadata); + } + } + } + + return null; + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/dex/activity/package-info.java b/apiserver/src/main/java/org/dependencytrack/dex/activity/package-info.java new file mode 100644 index 0000000000..7091a34e8d --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/dex/activity/package-info.java @@ -0,0 +1,22 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +@NullMarked +package org.dependencytrack.dex.activity; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventConverter.java b/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventConverter.java index 827f4326b5..898bae10c7 100644 --- a/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventConverter.java +++ b/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventConverter.java @@ -19,38 +19,17 @@ package org.dependencytrack.event.kafka; import alpine.event.framework.Event; -import com.google.protobuf.InvalidProtocolBufferException; -import com.google.protobuf.Message; import org.dependencytrack.event.ComponentRepositoryMetaAnalysisEvent; import org.dependencytrack.event.ComponentVulnerabilityAnalysisEvent; -import org.dependencytrack.event.kafka.KafkaTopics.Topic; -import org.dependencytrack.notification.proto.v1.BomConsumedOrProcessedSubject; -import org.dependencytrack.notification.proto.v1.BomProcessingFailedSubject; -import org.dependencytrack.notification.proto.v1.BomValidationFailedSubject; -import org.dependencytrack.notification.proto.v1.NewVulnerabilitySubject; -import org.dependencytrack.notification.proto.v1.NewVulnerableDependencySubject; -import org.dependencytrack.notification.proto.v1.Notification; -import org.dependencytrack.notification.proto.v1.PolicyViolationAnalysisDecisionChangeSubject; -import org.dependencytrack.notification.proto.v1.PolicyViolationSubject; -import org.dependencytrack.notification.proto.v1.Project; -import org.dependencytrack.notification.proto.v1.ProjectVulnAnalysisCompleteSubject; -import org.dependencytrack.notification.proto.v1.UserSubject; -import org.dependencytrack.notification.proto.v1.VexConsumedOrProcessedSubject; -import org.dependencytrack.notification.proto.v1.VulnerabilityAnalysisDecisionChangeSubject; import org.dependencytrack.proto.repometaanalysis.v1.AnalysisCommand; import org.dependencytrack.proto.vulnanalysis.v1.Component; import org.dependencytrack.proto.vulnanalysis.v1.ScanCommand; import org.dependencytrack.proto.vulnanalysis.v1.ScanKey; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; -import static org.apache.commons.lang3.ObjectUtils.requireNonEmpty; - /** * Utility class to convert {@link Event}s to {@link KafkaEvent}s. */ @@ -67,28 +46,6 @@ private KafkaEventConverter() { }; } - public static KafkaEvent convert(final Notification notification) { - final Topic topic = extractDestinationTopic(notification); - - final String recordKey; - try { - recordKey = extractEventKey(notification); - } catch (InvalidProtocolBufferException e) { - throw new RuntimeException(e); - } - - return new KafkaEvent<>(topic, recordKey, notification); - } - - static List> convertAllNotificationProtos(final Collection notifications) { - final var kafkaEvents = new ArrayList>(notifications.size()); - for (final Notification notification : notifications) { - kafkaEvents.add(convert(notification)); - } - - return kafkaEvents; - } - static KafkaEvent convert(final ComponentVulnerabilityAnalysisEvent event) { final var componentBuilder = Component.newBuilder() .setUuid(event.uuid().toString()); @@ -132,118 +89,4 @@ static KafkaEvent convert(final ComponentRepositoryMeta return new KafkaEvent<>(KafkaTopics.REPO_META_ANALYSIS_COMMAND, event.purlCoordinates(), analysisCommand, null); } - private static Topic extractDestinationTopic(final Notification notification) { - return switch (notification.getGroup()) { - case GROUP_ANALYZER -> KafkaTopics.NOTIFICATION_ANALYZER; - case GROUP_BOM_CONSUMED, GROUP_BOM_PROCESSED, GROUP_BOM_PROCESSING_FAILED, GROUP_BOM_VALIDATION_FAILED -> KafkaTopics.NOTIFICATION_BOM; - case GROUP_CONFIGURATION -> KafkaTopics.NOTIFICATION_CONFIGURATION; - case GROUP_DATASOURCE_MIRRORING -> KafkaTopics.NOTIFICATION_DATASOURCE_MIRRORING; - case GROUP_FILE_SYSTEM -> KafkaTopics.NOTIFICATION_FILE_SYSTEM; - case GROUP_INTEGRATION -> KafkaTopics.NOTIFICATION_INTEGRATION; - case GROUP_NEW_VULNERABILITY -> KafkaTopics.NOTIFICATION_NEW_VULNERABILITY; - case GROUP_NEW_VULNERABLE_DEPENDENCY -> KafkaTopics.NOTIFICATION_NEW_VULNERABLE_DEPENDENCY; - case GROUP_POLICY_VIOLATION -> KafkaTopics.NOTIFICATION_POLICY_VIOLATION; - case GROUP_PROJECT_AUDIT_CHANGE -> KafkaTopics.NOTIFICATION_PROJECT_AUDIT_CHANGE; - case GROUP_PROJECT_CREATED -> KafkaTopics.NOTIFICATION_PROJECT_CREATED; - case GROUP_PROJECT_VULN_ANALYSIS_COMPLETE -> KafkaTopics.NOTIFICATION_PROJECT_VULN_ANALYSIS_COMPLETE; - case GROUP_REPOSITORY -> KafkaTopics.NOTIFICATION_REPOSITORY; - case GROUP_VEX_CONSUMED, GROUP_VEX_PROCESSED -> KafkaTopics.NOTIFICATION_VEX; - case GROUP_USER_CREATED, GROUP_USER_DELETED -> KafkaTopics.NOTIFICATION_USER; - case GROUP_UNSPECIFIED, UNRECOGNIZED -> throw new IllegalArgumentException(""" - Unable to determine destination topic because the notification does not \ - specify a notification group: %s""".formatted(notification.getGroup())); - // NB: The lack of a default case is intentional. This way, the compiler will fail - // the build when new groups are added, and we don't have a case for it :) - }; - } - - private static String extractEventKey(final Notification notification) throws InvalidProtocolBufferException { - return switch (notification.getGroup()) { - case GROUP_BOM_CONSUMED, GROUP_BOM_PROCESSED -> { - requireSubjectOfTypeAnyOf(notification, List.of(BomConsumedOrProcessedSubject.class)); - final var subject = notification.getSubject().unpack(BomConsumedOrProcessedSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_BOM_PROCESSING_FAILED -> { - requireSubjectOfTypeAnyOf(notification, List.of(BomProcessingFailedSubject.class)); - final var subject = notification.getSubject().unpack(BomProcessingFailedSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_BOM_VALIDATION_FAILED -> { - requireSubjectOfTypeAnyOf(notification, List.of(BomValidationFailedSubject.class)); - final var subject = notification.getSubject().unpack(BomValidationFailedSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_NEW_VULNERABILITY -> { - requireSubjectOfTypeAnyOf(notification, List.of(NewVulnerabilitySubject.class)); - final var subject = notification.getSubject().unpack(NewVulnerabilitySubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_NEW_VULNERABLE_DEPENDENCY -> { - requireSubjectOfTypeAnyOf(notification, List.of(NewVulnerableDependencySubject.class)); - final var subject = notification.getSubject().unpack(NewVulnerableDependencySubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_POLICY_VIOLATION -> { - requireSubjectOfTypeAnyOf(notification, List.of(PolicyViolationSubject.class)); - final var subject = notification.getSubject().unpack(PolicyViolationSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_PROJECT_AUDIT_CHANGE -> { - final Class matchingSubject = requireSubjectOfTypeAnyOf(notification, List.of( - PolicyViolationAnalysisDecisionChangeSubject.class, - VulnerabilityAnalysisDecisionChangeSubject.class - )); - - if (matchingSubject == PolicyViolationAnalysisDecisionChangeSubject.class) { - final var subject = notification.getSubject().unpack(PolicyViolationAnalysisDecisionChangeSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } else { - final var subject = notification.getSubject().unpack(VulnerabilityAnalysisDecisionChangeSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - } - case GROUP_PROJECT_CREATED -> { - requireSubjectOfTypeAnyOf(notification, List.of(Project.class)); - final var subject = notification.getSubject().unpack(Project.class); - yield requireNonEmpty(subject.getUuid()); - } - case GROUP_PROJECT_VULN_ANALYSIS_COMPLETE -> { - requireSubjectOfTypeAnyOf(notification, List.of(ProjectVulnAnalysisCompleteSubject.class)); - final var subject = notification.getSubject().unpack(ProjectVulnAnalysisCompleteSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_VEX_CONSUMED, GROUP_VEX_PROCESSED -> { - requireSubjectOfTypeAnyOf(notification, List.of(VexConsumedOrProcessedSubject.class)); - final var subject = notification.getSubject().unpack(VexConsumedOrProcessedSubject.class); - yield requireNonEmpty(subject.getProject().getUuid()); - } - case GROUP_USER_CREATED, GROUP_USER_DELETED -> { - requireSubjectOfTypeAnyOf(notification, List.of(UserSubject.class)); - final var subject = notification.getSubject().unpack(UserSubject.class); - yield requireNonEmpty(subject.getUsername()); - } - case GROUP_ANALYZER, GROUP_CONFIGURATION, GROUP_DATASOURCE_MIRRORING, - GROUP_FILE_SYSTEM, GROUP_INTEGRATION, GROUP_REPOSITORY -> null; - case GROUP_UNSPECIFIED, UNRECOGNIZED -> throw new IllegalArgumentException(""" - Unable to determine record key because the notification does not \ - specify a notification group: %s""".formatted(notification.getGroup())); - // NB: The lack of a default case is intentional. This way, the compiler will fail - // the build when new groups are added, and we don't have a case for it :) - }; - } - - private static Class requireSubjectOfTypeAnyOf(final Notification notification, - final Collection> subjectClasses) { - if (!notification.hasSubject()) { - throw new IllegalArgumentException("Expected subject of type matching any of %s, but notification has no subject" - .formatted(subjectClasses)); - } - - return subjectClasses.stream() - .filter(notification.getSubject()::is).findFirst() - .orElseThrow(() -> new IllegalArgumentException("Expected subject of type matching any of %s, but is %s" - .formatted(subjectClasses, notification.getSubject().getTypeUrl()))); - } - } diff --git a/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventDispatcher.java b/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventDispatcher.java index 02c0e64675..963bf5b66f 100644 --- a/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventDispatcher.java +++ b/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaEventDispatcher.java @@ -62,15 +62,6 @@ public CompletableFuture dispatchEvent(final Event event) { return dispatchAll(List.of(kafkaEvent)).getFirst(); } - /** - * @deprecated Use {@link org.dependencytrack.notification.api.emission.NotificationEmitter} instead. - */ - @Deprecated(since = "5.7.0", forRemoval = true) - public List> dispatchAllNotificationProtos(final Collection notifications) { - final List> kafkaEvents = KafkaEventConverter.convertAllNotificationProtos(notifications); - return dispatchAll(kafkaEvents); - } - public List> dispatchAll(final Collection> events) { if (events == null || events.isEmpty()) { return Collections.emptyList(); diff --git a/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaTopics.java b/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaTopics.java index ce6b60a9a2..d11d37b4b2 100644 --- a/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaTopics.java +++ b/apiserver/src/main/java/org/dependencytrack/event/kafka/KafkaTopics.java @@ -23,7 +23,6 @@ import org.apache.kafka.common.serialization.Serdes; import org.dependencytrack.common.ConfigKey; import org.dependencytrack.event.kafka.serialization.KafkaProtobufSerde; -import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.proto.repometaanalysis.v1.AnalysisCommand; import org.dependencytrack.proto.repometaanalysis.v1.AnalysisResult; import org.dependencytrack.proto.vulnanalysis.v1.ScanCommand; @@ -32,50 +31,18 @@ public final class KafkaTopics { - public static final Topic NOTIFICATION_ANALYZER; - public static final Topic NOTIFICATION_BOM; - public static final Topic NOTIFICATION_CONFIGURATION; - public static final Topic NOTIFICATION_DATASOURCE_MIRRORING; - public static final Topic NOTIFICATION_FILE_SYSTEM; - public static final Topic NOTIFICATION_INTEGRATION; - public static final Topic NOTIFICATION_NEW_VULNERABILITY; - public static final Topic NOTIFICATION_NEW_VULNERABLE_DEPENDENCY; - public static final Topic NOTIFICATION_POLICY_VIOLATION; - public static final Topic NOTIFICATION_PROJECT_AUDIT_CHANGE; - public static final Topic NOTIFICATION_PROJECT_CREATED; - public static final Topic NOTIFICATION_REPOSITORY; - public static final Topic NOTIFICATION_VEX; - public static final Topic NOTIFICATION_USER; public static final Topic REPO_META_ANALYSIS_COMMAND; public static final Topic REPO_META_ANALYSIS_RESULT; public static final Topic VULN_ANALYSIS_COMMAND; public static final Topic VULN_ANALYSIS_RESULT; public static final Topic VULN_ANALYSIS_RESULT_PROCESSED; - public static final Topic NOTIFICATION_PROJECT_VULN_ANALYSIS_COMPLETE; - private static final Serde NOTIFICATION_SERDE = new KafkaProtobufSerde<>(Notification.parser()); - static { - NOTIFICATION_ANALYZER = new Topic<>("dtrack.notification.analyzer", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_BOM = new Topic<>("dtrack.notification.bom", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_CONFIGURATION = new Topic<>("dtrack.notification.configuration", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_DATASOURCE_MIRRORING = new Topic<>("dtrack.notification.datasource-mirroring", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_FILE_SYSTEM = new Topic<>("dtrack.notification.file-system", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_INTEGRATION = new Topic<>("dtrack.notification.integration", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_NEW_VULNERABILITY = new Topic<>("dtrack.notification.new-vulnerability", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_NEW_VULNERABLE_DEPENDENCY = new Topic<>("dtrack.notification.new-vulnerable-dependency", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_POLICY_VIOLATION = new Topic<>("dtrack.notification.policy-violation", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_PROJECT_AUDIT_CHANGE = new Topic<>("dtrack.notification.project-audit-change", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_PROJECT_CREATED = new Topic<>("dtrack.notification.project-created", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_REPOSITORY = new Topic<>("dtrack.notification.repository", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_VEX = new Topic<>("dtrack.notification.vex", Serdes.String(), NOTIFICATION_SERDE); REPO_META_ANALYSIS_COMMAND = new Topic<>("dtrack.repo-meta-analysis.component", Serdes.String(), new KafkaProtobufSerde<>(AnalysisCommand.parser())); REPO_META_ANALYSIS_RESULT = new Topic<>("dtrack.repo-meta-analysis.result", Serdes.String(), new KafkaProtobufSerde<>(AnalysisResult.parser())); VULN_ANALYSIS_COMMAND = new Topic<>("dtrack.vuln-analysis.component", new KafkaProtobufSerde<>(ScanKey.parser()), new KafkaProtobufSerde<>(ScanCommand.parser())); VULN_ANALYSIS_RESULT = new Topic<>("dtrack.vuln-analysis.result", new KafkaProtobufSerde<>(ScanKey.parser()), new KafkaProtobufSerde<>(ScanResult.parser())); VULN_ANALYSIS_RESULT_PROCESSED = new Topic<>("dtrack.vuln-analysis.result.processed", Serdes.String(), new KafkaProtobufSerde<>(ScanResult.parser())); - NOTIFICATION_PROJECT_VULN_ANALYSIS_COMPLETE = new Topic<>("dtrack.notification.project-vuln-analysis-complete", Serdes.String(), NOTIFICATION_SERDE); - NOTIFICATION_USER = new Topic<>("dtrack.notification.user", Serdes.String(), NOTIFICATION_SERDE); } public record Topic(String name, Serde keySerde, Serde valueSerde) { diff --git a/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index d1cd14359b..0ef5494096 100644 --- a/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -30,19 +30,8 @@ public enum ConfigPropertyConstants { INTERNAL_DEFAULT_OBJECTS_VERSION("internal", "default.objects.version", null, PropertyType.STRING, "Version of the default objects in the database", ConfigPropertyAccessMode.READ_ONLY), GENERAL_BASE_URL("general", "base.url", null, PropertyType.URL, "URL used to construct links back to Dependency-Track from external systems", ConfigPropertyAccessMode.READ_WRITE), GENERAL_BADGE_ENABLED("general", "badge.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable unauthenticated access to SVG badge from metrics", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_ENABLED("email", "smtp.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable SMTP", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_FROM_ADDR("email", "smtp.from.address", null, PropertyType.STRING, "The from email address to use to send output SMTP mail", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_SERVER_HOSTNAME("email", "smtp.server.hostname", null, PropertyType.STRING, "The hostname or IP address of the SMTP mail server", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_SERVER_PORT("email", "smtp.server.port", null, PropertyType.INTEGER, "The port the SMTP server listens on", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_USERNAME("email", "smtp.username", null, PropertyType.STRING, "The optional username to authenticate with when sending outbound SMTP mail", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_PASSWORD("email", "smtp.password", null, PropertyType.ENCRYPTEDSTRING, "The optional password for the username used for authentication", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_SSLTLS("email", "smtp.ssltls", "false", PropertyType.BOOLEAN, "Flag to enable/disable the use of SSL/TLS when connecting to the SMTP server", ConfigPropertyAccessMode.READ_WRITE), - EMAIL_SMTP_TRUSTCERT("email", "smtp.trustcert", "false", PropertyType.BOOLEAN, "Flag to enable/disable the trust of the certificate presented by the SMTP server", ConfigPropertyAccessMode.READ_WRITE), INTERNAL_COMPONENTS_GROUPS_REGEX("internal-components", "groups.regex", null, PropertyType.STRING, "Regex that matches groups of internal components", ConfigPropertyAccessMode.READ_WRITE), INTERNAL_COMPONENTS_NAMES_REGEX("internal-components", "names.regex", null, PropertyType.STRING, "Regex that matches names of internal components", ConfigPropertyAccessMode.READ_WRITE), - JIRA_URL("integrations", "jira.url", null, PropertyType.URL, "The base URL of the JIRA instance", ConfigPropertyAccessMode.READ_WRITE), - JIRA_USERNAME("integrations", "jira.username", null, PropertyType.STRING, "The optional username to authenticate with when creating an Jira issue", ConfigPropertyAccessMode.READ_WRITE), - JIRA_PASSWORD("integrations", "jira.password", null, PropertyType.ENCRYPTEDSTRING, "The optional password for the username used for authentication", ConfigPropertyAccessMode.READ_WRITE), MAINTENANCE_METRICS_RETENTION_DAYS("maintenance", "metrics.retention.days", "90", PropertyType.INTEGER, "Number of days to retain metrics data for", ConfigPropertyAccessMode.READ_WRITE), MAINTENANCE_PROJECTS_RETENTION_DAYS("maintenance", "projects.retention.days", "30", PropertyType.INTEGER, "Number of days to retain inactive projects for", ConfigPropertyAccessMode.READ_WRITE), MAINTENANCE_PROJECTS_RETENTION_TYPE("maintenance", "projects.retention.type", null, PropertyType.STRING, "Retention policy type for inactive projects", ConfigPropertyAccessMode.READ_WRITE), diff --git a/apiserver/src/main/java/org/dependencytrack/model/NotificationPublisher.java b/apiserver/src/main/java/org/dependencytrack/model/NotificationPublisher.java index 9c2e831f93..e84933bec3 100644 --- a/apiserver/src/main/java/org/dependencytrack/model/NotificationPublisher.java +++ b/apiserver/src/main/java/org/dependencytrack/model/NotificationPublisher.java @@ -22,6 +22,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import javax.jdo.annotations.Column; import javax.jdo.annotations.FetchGroup; @@ -31,9 +34,6 @@ import javax.jdo.annotations.Persistent; import javax.jdo.annotations.PrimaryKey; import javax.jdo.annotations.Unique; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import java.io.Serializable; import java.util.UUID; @@ -48,7 +48,7 @@ @FetchGroup(name = "ALL", members = { @Persistent(name = "name"), @Persistent(name = "description"), - @Persistent(name = "publisherClass"), + @Persistent(name = "extensionName"), @Persistent(name = "template"), @Persistent(name = "templateMimeType"), @Persistent(name = "defaultPublisher"), @@ -86,11 +86,11 @@ public enum FetchGroup { private String description; @Persistent(defaultFetchGroup = "true") - @Column(name = "PUBLISHER_CLASS", length = 1024, allowsNull = "false") + @Column(name = "EXTENSION_NAME", length = 1024, allowsNull = "false") @NotBlank @Size(min = 1, max = 1024) @JsonDeserialize(using = TrimmedStringDeserializer.class) - private String publisherClass; + private String extensionName; @Persistent(defaultFetchGroup = "false") @Column(name = "TEMPLATE", jdbcType = "CLOB") @@ -98,8 +98,7 @@ public enum FetchGroup { private String template; @Persistent(defaultFetchGroup = "true") - @Column(name = "TEMPLATE_MIME_TYPE", allowsNull = "false") - @NotBlank + @Column(name = "TEMPLATE_MIME_TYPE") @Size(min = 1, max = 255) @JsonDeserialize(using = TrimmedStringDeserializer.class) private String templateMimeType; @@ -140,12 +139,12 @@ public void setDescription(String description) { } @NotNull - public String getPublisherClass() { - return publisherClass; + public String getExtensionName() { + return extensionName; } - public void setPublisherClass(@NotNull String publisherClass) { - this.publisherClass = publisherClass; + public void setExtensionName(@NotNull String extensionName) { + this.extensionName = extensionName; } public String getTemplate() { diff --git a/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java b/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java index 1b4132df25..8c39acd448 100644 --- a/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java +++ b/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java @@ -29,7 +29,6 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -import org.apache.commons.collections4.CollectionUtils; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationLevel; import org.dependencytrack.notification.NotificationScope; @@ -48,12 +47,11 @@ import javax.jdo.annotations.PrimaryKey; import javax.jdo.annotations.Unique; import java.io.Serializable; -import java.util.ArrayList; -import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import java.util.TreeSet; import java.util.UUID; +import java.util.stream.Collectors; /** * Defines a Model class for notification configurations. @@ -133,9 +131,9 @@ public class NotificationRule implements Serializable { @Element(column = "TEAM_ID", foreignKey = "NOTIFICATIONRULE_TEAMS_TEAM_FK", deleteAction = ForeignKeyAction.CASCADE) private Set teams; - @Persistent - @Column(name = "NOTIFY_ON", length = 1024) - private String notifyOn; + @Persistent(defaultFetchGroup = "true") + @Column(name = "NOTIFY_ON", jdbcType = "ARRAY", sqlType = "TEXT ARRAY") + private Set notifyOn = new LinkedHashSet<>(); @Persistent @Column(name = "MESSAGE", length = 1024) @@ -151,6 +149,10 @@ public class NotificationRule implements Serializable { @Persistent(defaultFetchGroup = "true") @Column(name = "PUBLISHER_CONFIG", jdbcType = "CLOB") + @Extensions(value = { + @Extension(vendorName = "datanucleus", key = "insert-function", value = "CAST(? AS JSONB)"), + @Extension(vendorName = "datanucleus", key = "update-function", value = "CAST(? AS JSONB)") + }) @JsonDeserialize(using = TrimmedStringDeserializer.class) private String publisherConfig; @@ -252,31 +254,23 @@ public void setMessage(String message) { } public Set getNotifyOn() { - Set result = new TreeSet<>(); - if (notifyOn != null) { - String[] groups = notifyOn.split(","); - for (String s: groups) { - result.add(NotificationGroup.valueOf(s.trim())); - } + if (notifyOn == null) { + return null; } - return result; + + return notifyOn.stream() + .map(NotificationGroup::valueOf) + .collect(Collectors.toCollection(LinkedHashSet::new)); } public void setNotifyOn(Set groups) { - if (CollectionUtils.isEmpty(groups)) { + if (groups == null) { this.notifyOn = null; - return; - } - StringBuilder sb = new StringBuilder(); - List list = new ArrayList<>(groups); - Collections.sort(list); - for (int i=0; i extensionFactories = + pluginManager.getFactories(NotificationPublisher.class); + if (extensionFactories.isEmpty()) { + return; + } + + final var publishers = new ArrayList(extensionFactories.size()); + for (final var extensionFactory : extensionFactories) { + final var publisher = new org.dependencytrack.model.NotificationPublisher(); + publisher.setName(StringUtils.capitalize(extensionFactory.extensionName())); + publisher.setDescription("Default %s publisher".formatted(publisher.getName())); + publisher.setExtensionName(extensionFactory.extensionName()); + publisher.setDefaultPublisher(true); + + final NotificationTemplate template = extensionFactory.defaultTemplate(); + if (template != null) { + publisher.setTemplate(template.content()); + publisher.setTemplateMimeType(template.mimeType()); + } + + publishers.add(publisher); + } + + createPublishers(publishers); + } + + private void createPublishers(Collection publishers) { + useJdbiTransaction(handle -> { + final PreparedBatch preparedBatch = handle.prepareBatch(""" + INSERT INTO "NOTIFICATIONPUBLISHER" ( + "NAME" + , "EXTENSION_NAME" + , "DEFAULT_PUBLISHER" + , "DESCRIPTION" + , "TEMPLATE" + , "TEMPLATE_MIME_TYPE" + , "UUID" + ) + VALUES ( + :name + , :extensionName + , :defaultPublisher + , :description + , :template + , :templateMimeType + , GEN_RANDOM_UUID() + ) + ON CONFLICT ("NAME") DO UPDATE + SET "EXTENSION_NAME" = EXCLUDED."EXTENSION_NAME" + , "DESCRIPTION" = EXCLUDED."DESCRIPTION" + , "TEMPLATE" = EXCLUDED."TEMPLATE" + , "TEMPLATE_MIME_TYPE" = EXCLUDED."TEMPLATE_MIME_TYPE" + -- Only update when at least one relevant field has changed. + WHERE "NOTIFICATIONPUBLISHER"."EXTENSION_NAME" IS DISTINCT FROM EXCLUDED."EXTENSION_NAME" + OR "NOTIFICATIONPUBLISHER"."DESCRIPTION" IS DISTINCT FROM EXCLUDED."DESCRIPTION" + OR "NOTIFICATIONPUBLISHER"."TEMPLATE" IS DISTINCT FROM EXCLUDED."TEMPLATE" + OR "NOTIFICATIONPUBLISHER"."TEMPLATE_MIME_TYPE" IS DISTINCT FROM EXCLUDED."TEMPLATE_MIME_TYPE" + """); + + for (final var publisher : publishers) { + preparedBatch + .bindBean(publisher) + .add(); + } + + final int publishersCreatedOrUpdated = Arrays.stream(preparedBatch.execute()).sum(); + LOGGER.debug("Created or updated {} publishers", publishersCreatedOrUpdated); + }); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/notification/JdbcNotificationEmitter.java b/apiserver/src/main/java/org/dependencytrack/notification/JdbcNotificationEmitter.java index be20c0e2dd..4a45542866 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/JdbcNotificationEmitter.java +++ b/apiserver/src/main/java/org/dependencytrack/notification/JdbcNotificationEmitter.java @@ -36,9 +36,11 @@ import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.concurrent.TimeUnit; @@ -72,13 +74,12 @@ class JdbcNotificationEmitter implements NotificationEmitter { this.emittedDistribution = DistributionSummary .builder("dt.notifications.emitted") .withRegistry(meterRegistry); - } @Override public void emitAll(Collection notifications) { requireNonNull(connection, "connection must not be null"); - + emitAll(connection, notifications); } @@ -119,39 +120,30 @@ void emitAll(Connection connection, Collection notifications) { index++; } - // TODO: Modify the query such that emission is skipped if - // no applicable rule exists. This should be added once the - // NotificationOutboxRelay also performs rule evaluation and routing. - // Always inserting all notifications is a temporary solution that - // replicates the behavior we previously had with emitting directly to Kafka. - - // INSERT INTO "NOTIFICATION_OUTBOX" ("ID", "TIMESTAMP", "SCOPE", "GROUP", "LEVEL", "PAYLOAD") - // SELECT id - // , timestamp - // , scope - // , "group" - // , level - // , payload - // FROM UNNEST(?, ?, ?, ?, ?, ?) - // AS t(id, timestamp, scope, "group", level, payload) - // -- Preliminary check if there even is a rule that could match - // -- the notification. Note that more extensive matching is performed - // -- during relay. This is just to avoid unnecessary inserts. - // WHERE EXISTS( - // SELECT 1 - // FROM "NOTIFICATIONRULE" - // WHERE "ENABLED" - // AND "SCOPE" = t.scope - // AND "NOTIFICATION_LEVEL" <= t.level - // AND "NOTIFY_ON" LIKE ('%' || t."group" || '%') - // ) - - int emittedCount; + final var emittedIds = new HashSet(); try (final PreparedStatement ps = connection.prepareStatement(""" INSERT INTO "NOTIFICATION_OUTBOX" ("ID", "TIMESTAMP", "SCOPE", "GROUP", "LEVEL", "PAYLOAD") - SELECT * + SELECT id + , timestamp + , scope + , "group" + , level + , payload FROM UNNEST(?, ?, ?, ?, ?, ?) - """)) { + AS t(id, timestamp, scope, "group", level, payload) + -- Preliminary check if there even is a rule that could match + -- the notification. Note that more extensive matching is performed + -- during relay. This is just to avoid unnecessary inserts. + WHERE EXISTS( + SELECT 1 + FROM "NOTIFICATIONRULE" + WHERE "ENABLED" + AND "SCOPE" = t.scope + AND "NOTIFICATION_LEVEL" <= t.level + AND t."group" = ANY("NOTIFY_ON") + ) + RETURNING "ID" + """, PreparedStatement.RETURN_GENERATED_KEYS)) { // timestamp, scope, group, and level are only added for debugging and monitoring purposes. // The same information is included in payload, but that of course is not viewable @@ -161,29 +153,33 @@ FROM UNNEST(?, ?, ?, ?, ?, ?) ps.setArray(2, connection.createArrayOf("TIMESTAMPTZ", timestamps)); ps.setArray(3, connection.createArrayOf("TEXT", scopes)); ps.setArray(4, connection.createArrayOf("TEXT", groups)); - ps.setArray(5, connection.createArrayOf("TEXT", levels)); + ps.setArray(5, connection.createArrayOf("NOTIFICATION_LEVEL", levels)); ps.setArray(6, connection.createArrayOf("BYTEA", payloads)); + ps.executeUpdate(); - emittedCount = ps.executeUpdate(); + final ResultSet rs = ps.getGeneratedKeys(); + while (rs.next()) { + emittedIds.add(rs.getString(1)); + } } catch (SQLException e) { throw new IllegalStateException("Failed to insert notification records", e); } - // TODO: Once emission is filtered based on whether matching rules exist, - // this must be modified to only consider actually emitted notifications. for (final Notification notification : notifications) { - emittedDistribution - .withTags(List.of( - Tag.of("level", convert(notification.getLevel()).name()), - Tag.of("scope", convert(notification.getScope()).name()), - Tag.of("group", convert(notification.getGroup()).name()))) - .record(1); + if (emittedIds.contains(notification.getId())) { + emittedDistribution + .withTags(List.of( + Tag.of("level", convert(notification.getLevel()).name()), + Tag.of("scope", convert(notification.getScope()).name()), + Tag.of("group", convert(notification.getGroup()).name()))) + .record(1); + } } final long emitLatencyNanos = emitLatencySample.stop(emitLatencyTimer); logger.debug( "Emitted {} notifications in {}ms", - emittedCount, + emittedIds.size(), TimeUnit.NANOSECONDS.toMillis(emitLatencyNanos)); } diff --git a/apiserver/src/main/java/org/dependencytrack/notification/NotificationOutboxRelay.java b/apiserver/src/main/java/org/dependencytrack/notification/NotificationOutboxRelay.java index ad02704e21..63907453c6 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/NotificationOutboxRelay.java +++ b/apiserver/src/main/java/org/dependencytrack/notification/NotificationOutboxRelay.java @@ -28,30 +28,38 @@ import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics; import org.apache.commons.lang3.concurrent.BasicThreadFactory; -import org.apache.kafka.clients.producer.RecordMetadata; -import org.dependencytrack.event.kafka.KafkaEventDispatcher; +import org.dependencytrack.dex.engine.api.DexEngine; +import org.dependencytrack.dex.engine.api.request.CreateWorkflowRunRequest; +import org.dependencytrack.filestorage.api.FileStorage; +import org.dependencytrack.filestorage.proto.v1.FileMetadata; import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.persistence.jdbi.NotificationOutboxDao; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationWorkflowArg; import org.jdbi.v3.core.Handle; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import java.io.ByteArrayInputStream; import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.function.Function; import static io.github.resilience4j.core.IntervalFunction.ofExponentialRandomBackoff; import static java.util.Objects.requireNonNull; +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_ID; import static org.dependencytrack.notification.NotificationModelConverter.convert; import static org.dependencytrack.persistence.jdbi.JdbiFactory.inJdbiTransaction; @@ -68,11 +76,13 @@ final class NotificationOutboxRelay implements Closeable { private static final List COMMON_METER_TAGS = List.of(Tag.of("outboxName", "notifications")); private static final String OUTCOME_METER_TAG_NAME = "outcome"; - private final KafkaEventDispatcher delegateDispatcher; + private final DexEngine dexEngine; + private final PluginManager pluginManager; + private final Function routerFactory; private final MeterRegistry meterRegistry; - private final boolean routerEnabled; private final long pollIntervalMillis; private final int batchSize; + private final int largeNotificationThresholdBytes; private final BlockingQueue currentBatch; private final IntervalFunction backoffIntervalFunction; private @Nullable ScheduledExecutorService executorService; @@ -83,12 +93,16 @@ final class NotificationOutboxRelay implements Closeable { private @Nullable MeterProvider sentDistribution; public NotificationOutboxRelay( - KafkaEventDispatcher delegateDispatcher, + DexEngine dexEngine, + PluginManager pluginManager, + Function routerFactory, MeterRegistry meterRegistry, - boolean routerEnabled, long pollIntervalMillis, - int batchSize) { - this.delegateDispatcher = requireNonNull(delegateDispatcher, "delegate dispatcher must not be null"); + int batchSize, + int largeNotificationThresholdBytes) { + this.dexEngine = requireNonNull(dexEngine, "dexEngine must not be null"); + this.pluginManager = requireNonNull(pluginManager, "pluginManager must not be null"); + this.routerFactory = requireNonNull(routerFactory, "routerFactory must not be null"); this.meterRegistry = requireNonNull(meterRegistry, "meterRegistry must not be null"); if (pollIntervalMillis <= 0) { throw new IllegalArgumentException("pollIntervalMillis must be greater than 0"); @@ -96,9 +110,12 @@ public NotificationOutboxRelay( if (batchSize <= 0) { throw new IllegalArgumentException("batchSize must be greater than 0"); } - this.routerEnabled = routerEnabled; + if (largeNotificationThresholdBytes <= 0) { + throw new IllegalArgumentException("largeNotificationThresholdBytes must be greater than 0"); + } this.pollIntervalMillis = pollIntervalMillis; this.batchSize = batchSize; + this.largeNotificationThresholdBytes = largeNotificationThresholdBytes; this.currentBatch = new ArrayBlockingQueue<>(batchSize); this.backoffIntervalFunction = ofExponentialRandomBackoff( /* initialDelay */ pollIntervalMillis, @@ -226,21 +243,16 @@ private RelayCycleOutcome executeRelayCycle() { return RelayCycleOutcome.COMPLETED; } - if (routerEnabled) { - try { - final List publishTasks = - new NotificationRouter(handle, meterRegistry).route(currentBatch); - LOGGER.debug("Router generated {} publish tasks", publishTasks.size()); - } catch (RuntimeException e) { - LOGGER.warn(""" - Router failed, but since routing results are not currently used, - the failure is ignored. If it continues to fail, consider disabling the router.""", e); - } + final NotificationRouter router = routerFactory.apply(handle); + final List routerResults = router.route(currentBatch); + LOGGER.debug("Router generated {} results", routerResults.size()); + if (routerResults.isEmpty()) { + return RelayCycleOutcome.COMPLETED; } final Timer.Sample sendLatencySample = Timer.start(); try { - sendAll(currentBatch); + sendAll(routerResults); } finally { sendLatencySample.stop(sendLatencyTimer); } @@ -267,27 +279,53 @@ SELECT pg_try_advisory_xact_lock(:lockId) .one(); } - private void sendAll(Collection notifications) { - @SuppressWarnings("removal") final List> futures = - delegateDispatcher.dispatchAllNotificationProtos(notifications); - - final CompletableFuture combinedFuture = - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + private void sendAll(Collection routerResults) { + final var createRunRequests = new ArrayList>(routerResults.size()); + + for (final var routerResult : routerResults) { + final Notification notification = routerResult.notification(); + + try (var ignored = MDC.putCloseable(MDC_NOTIFICATION_ID, notification.getId())) { + final var workflowArgBuilder = + PublishNotificationWorkflowArg.newBuilder() + .setNotificationId(notification.getId()) + .addAllNotificationRuleNames(routerResult.ruleNames()); + + // Large payloads have the potential to slow down the dex engine, + // as they're stored in the workflow history. Some notifications + // can be large, e.g. BOM_CONSUMED or PROJECT_VULN_ANALYSIS_COMPLETED. + // + // If a notification exceeds the configured size threshold, + // offload it to file storage and send the file's metadata + // as workflow argument instead. It is the workflow's responsibility + // to ensure that the file is deleted. + if (notification.getSerializedSize() > largeNotificationThresholdBytes) { + LOGGER.warn( + "Notification size {}b exceeds large threshold of {}b; Will offload to file storage", + notification.getSerializedSize(), + largeNotificationThresholdBytes); + + try (final var fileStorage = pluginManager.getExtension(FileStorage.class)) { + final FileMetadata fileMetadata = fileStorage.store( + "notifications/%s.proto".formatted(notification.getId()), + "application/protobuf", + new ByteArrayInputStream(notification.toByteArray())); + workflowArgBuilder.setNotificationFileMetadata(fileMetadata); + } catch (IOException e) { + throw new UncheckedIOException("Failed to store notification file", e); + } + } else { + workflowArgBuilder.setNotification(notification); + } - try { - // Since we're in a database transaction, ensure we're not blocking it - // for prolonged time. 5 seconds should be plenty for every Kafka cluster. - combinedFuture.get(5, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException( - "Interrupted while waiting for messages to be acknowledged", e); - } catch (TimeoutException e) { - throw new IllegalStateException( - "Timed out while waiting for messages to be acknowledged", e); - } catch (ExecutionException e) { - throw new IllegalStateException("Failed to send messages", e.getCause()); + createRunRequests.add( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class) + .withWorkflowInstanceId("publish-notification:" + notification.getId()) + .withArgument(workflowArgBuilder.build())); + } } + + dexEngine.createRuns(createRunRequests); } } diff --git a/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java b/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java index 7621c1c339..5f3fcd4367 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java +++ b/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java @@ -48,12 +48,17 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import static java.util.Objects.requireNonNull; +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_GROUP; +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_ID; +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_LEVEL; +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_SCOPE; import static org.dependencytrack.notification.NotificationModelConverter.convert; import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_PORTFOLIO; @@ -62,6 +67,9 @@ */ final class NotificationRouter { + record Result(Notification notification, Set ruleNames) { + } + private static final Logger LOGGER = LoggerFactory.getLogger(NotificationRouter.class.getName()); private final Handle jdbiHandle; @@ -88,7 +96,7 @@ final class NotificationRouter { .withRegistry(meterRegistry); } - List route(Collection notifications) { + List route(Collection notifications) { requireNonNull(notifications, "notifications must not be null"); if (notifications.isEmpty()) { return Collections.emptyList(); @@ -107,17 +115,17 @@ List route(Collection notifications) { return Collections.emptyList(); } - final var publishTasks = new ArrayList(rulesByNotification.size()); + final var results = new ArrayList(rulesByNotification.size()); for (final Map.Entry> entry : rulesByNotification.entrySet()) { final Notification notification = entry.getKey(); final List rules = entry.getValue(); try (var ignoredMdcScope = new MdcScope(Map.ofEntries( - Map.entry("notificationId", notification.getId()), - Map.entry("notificationScope", convert(notification.getScope()).name()), - Map.entry("notificationGroup", convert(notification.getGroup()).name()), - Map.entry("notificationLevel", convert(notification.getLevel()).name())))) { + Map.entry(MDC_NOTIFICATION_ID, notification.getId()), + Map.entry(MDC_NOTIFICATION_SCOPE, convert(notification.getScope()).name()), + Map.entry(MDC_NOTIFICATION_GROUP, convert(notification.getGroup()).name()), + Map.entry(MDC_NOTIFICATION_LEVEL, convert(notification.getLevel()).name())))) { final Timer.Sample ruleFilterLatencySample = Timer.start(); final List applicableRules; try { @@ -126,15 +134,19 @@ List route(Collection notifications) { ruleFilterLatencySample.stop(ruleFilterLatency); } + final var applicableRuleNames = new HashSet(applicableRules.size()); for (final RuleQueryResult rule : applicableRules) { - LOGGER.debug("Adding publish task for rule {}", rule.name()); rulesMatchedCounter.withTag("ruleName", rule.name()).increment(); - publishTasks.add(new NotificationPublishTask(rule.id(), rule.name(), notification)); + applicableRuleNames.add(rule.name()); + } + + if (!applicableRuleNames.isEmpty()) { + results.add(new Result(notification, applicableRuleNames)); } } } - return publishTasks; + return results; } public record RuleQueryResult( @@ -203,7 +215,7 @@ FROM UNNEST(:indexes, :scopes, :levels, :groups) AS t(index, scope, level, "group") INNER JOIN "NOTIFICATIONRULE" AS rule ON rule."SCOPE" = t.scope - AND rule."NOTIFY_ON" LIKE ('%' || t."group" || '%') + AND t."group" = ANY(rule."NOTIFY_ON") AND rule."NOTIFICATION_LEVEL" <= t.level WHERE rule."ENABLED" """); diff --git a/apiserver/src/main/java/org/dependencytrack/notification/NotificationRuleContactsSupplier.java b/apiserver/src/main/java/org/dependencytrack/notification/NotificationRuleContactsSupplier.java index 68c4234c16..60ad711eea 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/NotificationRuleContactsSupplier.java +++ b/apiserver/src/main/java/org/dependencytrack/notification/NotificationRuleContactsSupplier.java @@ -32,10 +32,10 @@ */ final class NotificationRuleContactsSupplier implements Supplier> { - private final long ruleId; + private final String ruleName; - NotificationRuleContactsSupplier(long ruleId) { - this.ruleId = ruleId; + NotificationRuleContactsSupplier(String ruleName) { + this.ruleName = ruleName; } @Override @@ -45,18 +45,20 @@ public Set get() { SELECT DISTINCT "USERNAME" , "EMAIL" - FROM "NOTIFICATIONRULE_TEAMS" AS nrt + FROM "NOTIFICATIONRULE" AS r + INNER JOIN "NOTIFICATIONRULE_TEAMS" AS nrt + ON nrt."NOTIFICATIONRULE_ID" = r."ID" INNER JOIN "TEAM" AS t ON t."ID" = nrt."TEAM_ID" INNER JOIN "USERS_TEAMS" AS ut ON ut."TEAM_ID" = t."ID" INNER JOIN "USER" AS u ON u."ID" = ut."USER_ID" - WHERE nrt."NOTIFICATIONRULE_ID" = :ruleId + WHERE r."NAME" = :ruleName """); return query - .bind("ruleId", ruleId) + .bind("ruleName", ruleName) .map(ConstructorMapper.of(NotificationRuleContact.class)) .set(); }); diff --git a/apiserver/src/main/java/org/dependencytrack/notification/NotificationSubsystemInitializer.java b/apiserver/src/main/java/org/dependencytrack/notification/NotificationSubsystemInitializer.java index a5f642714a..e3bc088fb4 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/NotificationSubsystemInitializer.java +++ b/apiserver/src/main/java/org/dependencytrack/notification/NotificationSubsystemInitializer.java @@ -19,15 +19,19 @@ package org.dependencytrack.notification; import io.micrometer.core.instrument.Metrics; +import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; -import org.dependencytrack.event.kafka.KafkaEventDispatcher; +import org.dependencytrack.dex.engine.api.DexEngine; +import org.dependencytrack.plugin.PluginManager; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static java.util.Objects.requireNonNull; + /** * @since 5.7.0 */ @@ -45,13 +49,23 @@ public void contextInitialized(ServletContextEvent event) { return; } + final ServletContext servletContext = event.getServletContext(); + + final var pluginManager = (PluginManager) servletContext.getAttribute(PluginManager.class.getName()); + requireNonNull(pluginManager, "pluginManager has not been initialized"); + + final var dexEngine = (DexEngine) servletContext.getAttribute(DexEngine.class.getName()); + requireNonNull(pluginManager, "dexEngine has not been initialized"); + LOGGER.info("Starting outbox relay"); relay = new NotificationOutboxRelay( - new KafkaEventDispatcher(), + dexEngine, + pluginManager, + handle -> new NotificationRouter(handle, Metrics.globalRegistry), Metrics.globalRegistry, - config.getValue("notification.router.enabled", boolean.class), config.getValue("notification.outbox-relay.poll-interval-ms", long.class), - config.getValue("notification.outbox-relay.batch-size", int.class)); + config.getValue("notification.outbox-relay.batch-size", int.class), + config.getValue("notification.outbox-relay.large-notification-threshold-bytes", int.class)); relay.start(); } diff --git a/apiserver/src/main/java/org/dependencytrack/notification/PublishNotificationActivity.java b/apiserver/src/main/java/org/dependencytrack/notification/PublishNotificationActivity.java new file mode 100644 index 0000000000..ce2bad1063 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/notification/PublishNotificationActivity.java @@ -0,0 +1,224 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.notification; + +import com.fasterxml.jackson.databind.JsonNode; +import org.dependencytrack.common.MdcScope; +import org.dependencytrack.dex.api.Activity; +import org.dependencytrack.dex.api.ActivityContext; +import org.dependencytrack.dex.api.ActivitySpec; +import org.dependencytrack.dex.api.failure.TerminalApplicationFailureException; +import org.dependencytrack.filestorage.api.FileStorage; +import org.dependencytrack.filestorage.proto.v1.FileMetadata; +import org.dependencytrack.notification.api.publishing.NotificationPublishContext; +import org.dependencytrack.notification.api.publishing.NotificationPublisher; +import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; +import org.dependencytrack.notification.api.publishing.RetryablePublishException; +import org.dependencytrack.notification.api.templating.NotificationTemplate; +import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.notification.templating.pebble.PebbleNotificationTemplateRendererFactory; +import org.dependencytrack.plugin.NoSuchExtensionException; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.plugin.api.config.InvalidRuntimeConfigException; +import org.dependencytrack.plugin.api.config.RuntimeConfig; +import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; +import org.dependencytrack.plugin.runtime.config.RuntimeConfigMapper; +import org.dependencytrack.plugin.runtime.config.UnresolvableSecretException; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationActivityArg; +import org.jdbi.v3.core.statement.Query; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.function.Function; + +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_ID; +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_RULE_NAME; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; + +/** + * @since 5.7.0 + */ +@ActivitySpec(name = "publish-notification", defaultTaskQueue = "notifications") +public final class PublishNotificationActivity implements Activity { + + private static final Logger LOGGER = LoggerFactory.getLogger(PublishNotificationActivity.class); + + private final PluginManager pluginManager; + private final RuntimeConfigMapper configMapper; + private final Function secretResolver; + private final PebbleNotificationTemplateRendererFactory notificationTemplateRendererFactory; + + public PublishNotificationActivity( + PluginManager pluginManager, + Function secretResolver, + PebbleNotificationTemplateRendererFactory notificationTemplateRendererFactory) { + this.pluginManager = pluginManager; + this.configMapper = RuntimeConfigMapper.getInstance(); + this.secretResolver = secretResolver; + this.notificationTemplateRendererFactory = notificationTemplateRendererFactory; + } + + @Override + public @Nullable Void execute( + ActivityContext ctx, + @Nullable PublishNotificationActivityArg argument) { + if (argument == null) { + throw new TerminalApplicationFailureException("No argument provided"); + } + + try (var ignored = new MdcScope(Map.ofEntries( + Map.entry(MDC_NOTIFICATION_ID, argument.getNotificationId()), + Map.entry(MDC_NOTIFICATION_RULE_NAME, argument.getNotificationRuleName())))) { + final RuleMetadata ruleMetadata = getRuleMetadata(argument.getNotificationRuleName()); + if (ruleMetadata == null) { + throw new TerminalApplicationFailureException( + "Notification rule '%s' does not exist".formatted(argument.getNotificationRuleName())); + } + + final NotificationPublisherFactory publisherFactory; + try { + publisherFactory = pluginManager.getFactory(NotificationPublisher.class, ruleMetadata.extensionName()); + } catch (NoSuchExtensionException e) { + throw new TerminalApplicationFailureException(e); + } + + final Notification notification = getNotification(argument); + + final var template = ruleMetadata.template() != null + ? new NotificationTemplate(ruleMetadata.template(), ruleMetadata.templateMimeType()) + : null; + + final var publishCtx = new NotificationPublishContext( + getRuleConfig(publisherFactory.ruleConfigSpec(), ruleMetadata.publisherConfig()), + new NotificationRuleContactsSupplier(argument.getNotificationRuleName()), + notificationTemplateRendererFactory.createRenderer(template)); + + LOGGER.debug("Publishing notification"); + try (final NotificationPublisher publisher = publisherFactory.create()) { + publisher.publish(publishCtx, notification); + } catch (RuntimeException | IOException e) { + if (e instanceof final RetryablePublishException rpe) { + LOGGER.debug("Failed to publish with retryable cause", e); + throw rpe; + } + + throw new TerminalApplicationFailureException( + "Failed to publish notification with non-retryable cause", e); + } + } + + return null; + } + + private record RuleMetadata( + String extensionName, + @Nullable String publisherConfig, + @Nullable String template, + @Nullable String templateMimeType) { + } + + private @Nullable RuleMetadata getRuleMetadata(String ruleName) { + return withJdbiHandle(handle -> { + final Query query = handle.createQuery(""" + SELECT p."EXTENSION_NAME" + , r."PUBLISHER_CONFIG" + , p."TEMPLATE" + , p."TEMPLATE_MIME_TYPE" + FROM "NOTIFICATIONRULE" AS r + INNER JOIN "NOTIFICATIONPUBLISHER" AS p + ON p."ID" = r."PUBLISHER" + WHERE r."NAME" = :ruleName + """); + + return query + .bind("ruleName", ruleName) + .map((rs, ctx) -> new RuleMetadata( + rs.getString(1), + rs.getString(2), + rs.getString(3), + rs.getString(4))) + .findOne() + .orElse(null); + }); + } + + private Notification getNotification(PublishNotificationActivityArg argument) { + if (argument.hasNotification()) { + return argument.getNotification(); + } else if (argument.hasNotificationFileMetadata()) { + final FileMetadata fileMetadata = argument.getNotificationFileMetadata(); + LOGGER.debug("Retrieving notification from {}", fileMetadata.getLocation()); + + try (final var fileStorage = pluginManager.getExtension(FileStorage.class, fileMetadata.getProviderName()); + final InputStream fileInputStream = fileStorage.get(argument.getNotificationFileMetadata())) { + return Notification.parseFrom(fileInputStream); + } catch (NoSuchExtensionException e) { + throw new TerminalApplicationFailureException(e); + } catch (FileNotFoundException e) { + throw new TerminalApplicationFailureException("Notification file not found", e); + } catch (IOException e) { + throw new UncheckedIOException("Failed to get notification file", e); + } + } + + throw new TerminalApplicationFailureException("No notification found"); + } + + private @Nullable RuntimeConfig getRuleConfig( + @Nullable RuntimeConfigSpec configSpec, + @Nullable String configJson) { + if (configSpec == null) { + // Publisher doesn't support rule-level configuration. + return null; + } + if (configJson == null) { + throw new TerminalApplicationFailureException(""" + Notification rule does not specify a publisher configuration, \ + but the publisher requires one"""); + } + + final RuntimeConfig config; + try { + final JsonNode configJsonNode = configMapper.validateJson(configJson, configSpec); + + configMapper.resolveSecretRefs(configJsonNode, configSpec, secretResolver); + + config = configMapper.convert(configJsonNode, configSpec.configClass()); + + if (configSpec.validator() != null) { + configSpec.validator().validate(config); + } + } catch (InvalidRuntimeConfigException e) { + throw new TerminalApplicationFailureException( + "Publisher configuration of the notification rule is invalid", e); + } catch (UnresolvableSecretException e) { + throw new TerminalApplicationFailureException( + "Publisher configuration references an unresolvable secret", e); + } + + return config; + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/notification/PublishNotificationWorkflow.java b/apiserver/src/main/java/org/dependencytrack/notification/PublishNotificationWorkflow.java new file mode 100644 index 0000000000..65b42511c9 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/notification/PublishNotificationWorkflow.java @@ -0,0 +1,144 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.notification; + +import org.dependencytrack.dex.activity.DeleteFilesActivity; +import org.dependencytrack.dex.api.ActivityCallOptions; +import org.dependencytrack.dex.api.Awaitable; +import org.dependencytrack.dex.api.RetryPolicy; +import org.dependencytrack.dex.api.Workflow; +import org.dependencytrack.dex.api.WorkflowContext; +import org.dependencytrack.dex.api.WorkflowSpec; +import org.dependencytrack.dex.api.failure.ActivityFailureException; +import org.dependencytrack.dex.api.failure.TerminalApplicationFailureException; +import org.dependencytrack.proto.internal.workflow.v1.DeleteFilesArgument; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationActivityArg; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationWorkflowArg; +import org.jspecify.annotations.Nullable; +import org.slf4j.MDC; + +import java.time.Duration; +import java.util.LinkedHashMap; + +import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_ID; + +/** + * @since 5.7.0 + */ +@WorkflowSpec(name = "publish-notification") +public final class PublishNotificationWorkflow implements Workflow { + + @Override + public @Nullable Void execute( + WorkflowContext ctx, + @Nullable PublishNotificationWorkflowArg arg) { + if (arg == null) { + throw new TerminalApplicationFailureException("No argument provided"); + } + if (!arg.hasNotification() && !arg.hasNotificationFileMetadata()) { + throw new TerminalApplicationFailureException( + "Neither notification nor notification file metadata provided"); + } + + try (var ignoredMdcNotificationId = MDC.putCloseable(MDC_NOTIFICATION_ID, arg.getNotificationId())) { + final var awaitableByRuleName = + new LinkedHashMap>(arg.getNotificationRuleNamesCount()); + + // Schedule publishing activities for all applicable notification rules concurrently. + for (final String ruleName : arg.getNotificationRuleNamesList()) { + final PublishNotificationActivityArg activityArg = createActivityArg(arg, ruleName); + + ctx.logger().debug("Scheduling publish for rule '{}'", ruleName); + final Awaitable awaitable = ctx + .activity(PublishNotificationActivity.class) + .call(new ActivityCallOptions() + .withArgument(activityArg)); + + awaitableByRuleName.put(ruleName, awaitable); + } + + // Wait for all scheduled activities to complete. + int activitiesFailed = 0; + for (final var entry : awaitableByRuleName.entrySet()) { + final String ruleName = entry.getKey(); + final Awaitable awaitable = entry.getValue(); + + ctx.logger().debug("Waiting for notification publishing to complete for rule '{}'", ruleName); + try { + awaitable.await(); + ctx.logger().debug("Successfully published notification for rule '{}'", ruleName); + } catch (ActivityFailureException e) { + ctx.logger().warn("Failed to publish notification for rule '{}'", ruleName, e.getCause()); + activitiesFailed++; + } + } + + maybeDeleteNotificationFile(ctx, arg); + + // Fail the workflow run only when *all* publishing activities failed. + if (activitiesFailed > 0 && activitiesFailed == awaitableByRuleName.size()) { + throw new TerminalApplicationFailureException( + "Publishing failed for all applicable rules"); + } + } + + return null; + } + + private PublishNotificationActivityArg createActivityArg( + PublishNotificationWorkflowArg arg, + String ruleName) { + final var activityArgBuilder = PublishNotificationActivityArg.newBuilder() + .setNotificationId(arg.getNotificationId()) + .setNotificationRuleName(ruleName); + + if (arg.hasNotification()) { + activityArgBuilder.setNotification(arg.getNotification()); + } else { + activityArgBuilder.setNotificationFileMetadata(arg.getNotificationFileMetadata()); + } + + return activityArgBuilder.build(); + } + + private void maybeDeleteNotificationFile( + WorkflowContext ctx, + PublishNotificationWorkflowArg argument) { + if (!argument.hasNotificationFileMetadata()) { + return; + } + + ctx.logger().debug("Scheduling notification file for deletion"); + + try { + ctx.activity(DeleteFilesActivity.class).call( + new ActivityCallOptions() + .withRetryPolicy(RetryPolicy.ofDefault() + .withInitialDelay(Duration.ofSeconds(1)) + .withMaxDelay(Duration.ofSeconds(10)) + .withMaxAttempts(3)) + .withArgument(DeleteFilesArgument.newBuilder() + .addFileMetadata(argument.getNotificationFileMetadata()) + .build())).await(); + } catch (ActivityFailureException e) { + ctx.logger().warn("Failed to delete notification file", e.getCause()); + } + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java b/apiserver/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java deleted file mode 100644 index af7c972f72..0000000000 --- a/apiserver/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * 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) OWASP Foundation. All Rights Reserved. - */ -package org.dependencytrack.notification.publisher; - -import jakarta.ws.rs.core.MediaType; -import org.dependencytrack.notification.api.publishing.NotificationPublisher; - -import static org.dependencytrack.notification.publisher.PublisherClass.ConsolePublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.CsWebexPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.JiraPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.MattermostPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.MsTeamsPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.SendMailPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.SlackPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.WebhookPublisher; - -/** - * @deprecated To be removed in favour of dynamically discovered {@link NotificationPublisher}s. - */ -@Deprecated(forRemoval = true, since = "5.7.0") -public enum DefaultNotificationPublishers { - - SLACK("Slack", "Publishes notifications to a Slack channel", SlackPublisher, "/org/dependencytrack/notification/publishing/slack/DefaultTemplate.peb", MediaType.APPLICATION_JSON, true), - MS_TEAMS("Microsoft Teams", "Publishes notifications to a Microsoft Teams channel", MsTeamsPublisher, "/org/dependencytrack/notification/publishing/msteams/DefaultTemplate.peb", MediaType.APPLICATION_JSON, true), - MATTERMOST("Mattermost", "Publishes notifications to a Mattermost channel", MattermostPublisher, "/org/dependencytrack/notification/publishing/mattermost/DefaultTemplate.peb", MediaType.APPLICATION_JSON, true), - EMAIL("Email", "Sends notifications to an email address", SendMailPublisher, "/org/dependencytrack/notification/publishing/email/DefaultTemplate.peb", MediaType.TEXT_PLAIN, true), - CONSOLE("Console", "Displays notifications on the system console", ConsolePublisher, "/org/dependencytrack/notification/publishing/console/DefaultTemplate.peb", MediaType.TEXT_PLAIN, true), - WEBHOOK("Outbound Webhook", "Publishes notifications to a configurable endpoint", WebhookPublisher, "/org/dependencytrack/notification/publishing/webhook/DefaultTemplate.peb", MediaType.APPLICATION_JSON, true), - CS_WEBEX("Cisco Webex", "Publishes notifications to a Cisco Webex Teams channel", CsWebexPublisher, "/org/dependencytrack/notification/publishing/webex/DefaultTemplate.peb", MediaType.APPLICATION_JSON, true), - JIRA("Jira", "Creates a Jira issue in a configurable Jira instance and queue", JiraPublisher, "/org/dependencytrack/notification/publishing/jira/DefaultTemplate.peb", MediaType.APPLICATION_JSON, true); - - private String name; - private String description; - private PublisherClass publisherClass; - private String templateFile; - private String templateMimeType; - private boolean defaultPublisher; - - DefaultNotificationPublishers(final String name, final String description, final PublisherClass publisherClass, - final String templateFile, final String templateMimeType, final boolean defaultPublisher) { - this.name = name; - this.description = description; - this.publisherClass = publisherClass; - this.templateFile = templateFile; - this.templateMimeType = templateMimeType; - this.defaultPublisher = defaultPublisher; - } - - public String getPublisherName() { - return name; - } - - public String getPublisherDescription() { - return description; - } - - public PublisherClass getPublisherClass() { - return publisherClass; - } - - public String getPublisherTemplateFile() { - return templateFile; - } - - public String getTemplateMimeType() { - return templateMimeType; - } - - public boolean isDefaultPublisher() { - return defaultPublisher; - } -} \ No newline at end of file diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/DatabaseSeedingInitTask.java b/apiserver/src/main/java/org/dependencytrack/persistence/DatabaseSeedingInitTask.java index 87bf747383..e8f8d9568f 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/DatabaseSeedingInitTask.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/DatabaseSeedingInitTask.java @@ -32,7 +32,6 @@ import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.DefaultRepository; import org.dependencytrack.model.License; -import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; import org.dependencytrack.parser.spdx.json.SpdxLicenseDetailParser; import org.dependencytrack.persistence.jdbi.ConfigPropertyDao; import org.dependencytrack.persistence.jdbi.JdbiFactory; @@ -44,7 +43,6 @@ import java.io.IOException; import java.io.InputStream; -import java.io.UncheckedIOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -144,7 +142,6 @@ public void execute(final InitTaskContext ctx) throws Exception { seedDefaultConfigProperties(handle); seedDefaultPermissions(handle); seedDefaultLicenses(handle); - seedDefaultNotificationPublishers(handle); seedDefaultRepositories(handle); final boolean isFirstExecution = defaultObjectsVersion == null; @@ -411,54 +408,6 @@ SELECT DISTINCT ON (group_name) .execute(); } - // TODO: - // * Move to a separate init task or ServletContextListener. - // * Replace DefaultNotificationPublishers enum with dynamic - // discovery of NotificationPublisher extensions. - public static void seedDefaultNotificationPublishers(final Handle jdbiHandle) { - final PreparedBatch preparedBatch = jdbiHandle.prepareBatch(""" - INSERT INTO "NOTIFICATIONPUBLISHER" ( - "NAME", "PUBLISHER_CLASS", "DEFAULT_PUBLISHER", "DESCRIPTION" - , "TEMPLATE", "TEMPLATE_MIME_TYPE", "UUID") - VALUES ( - :publisherName, :publisherClass, TRUE, :publisherDescription - , :templateContent, :templateMimeType, GEN_RANDOM_UUID()) - ON CONFLICT ("NAME") DO UPDATE - SET "PUBLISHER_CLASS" = EXCLUDED."PUBLISHER_CLASS" - , "DESCRIPTION" = EXCLUDED."DESCRIPTION" - , "TEMPLATE" = EXCLUDED."TEMPLATE" - , "TEMPLATE_MIME_TYPE" = EXCLUDED."TEMPLATE_MIME_TYPE" - -- Only update when at least one relevant field has changed. - WHERE "NOTIFICATIONPUBLISHER"."PUBLISHER_CLASS" IS DISTINCT FROM EXCLUDED."PUBLISHER_CLASS" - OR "NOTIFICATIONPUBLISHER"."DESCRIPTION" IS DISTINCT FROM EXCLUDED."DESCRIPTION" - OR "NOTIFICATIONPUBLISHER"."TEMPLATE" IS DISTINCT FROM EXCLUDED."TEMPLATE" - OR "NOTIFICATIONPUBLISHER"."TEMPLATE_MIME_TYPE" IS DISTINCT FROM EXCLUDED."TEMPLATE_MIME_TYPE" - """); - - for (final DefaultNotificationPublishers publisher : DefaultNotificationPublishers.values()) { - final String templateContent; - try (final InputStream inputStream = - DatabaseSeedingInitTask.class - .getResourceAsStream( - publisher.getPublisherTemplateFile())) { - if (inputStream == null) { - throw new IllegalStateException( - "Template file could not be found: " + publisher.getPublisherTemplateFile()); - } - templateContent = new String(inputStream.readAllBytes()); - } catch (IOException e) { - throw new UncheckedIOException("Failed to read template file", e); - } - - preparedBatch.bindBean(publisher); - preparedBatch.bind("templateContent", templateContent); - preparedBatch.add(); - } - - final int publishersCreatedOrUpdated = Arrays.stream(preparedBatch.execute()).sum(); - LOGGER.debug("Created or updated {} publishers", publishersCreatedOrUpdated); - } - public static void seedDefaultRepositories(final Handle jdbiHandle) { final PreparedBatch preparedBatch = jdbiHandle.prepareBatch(""" INSERT INTO "REPOSITORY"( diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java b/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java index a5b1943eb0..953b686ef8 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java @@ -18,18 +18,16 @@ */ package org.dependencytrack.persistence; -import alpine.model.Team; import alpine.persistence.PaginatedResult; import alpine.persistence.ScopedCustomization; import alpine.resources.AlpineRequest; import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; -import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; import org.dependencytrack.notification.NotificationLevel; import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.proto.v1.Notification; -import org.dependencytrack.notification.publisher.PublisherClass; +import org.jspecify.annotations.NonNull; import javax.jdo.PersistenceManager; import javax.jdo.Query; @@ -73,6 +71,7 @@ public class NotificationQueryManager extends QueryManager implements IQueryMana * @param publisher the publisher * @return a new NotificationRule */ + @Override public NotificationRule createNotificationRule(String name, NotificationScope scope, NotificationLevel level, NotificationPublisher publisher) { return callInTransaction(() -> { final NotificationRule rule = new NotificationRule(); @@ -92,6 +91,7 @@ public NotificationRule createNotificationRule(String name, NotificationScope sc * @param transientRule the rule to update * @return a NotificationRule */ + @Override public NotificationRule updateNotificationRule(NotificationRule transientRule) { return callInTransaction(() -> { final NotificationRule rule = getObjectByUuid(NotificationRule.class, transientRule.getUuid()); @@ -111,6 +111,7 @@ public NotificationRule updateNotificationRule(NotificationRule transientRule) { * Returns a paginated list of all notification rules. * @return a paginated list of NotificationRules */ + @Override public PaginatedResult getNotificationRules() { final Query query = pm.newQuery(NotificationRule.class); if (orderBy == null) { @@ -130,6 +131,7 @@ public PaginatedResult getNotificationRules() { * @return list of all NotificationPublisher objects */ @SuppressWarnings("unchecked") + @Override public List getAllNotificationPublishers() { final Query query = pm.newQuery(NotificationPublisher.class); query.getFetchPlan().addGroup(NotificationPublisher.FetchGroup.ALL.name()); @@ -142,38 +144,19 @@ public List getAllNotificationPublishers() { * @param name The name of the NotificationPublisher * @return a NotificationPublisher */ + @Override public NotificationPublisher getNotificationPublisher(final String name) { final Query query = pm.newQuery(NotificationPublisher.class, "name == :name"); query.setRange(0, 1); return singleResult(query.execute(name)); } - /** - * Retrieves a NotificationPublisher by its class. - * @param clazz The Class of the NotificationPublisher - * @return a NotificationPublisher - */ - public NotificationPublisher getDefaultNotificationPublisher(final PublisherClass clazz) { - return getDefaultNotificationPublisher(clazz.name()); - } - - /** - * Retrieves a NotificationPublisher by its class. - * @param clazz The Class of the NotificationPublisher - * @return a NotificationPublisher - */ - private NotificationPublisher getDefaultNotificationPublisher(final String clazz) { - final Query query = pm.newQuery(NotificationPublisher.class, "publisherClass == :publisherClass && defaultPublisher == true"); - query.getFetchPlan().addGroup(NotificationPublisher.FetchGroup.ALL.name()); - query.setRange(0, 1); - return singleResult(query.execute(clazz)); - } - /** * Retrieves a DefaultNotificationPublisher by its name. * @param name The name of the DefaultNotificationPublisher * @return a DefaultNotificationPublisher */ + @Override public NotificationPublisher getDefaultNotificationPublisherByName(final String name) { final Query query = pm.newQuery(NotificationPublisher.class, "name == :name && defaultPublisher == true"); query.getFetchPlan().addGroup(NotificationPublisher.FetchGroup.ALL.name()); @@ -186,14 +169,19 @@ public NotificationPublisher getDefaultNotificationPublisherByName(final String * @param name The name of the NotificationPublisher * @return a NotificationPublisher */ - public NotificationPublisher createNotificationPublisher(final String name, final String description, - final String publisherClass, final String templateContent, - final String templateMimeType, final boolean defaultPublisher) { + @Override + public NotificationPublisher createNotificationPublisher( + @NonNull String name, + String description, + @NonNull String extensionName, + String templateContent, + String templateMimeType, + boolean defaultPublisher) { return callInTransaction(() -> { final NotificationPublisher publisher = new NotificationPublisher(); publisher.setName(name); publisher.setDescription(description); - publisher.setPublisherClass(publisherClass); + publisher.setExtensionName(extensionName); publisher.setTemplate(templateContent); publisher.setTemplateMimeType(templateMimeType); publisher.setDefaultPublisher(defaultPublisher); @@ -205,17 +193,13 @@ public NotificationPublisher createNotificationPublisher(final String name, fina * Updates a NotificationPublisher. * @return a NotificationPublisher object */ + @Override public NotificationPublisher updateNotificationPublisher(NotificationPublisher transientPublisher) { - NotificationPublisher publisher = null; - if (transientPublisher.getId() > 0) { - publisher = getObjectById(NotificationPublisher.class, transientPublisher.getId()); - } else if (transientPublisher.isDefaultPublisher()) { - publisher = getDefaultNotificationPublisher(transientPublisher.getPublisherClass()); - } + final var publisher = getObjectById(NotificationPublisher.class, transientPublisher.getId()); if (publisher != null) { publisher.setName(transientPublisher.getName()); publisher.setDescription(transientPublisher.getDescription()); - publisher.setPublisherClass(transientPublisher.getPublisherClass()); + publisher.setExtensionName(transientPublisher.getExtensionName()); publisher.setTemplate(transientPublisher.getTemplate()); publisher.setTemplateMimeType(transientPublisher.getTemplateMimeType()); publisher.setDefaultPublisher(transientPublisher.isDefaultPublisher()); @@ -224,45 +208,6 @@ public NotificationPublisher updateNotificationPublisher(NotificationPublisher t return null; } - /** - * Removes projects from NotificationRules - */ - @SuppressWarnings("unchecked") - public void removeProjectFromNotificationRules(final Project project) { - final Query query = pm.newQuery(NotificationRule.class, "projects.contains(:project)"); - try { - for (final NotificationRule rule : (List) query.execute(project)) { - rule.getProjects().remove(project); - if (!pm.currentTransaction().isActive()) { - persist(rule); - } - } - } finally { - query.closeAll(); - } - } - - /** - * Removes teams from NotificationRules - */ - @SuppressWarnings("unchecked") - public void removeTeamFromNotificationRules(final Team team) { - final Query query = pm.newQuery(NotificationRule.class, "teams.contains(:team)"); - for (final NotificationRule rule: (List) query.execute(team)) { - rule.getTeams().remove(team); - persist(rule); - } - } - - /** - * Delete a notification publisher and associated rules. - */ - public void deleteNotificationPublisher(final NotificationPublisher notificationPublisher) { - final Query query = pm.newQuery(NotificationRule.class, "publisher.uuid == :uuid"); - query.deletePersistentAll(notificationPublisher.getUuid()); - delete(notificationPublisher); - } - /** * @since 4.12.3 */ @@ -336,6 +281,7 @@ public List getNotificationOutbox() { /** * @since 5.7.0 */ + @Override public void truncateNotificationOutbox() { try (var ignored = new ScopedCustomization(pm).withProperty(PROPERTY_QUERY_SQL_ALLOWALL, "true")) { final Query query = pm.newQuery(Query.SQL, /* language=SQL */ """ diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java b/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java index 7f3119b302..041d3b94da 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -85,13 +85,13 @@ import org.dependencytrack.notification.NotificationLevel; import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.proto.v1.Notification; -import org.dependencytrack.notification.publisher.PublisherClass; import org.dependencytrack.persistence.command.MakeAnalysisCommand; import org.dependencytrack.persistence.command.MakeViolationAnalysisCommand; import org.dependencytrack.persistence.jdbi.EffectivePermissionDao; import org.dependencytrack.persistence.jdbi.JdbiFactory; import org.dependencytrack.resources.v1.vo.DependencyGraphResponse; import org.dependencytrack.tasks.IntegrityMetaInitializerTask; +import org.jspecify.annotations.NonNull; import javax.jdo.FetchPlan; import javax.jdo.PersistenceManager; @@ -1042,36 +1042,25 @@ public NotificationPublisher getNotificationPublisher(final String name) { return getNotificationQueryManager().getNotificationPublisher(name); } - public NotificationPublisher getDefaultNotificationPublisher(final PublisherClass clazz) { - return getNotificationQueryManager().getDefaultNotificationPublisher(clazz); - } - public NotificationPublisher getDefaultNotificationPublisherByName(String publisherName) { return getNotificationQueryManager().getDefaultNotificationPublisherByName(publisherName); } - public NotificationPublisher createNotificationPublisher(final String name, final String description, - final String publisherClass, final String templateContent, - final String templateMimeType, final boolean defaultPublisher) { - return getNotificationQueryManager().createNotificationPublisher(name, description, publisherClass, templateContent, templateMimeType, defaultPublisher); + public NotificationPublisher createNotificationPublisher( + @NonNull String name, + String description, + @NonNull String extensionName, + String templateContent, + String templateMimeType, + boolean defaultPublisher) { + return getNotificationQueryManager().createNotificationPublisher( + name, description, extensionName, templateContent, templateMimeType, defaultPublisher); } public NotificationPublisher updateNotificationPublisher(NotificationPublisher transientPublisher) { return getNotificationQueryManager().updateNotificationPublisher(transientPublisher); } - public void deleteNotificationPublisher(NotificationPublisher notificationPublisher) { - getNotificationQueryManager().deleteNotificationPublisher(notificationPublisher); - } - - public void removeProjectFromNotificationRules(final Project project) { - getNotificationQueryManager().removeProjectFromNotificationRules(project); - } - - public void removeTeamFromNotificationRules(final Team team) { - getNotificationQueryManager().removeTeamFromNotificationRules(team); - } - /** * Determines if a config property is enabled or not. * diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java index 220866c476..a7f92d8b45 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java @@ -26,12 +26,15 @@ import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Validator; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -47,12 +50,18 @@ import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.validation.ValidUuid; import org.dependencytrack.notification.JdoNotificationEmitter; -import org.dependencytrack.notification.publisher.PublisherClass; +import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.NoSuchExtensionException; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; +import org.dependencytrack.resources.v1.vo.CreateNotificationPublisherRequest; +import org.dependencytrack.resources.v1.vo.UpdateNotificationPublisherRequest; +import org.owasp.security.logging.SecurityMarkers; -import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.stream.Stream; import static org.dependencytrack.notification.NotificationModelConverter.convert; @@ -77,6 +86,13 @@ public class NotificationPublisherResource extends AlpineResource { private static final Logger LOGGER = Logger.getLogger(NotificationPublisherResource.class); + private final PluginManager pluginManager; + + @Inject + NotificationPublisherResource(PluginManager pluginManager) { + this.pluginManager = pluginManager; + } + @GET @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Returns a list of all notification publishers", @@ -90,7 +106,10 @@ public class NotificationPublisherResource extends AlpineResource { ), @ApiResponse(responseCode = "401", description = "Unauthorized") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_READ}) + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_READ + }) public Response getAllNotificationPublishers() { try (QueryManager qm = new QueryManager()) { final List publishers = qm.getAllNotificationPublishers(); @@ -115,44 +134,37 @@ public Response getAllNotificationPublishers() { @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "409", description = "Conflict with an existing publisher's name") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_CREATE}) - public Response createNotificationPublisher(NotificationPublisher jsonNotificationPublisher) { - final Validator validator = super.getValidator(); - failOnValidationError( - validator.validateProperty(jsonNotificationPublisher, "name"), - validator.validateProperty(jsonNotificationPublisher, "publisherClass"), - validator.validateProperty(jsonNotificationPublisher, "description"), - validator.validateProperty(jsonNotificationPublisher, "templateMimeType"), - validator.validateProperty(jsonNotificationPublisher, "template") - ); - - // TODO: - // * Rename publisherClass -> publisherExtensionName. - // * Validate publisher extension exists. + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_CREATE + }) + public Response createNotificationPublisher(@Valid CreateNotificationPublisherRequest request) { + requireExtensionExists(request.extensionName()); - try (QueryManager qm = new QueryManager()) { - return qm.callInTransaction(() -> { - NotificationPublisher existingNotificationPublisher = qm.getNotificationPublisher(jsonNotificationPublisher.getName()); - if (existingNotificationPublisher != null) { - return Response.status(Response.Status.CONFLICT).entity("The notification with the name " + jsonNotificationPublisher.getName() + " already exist").build(); + final NotificationPublisher createdPublisher; + try (final var qm = new QueryManager(getAlpineRequest())) { + createdPublisher = qm.callInTransaction(() -> { + final NotificationPublisher existingPublisher = qm.getNotificationPublisher(request.name()); + if (existingPublisher != null) { + throw new ClientErrorException(Response + .status(Response.Status.CONFLICT) + .entity("The notification with the name " + request.name() + " already exist") + .build()); } - if (jsonNotificationPublisher.isDefaultPublisher()) { - return Response.status(Response.Status.BAD_REQUEST).entity("The creation of a new default publisher is forbidden").build(); - } - if (Arrays.stream(PublisherClass.values()).anyMatch(clazz -> - clazz.name().equalsIgnoreCase(jsonNotificationPublisher.getPublisherClass()))) { - NotificationPublisher notificationPublisherCreated = qm.createNotificationPublisher( - jsonNotificationPublisher.getName(), jsonNotificationPublisher.getDescription(), - jsonNotificationPublisher.getPublisherClass(), jsonNotificationPublisher.getTemplate(), jsonNotificationPublisher.getTemplateMimeType(), - jsonNotificationPublisher.isDefaultPublisher() - ); - return Response.status(Response.Status.CREATED).entity(notificationPublisherCreated).build(); - } else { - return Response.status(Response.Status.BAD_REQUEST).entity("The publisher class " + jsonNotificationPublisher.getPublisherClass() + " is not valid.").build(); - } + return qm.createNotificationPublisher( + request.name(), + request.description(), + request.extensionName(), + request.template(), + request.templateMimeType(), + /* defaultPublisher */ false); }); } + + LOGGER.info(SecurityMarkers.SECURITY_AUDIT, "Created notification publisher '{}'", createdPublisher.getName()); + + return Response.status(Response.Status.CREATED).entity(createdPublisher).build(); } @POST @@ -173,55 +185,51 @@ public Response createNotificationPublisher(NotificationPublisher jsonNotificati @ApiResponse(responseCode = "404", description = "The notification publisher could not be found"), @ApiResponse(responseCode = "409", description = "Conflict with an existing publisher's name") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE}) - public Response updateNotificationPublisher(NotificationPublisher jsonNotificationPublisher) { - final Validator validator = super.getValidator(); - failOnValidationError( - validator.validateProperty(jsonNotificationPublisher, "name"), - validator.validateProperty(jsonNotificationPublisher, "publisherClass"), - validator.validateProperty(jsonNotificationPublisher, "description"), - validator.validateProperty(jsonNotificationPublisher, "templateMimeType"), - validator.validateProperty(jsonNotificationPublisher, "template"), - validator.validateProperty(jsonNotificationPublisher, "uuid") - ); - - // TODO: - // * Rename publisherClass -> publisherExtensionName. - // * Validate publisher extension exists. - - try (QueryManager qm = new QueryManager()) { - return qm.callInTransaction(() -> { - NotificationPublisher existingPublisher = qm.getObjectByUuid(NotificationPublisher.class, jsonNotificationPublisher.getUuid()); - if (existingPublisher != null) { - if (existingPublisher.isDefaultPublisher()) { - return Response.status(Response.Status.BAD_REQUEST).entity("The modification of a default publisher is forbidden").build(); - } + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE + }) + public Response updateNotificationPublisher(UpdateNotificationPublisherRequest request) { + requireExtensionExists(request.extensionName()); - if (!jsonNotificationPublisher.getName().equals(existingPublisher.getName())) { - NotificationPublisher existingNotificationPublisherWithModifiedName = qm.getNotificationPublisher(jsonNotificationPublisher.getName()); - if (existingNotificationPublisherWithModifiedName != null) { - return Response.status(Response.Status.CONFLICT).entity("An existing publisher with the name '" + existingNotificationPublisherWithModifiedName.getName() + "' already exist").build(); - } - } - existingPublisher.setName(jsonNotificationPublisher.getName()); - existingPublisher.setDescription(jsonNotificationPublisher.getDescription()); + final NotificationPublisher updatedPublisher; + try (final var qm = new QueryManager(getAlpineRequest())) { + updatedPublisher = qm.callInTransaction(() -> { + final var publisher = qm.getObjectByUuid(NotificationPublisher.class, request.uuid()); + if (publisher == null) { + throw new ClientErrorException(Response + .status(Response.Status.NOT_FOUND) + .entity("The UUID of the notification publisher could not be found.") + .build()); + } + if (publisher.isDefaultPublisher()) { + throw new ClientErrorException(Response + .status(Response.Status.BAD_REQUEST) + .entity("The modification of a default publisher is forbidden") + .build()); + } - if (Arrays.stream(PublisherClass.values()).anyMatch(clazz -> - clazz.name().equalsIgnoreCase(jsonNotificationPublisher.getPublisherClass()))) { - existingPublisher.setPublisherClass(jsonNotificationPublisher.getPublisherClass()); - } else { - return Response.status(Response.Status.BAD_REQUEST).entity("The publisher class " + jsonNotificationPublisher.getPublisherClass() + " is not valid.").build(); + if (!request.name().equals(publisher.getName())) { + final NotificationPublisher conflictingPublisher = qm.getNotificationPublisher(request.name()); + if (conflictingPublisher != null) { + throw new ClientErrorException(Response + .status(Response.Status.CONFLICT) + .entity("An existing publisher with the name '" + conflictingPublisher.getName() + "' already exist") + .build()); } - existingPublisher.setTemplate(jsonNotificationPublisher.getTemplate()); - existingPublisher.setTemplateMimeType(jsonNotificationPublisher.getTemplateMimeType()); - existingPublisher.setDefaultPublisher(false); - NotificationPublisher notificationPublisherUpdated = qm.updateNotificationPublisher(existingPublisher); - return Response.ok(notificationPublisherUpdated).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the notification publisher could not be found.").build(); } + publisher.setName(request.name()); + publisher.setDescription(request.description()); + publisher.setExtensionName(request.extensionName()); + publisher.setTemplate(request.template()); + publisher.setTemplateMimeType(request.templateMimeType()); + return publisher; }); } + + LOGGER.info(SecurityMarkers.SECURITY_AUDIT, "Updated notification publisher '{}'", updatedPublisher.getName()); + + return Response.ok(updatedPublisher).build(); } @DELETE @@ -238,18 +246,21 @@ public Response updateNotificationPublisher(NotificationPublisher jsonNotificati @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The UUID of the notification publisher could not be found") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, - Permissions.Constants.SYSTEM_CONFIGURATION_DELETE}) - public Response deleteNotificationPublisher(@Parameter(description = "The UUID of the notification publisher to delete", schema = @Schema(type = "string", format = "uuid"), required = true) - @PathParam("notificationPublisherUuid") @ValidUuid String notificationPublisherUuid) { - try (QueryManager qm = new QueryManager()) { + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_DELETE + }) + public Response deleteNotificationPublisher( + @Parameter(description = "The UUID of the notification publisher to delete", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("notificationPublisherUuid") @ValidUuid String notificationPublisherUuid) { + try (final var qm = new QueryManager(getAlpineRequest())) { return qm.callInTransaction(() -> { final NotificationPublisher notificationPublisher = qm.getObjectByUuid(NotificationPublisher.class, notificationPublisherUuid); if (notificationPublisher != null) { if (notificationPublisher.isDefaultPublisher()) { return Response.status(Response.Status.BAD_REQUEST).entity("Deleting a default notification publisher is forbidden.").build(); } else { - qm.deleteNotificationPublisher(notificationPublisher); + qm.delete(notificationPublisher); return Response.status(Response.Status.NO_CONTENT).build(); } } else { @@ -259,6 +270,51 @@ public Response deleteNotificationPublisher(@Parameter(description = "The UUID o } } + @GET + @Path("/{uuid}/configSchema") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Get notification publisher config schema", + description = "

Requires permission SYSTEM_CONFIGURATION or SYSTEM_CONFIGURATION_READ

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Publisher config JSON schema", + content = @Content(schema = @Schema(additionalProperties = AdditionalPropertiesValue.TRUE))), + @ApiResponse(responseCode = "204", description = "Publisher has no configuration"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The UUID of the notification publisher could not be found") + }) + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_READ + }) + public Response getNotificationPublisherConfigSchema(@PathParam("uuid") UUID uuid) { + final String extensionName; + try (final var qm = new QueryManager(getAlpineRequest())) { + final var publisher = qm.getObjectByUuid(NotificationPublisher.class, uuid); + if (publisher != null) { + extensionName = publisher.getExtensionName(); + } else { + throw new ClientErrorException(Response + .status(Response.Status.NOT_FOUND) + .entity("The UUID of the notification publisher could not be found.") + .build()); + } + } + + final NotificationPublisherFactory extensionFactory = + requireExtensionExists(extensionName); + + final RuntimeConfigSpec ruleConfigSpec = extensionFactory.ruleConfigSpec(); + if (ruleConfigSpec == null) { + return Response.noContent().build(); + } + + return Response.ok(ruleConfigSpec.schema()).build(); + } + @POST @Path("/test/{uuid}") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @@ -276,7 +332,7 @@ public Response deleteNotificationPublisher(@Parameter(description = "The UUID o public Response testNotificationRule( @Parameter(description = "The UUID of the rule to test", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("uuid") @ValidUuid String ruleUuid) { - try (QueryManager qm = new QueryManager()) { + try (final var qm = new QueryManager(getAlpineRequest())) { return qm.callInTransaction(() -> { NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, ruleUuid); if (rule == null) { @@ -315,4 +371,18 @@ public Response testNotificationRule( return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("Exception occured while sending the notification.").build(); } } + + private NotificationPublisherFactory requireExtensionExists(String extensionName) { + try { + return pluginManager.getFactory( + org.dependencytrack.notification.api.publishing.NotificationPublisher.class, + extensionName); + } catch (NoSuchExtensionException e) { + throw new ClientErrorException(Response + .status(Response.Status.BAD_REQUEST) + .entity("No extension with name '%s' exists".formatted(extensionName)) + .build()); + } + } + } \ No newline at end of file diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java index 1a22b00ee6..792485525c 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java @@ -21,6 +21,7 @@ import alpine.model.Team; import alpine.persistence.PaginatedResult; import alpine.server.auth.PermissionRequired; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.headers.Header; @@ -32,7 +33,9 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Validator; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -43,23 +46,31 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.apache.commons.lang3.StringUtils; import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Project; import org.dependencytrack.model.validation.ValidUuid; import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.plugin.api.config.InvalidRuntimeConfigException; +import org.dependencytrack.plugin.api.config.RuntimeConfig; +import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; +import org.dependencytrack.plugin.runtime.config.RuntimeConfigMapper; import org.dependencytrack.resources.AbstractApiResource; import org.dependencytrack.resources.v1.openapi.PaginatedApi; import org.dependencytrack.resources.v1.problems.ProblemDetails; +import org.dependencytrack.resources.v1.vo.CreateNotificationRuleRequest; +import org.dependencytrack.resources.v1.vo.UpdateNotificationRuleRequest; +import org.owasp.security.logging.SecurityMarkers; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.List; import java.util.Set; -import static org.dependencytrack.notification.publisher.PublisherClass.SendMailPublisher; - /** * JAX-RS resources for processing notification rules. * @@ -74,6 +85,17 @@ }) public class NotificationRuleResource extends AbstractApiResource { + private static final Logger LOGGER = LoggerFactory.getLogger(NotificationRuleResource.class); + + private final PluginManager pluginManager; + private final RuntimeConfigMapper configMapper; + + @Inject + NotificationRuleResource(PluginManager pluginManager) { + this.pluginManager = pluginManager; + this.configMapper = RuntimeConfigMapper.getInstance(); + } + @GET @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Returns a list of all notification rules", @@ -89,9 +111,12 @@ public class NotificationRuleResource extends AbstractApiResource { ), @ApiResponse(responseCode = "401", description = "Unauthorized") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_READ}) + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_READ + }) public Response getAllNotificationRules() { - try (QueryManager qm = new QueryManager(getAlpineRequest())) { + try (final var qm = new QueryManager(getAlpineRequest())) { final PaginatedResult result = qm.getNotificationRules(); return Response.ok(result.getObjects()).header(TOTAL_COUNT_HEADER, result.getTotal()).build(); } @@ -112,29 +137,53 @@ public Response getAllNotificationRules() { @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The UUID of the notification publisher could not be found") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_CREATE}) - public Response createNotificationRule(NotificationRule jsonRule) { - final Validator validator = super.getValidator(); - failOnValidationError( - validator.validateProperty(jsonRule, "name") - ); + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_CREATE + }) + public Response createNotificationRule(@Valid CreateNotificationRuleRequest request) { + final NotificationRule createdRule; + try (final var qm = new QueryManager(getAlpineRequest())) { + createdRule = qm.callInTransaction(() -> { + NotificationPublisher publisher = null; + if (request.publisher() != null) { + publisher = qm.getObjectByUuid(NotificationPublisher.class, request.publisher().uuid()); + } + if (publisher == null) { + throw new ClientErrorException(Response + .status(Response.Status.NOT_FOUND) + .entity("The UUID of the notification publisher could not be found.") + .build()); + } - try (QueryManager qm = new QueryManager()) { - NotificationPublisher publisher = null; - if (jsonRule.getPublisher() != null) { - publisher = qm.getObjectByUuid(NotificationPublisher.class, jsonRule.getPublisher().getUuid()); - } - if (publisher == null) { - return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the notification publisher could not be found.").build(); - } - final NotificationRule rule = qm.createNotificationRule( - StringUtils.trimToNull(jsonRule.getName()), - jsonRule.getScope(), - jsonRule.getNotificationLevel(), - publisher - ); - return Response.status(Response.Status.CREATED).entity(rule).build(); + final NotificationPublisherFactory extensionFactory = pluginManager.getFactory( + org.dependencytrack.notification.api.publishing.NotificationPublisher.class, + publisher.getExtensionName()); + + final NotificationRule rule = qm.createNotificationRule( + request.name(), + request.scope(), + request.level(), + publisher); + + final RuntimeConfigSpec ruleConfigSpec = extensionFactory.ruleConfigSpec(); + if (ruleConfigSpec != null) { + final String defaultRuleConfigJson = + RuntimeConfigMapper.getInstance() + .serialize(ruleConfigSpec.defaultConfig()); + rule.setPublisherConfig(defaultRuleConfigJson); + } + + return rule; + }); } + + LOGGER.info(SecurityMarkers.SECURITY_AUDIT, "Created notification rule '{}'", createdRule.getName()); + + return Response + .status(Response.Status.CREATED) + .entity(createdRule) + .build(); } @POST @@ -152,26 +201,75 @@ public Response createNotificationRule(NotificationRule jsonRule) { @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The UUID of the notification rule could not be found") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE}) - public Response updateNotificationRule(NotificationRule jsonRule) { - final Validator validator = super.getValidator(); - failOnValidationError( - validator.validateProperty(jsonRule, "name"), - validator.validateProperty(jsonRule, "publisherConfig") - ); + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE + }) + public Response updateNotificationRule(UpdateNotificationRuleRequest request) { + final NotificationRule updatedRule; + try (final var qm = new QueryManager(getAlpineRequest())) { + updatedRule = qm.callInTransaction(() -> { + var rule = qm.getObjectByUuid(NotificationRule.class, request.uuid()); + if (rule == null) { + throw new ClientErrorException(Response + .status(Response.Status.NOT_FOUND) + .entity("The UUID of the notification rule could not be found.") + .build()); + } - // TODO: Validate publisherConfig against the JSON schema of the applicable NotificationPublisher extension. + final NotificationPublisherFactory publisherFactory = pluginManager.getFactory( + org.dependencytrack.notification.api.publishing.NotificationPublisher.class, + rule.getPublisher().getExtensionName()); + + final RuntimeConfigSpec ruleConfigSpec = publisherFactory.ruleConfigSpec(); + if (ruleConfigSpec == null) { + if (request.publisherConfig() != null) { + throw new ClientErrorException(Response + .status(Response.Status.BAD_REQUEST) + .entity("The publisher does not support configuration.") + .build()); + } + } else { + if (request.publisherConfig() == null) { + throw new ClientErrorException(Response + .status(Response.Status.BAD_REQUEST) + .entity("The publisher requires configuration, but none was provided.") + .build()); + } - try (QueryManager qm = new QueryManager()) { - NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, jsonRule.getUuid()); - if (rule != null) { - jsonRule.setName(StringUtils.trimToNull(jsonRule.getName())); - rule = qm.updateNotificationRule(jsonRule); - return Response.ok(rule).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the notification rule could not be found.").build(); - } + try { + final JsonNode ruleConfigNode = configMapper.validateJson(request.publisherConfig(), ruleConfigSpec); + final RuntimeConfig ruleConfig = configMapper.convert(ruleConfigNode, ruleConfigSpec.configClass()); + if (ruleConfigSpec.validator() != null) { + ruleConfigSpec.validator().validate(ruleConfig); + } + } catch (InvalidRuntimeConfigException e) { + throw new ClientErrorException(Response + .status(Response.Status.BAD_REQUEST) + .entity("Invalid publisher configuration: " + e.getMessage()) + .build()); + } + } + + final var transientRule = new NotificationRule(); + transientRule.setName(request.name()); + transientRule.setEnabled(request.enabled()); + transientRule.setNotifyChildren(request.notifyChildren()); + transientRule.setLogSuccessfulPublish(request.logSuccessfulPublish()); + transientRule.setScope(request.scope()); + transientRule.setNotificationLevel(request.level()); + transientRule.setNotifyOn(request.notifyOn()); + transientRule.setPublisherConfig(request.publisherConfig()); + transientRule.setTags(request.tags()); + transientRule.setUuid(request.uuid()); + + return qm.updateNotificationRule(transientRule); + }); } + + LOGGER.info(SecurityMarkers.SECURITY_AUDIT, "Updated notification rule '{}'", updatedRule.getName()); + + return Response.ok(updatedRule).build(); } @DELETE @@ -185,9 +283,12 @@ public Response updateNotificationRule(NotificationRule jsonRule) { @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The UUID of the notification rule could not be found") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_DELETE}) + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_DELETE + }) public Response deleteNotificationRule(NotificationRule jsonRule) { - try (QueryManager qm = new QueryManager()) { + try (final var qm = new QueryManager(getAlpineRequest())) { return qm.callInTransaction(() -> { final NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, jsonRule.getUuid()); if (rule != null) { @@ -221,13 +322,16 @@ public Response deleteNotificationRule(NotificationRule jsonRule) { content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON)), @ApiResponse(responseCode = "404", description = "The notification rule or project could not be found") }) - @PermissionRequired({Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE}) + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE + }) public Response addProjectToRule( @Parameter(description = "The UUID of the rule to add a project to", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("ruleUuid") @ValidUuid String ruleUuid, @Parameter(description = "The UUID of the project to add to the rule", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("projectUuid") @ValidUuid String projectUuid) { - try (QueryManager qm = new QueryManager()) { + try (final var qm = new QueryManager(getAlpineRequest())) { return qm.callInTransaction(() -> { final NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, ruleUuid); if (rule == null) { @@ -279,7 +383,7 @@ public Response removeProjectFromRule( @PathParam("ruleUuid") @ValidUuid String ruleUuid, @Parameter(description = "The UUID of the project to remove from the rule", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("projectUuid") @ValidUuid String projectUuid) { - try (QueryManager qm = new QueryManager()) { + try (final var qm = new QueryManager(getAlpineRequest())) { return qm.callInTransaction(() -> { final NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, ruleUuid); if (rule == null) { @@ -327,15 +431,12 @@ public Response addTeamToRule( @PathParam("ruleUuid") @ValidUuid String ruleUuid, @Parameter(description = "The UUID of the team to add to the rule", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("teamUuid") @ValidUuid String teamUuid) { - try (QueryManager qm = new QueryManager()) { + try (final var qm = new QueryManager(getAlpineRequest())) { return qm.callInTransaction(() -> { final NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, ruleUuid); if (rule == null) { return Response.status(Response.Status.NOT_FOUND).entity("The notification rule could not be found.").build(); } - if (!rule.getPublisher().getPublisherClass().equals(SendMailPublisher.name())) { - return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on notification rules with EMAIL publisher.").build(); - } final Team team = qm.getObjectByUuid(Team.class, teamUuid); if (team == null) { return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build(); @@ -374,15 +475,12 @@ public Response removeTeamFromRule( @PathParam("ruleUuid") @ValidUuid String ruleUuid, @Parameter(description = "The UUID of the project to remove from the rule", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("teamUuid") @ValidUuid String teamUuid) { - try (QueryManager qm = new QueryManager()) { + try (final var qm = new QueryManager(getAlpineRequest())) { return qm.callInTransaction(() -> { final NotificationRule rule = qm.getObjectByUuid(NotificationRule.class, ruleUuid); if (rule == null) { return Response.status(Response.Status.NOT_FOUND).entity("The notification rule could not be found.").build(); } - if (!rule.getPublisher().getPublisherClass().equals(SendMailPublisher.name())) { - return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on notification rules with EMAIL publisher.").build(); - } final Team team = qm.getObjectByUuid(Team.class, teamUuid); if (team == null) { return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build(); diff --git a/apiserver/src/main/java/org/dependencytrack/notification/NotificationPublishTask.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/CreateNotificationPublisherRequest.java similarity index 61% rename from apiserver/src/main/java/org/dependencytrack/notification/NotificationPublishTask.java rename to apiserver/src/main/java/org/dependencytrack/resources/v1/vo/CreateNotificationPublisherRequest.java index 6dc75d1698..9d975a2831 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/NotificationPublishTask.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/CreateNotificationPublisherRequest.java @@ -16,20 +16,19 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.notification; +package org.dependencytrack.resources.v1.vo; -import org.dependencytrack.notification.proto.v1.Notification; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; /** - * Unit of work for publishing a notification. - * - * @param ruleId ID of the applicable notification rule. - * @param ruleName Name of the applicable notification rule. - * @param notification The notification to publish. * @since 5.7.0 */ -record NotificationPublishTask( - long ruleId, - String ruleName, - Notification notification) { +@JsonIgnoreProperties(ignoreUnknown = true) +public record CreateNotificationPublisherRequest( + @NotBlank String name, + @NotBlank String extensionName, + String description, + String template, + String templateMimeType) { } diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/CreateNotificationRuleRequest.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/CreateNotificationRuleRequest.java new file mode 100644 index 0000000000..0cf8a351d2 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/CreateNotificationRuleRequest.java @@ -0,0 +1,44 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.dependencytrack.notification.NotificationLevel; +import org.dependencytrack.notification.NotificationScope; + +import java.util.UUID; + +/** + * @since 5.7.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record CreateNotificationRuleRequest( + @NotBlank String name, + @NotNull NotificationScope scope, + @JsonAlias("notificationLevel") @NotNull NotificationLevel level, + @NotNull @Valid Publisher publisher) { + + public record Publisher(@NotNull UUID uuid) { + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationPublisherRequest.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationPublisherRequest.java new file mode 100644 index 0000000000..54a6cc3411 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationPublisherRequest.java @@ -0,0 +1,38 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +/** + * @since 5.7.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record UpdateNotificationPublisherRequest( + @NotBlank String name, + @NotBlank String extensionName, + String description, + String template, + String templateMimeType, + @NotNull UUID uuid) { +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationRuleRequest.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationRuleRequest.java new file mode 100644 index 0000000000..c5cfa54b70 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationRuleRequest.java @@ -0,0 +1,48 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.dependencytrack.model.Tag; +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationLevel; +import org.dependencytrack.notification.NotificationScope; + +import java.util.Set; +import java.util.UUID; + +/** + * @since 5.7.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record UpdateNotificationRuleRequest( + @NotBlank String name, + boolean enabled, + boolean notifyChildren, + boolean logSuccessfulPublish, + @NotNull NotificationScope scope, + @JsonAlias("notificationLevel") @NotNull NotificationLevel level, + Set<@NotNull NotificationGroup> notifyOn, + String publisherConfig, + Set tags, + @NotNull UUID uuid) { +} diff --git a/apiserver/src/main/resources/application-dev.properties b/apiserver/src/main/resources/application-dev.properties index f45eac58d0..a88d2e4909 100644 --- a/apiserver/src/main/resources/application-dev.properties +++ b/apiserver/src/main/resources/application-dev.properties @@ -2,4 +2,6 @@ alpine.bcrypt.rounds=4 dt.datasource.url=jdbc:postgresql://localhost:5432/dtrack dt.datasource.username=dtrack dt.datasource.password=dtrack -dt.metrics.enabled=true \ No newline at end of file +dt.metrics.enabled=true +dt.notification-publisher.email.allow-local-connections=true +dt.notification-publisher.kafka.allow-local-connections=true \ No newline at end of file diff --git a/apiserver/src/main/resources/application.properties b/apiserver/src/main/resources/application.properties index 8f1465e1e7..df1f09eba4 100644 --- a/apiserver/src/main/resources/application.properties +++ b/apiserver/src/main/resources/application.properties @@ -703,23 +703,15 @@ notification.outbox-relay.poll-interval-ms=1000 # @required notification.outbox-relay.batch-size=100 -# Defines whether the notification router should be enabled. -# -# The router currently only evaluates rules against emitted notifications, -# but does not influence which notifications are sent to Kafka. -# -# Enabling it is only useful to monitor if and how much it impacts relay performance. -# For that purpose, the following Prometheus metrics may be used: -#
    -#
  • dtrack_notification_router_rule_query_latency
  • -#
  • dtrack_notification_router_rule_filter_latency
  • -#
  • dtrack_notification_router_rules_matched
  • -#
+# Defines the size in bytes at which notifications are considered "large". +#

+# Large notifications will be offloaded to file storage before +# being sent to the dex engine for publishing. # # @category: Notification -# @type: boolean +# @type: integer # @required -notification.router.enabled=false +notification.outbox-relay.large-notification-threshold-bytes=65536 # @category: Kafka # @example: localhost:9092 @@ -1810,6 +1802,72 @@ dt.file-storage.s3.enabled=false # @valid-values: [-7..22] # dt.file-storage.s3.compression.level= +# Defines whether the console notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.console.enabled=true + +# Defines whether the email notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.email.enabled=true + +# Defines whether the email notification publisher is allowed to connect to local hosts. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.email.allow-local-connections=false + +# Defines whether the Jira notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.jira.enabled=true + +# Defines whether the Kafka notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.kafka.enabled=true + +# Defines whether the Kafka notification publisher is allowed to connect to local hosts. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.kafka.allow-local-connections=false + +# Defines whether the Mattermost notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.mattermost.enabled=true + +# Defines whether the Microsoft Teams notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.msteams.enabled=true + +# Defines whether the Slack notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.slack.enabled=true + +# Defines whether the WebEx notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.webex.enabled=true + +# Defines whether the Webhook notification publisher is enabled. +# +# @category: Notification +# @type: boolean +# dt.notification-publisher.webhook.enabled=true + # Defines the name of the data source to be used by the durable execution engine. # # For larger deployments, it is recommended to use a separate, @@ -1853,6 +1911,60 @@ dt.dex-engine.workflow-task-scheduler.poll-interval-ms=100 # @type: integer dt.dex-engine.activity-task-scheduler.poll-interval-ms=100 +# Defines whether the default workflow worker should be enabled. +# +# @category: Durable Execution +# @type: boolean +# dt.dex-engine.workflow-worker.default.enabled=true + +# @hidden +dt.dex-engine.workflow-worker.default.queue-name=default + +# Defines the maximum concurrency of the default workflow worker. +#

+# Note that workflow workers do not perform any I/O (although they +# may block while waiting for semaphores and buffer flushes), +# and are executed with virtual threads. This means that it's +# usually perfectly fine to have a high degree of concurrency, +# without risking excessive resource usage or I/O thrashing. +# +# @category: Durable Execution +# @type: integer +# @required +dt.dex-engine.workflow-worker.default.max-concurrency=100 + +# Defines whether the default activity worker should be enabled. +# +# @category: Durable Execution +# @type: boolean +# dt.dex-engine.activity-worker.default.enabled=true + +# @hidden +dt.dex-engine.activity-worker.default.queue-name=default + +# Defines the maximum concurrency of the default activity worker. +# +# @category: Durable Execution +# @type: integer +# @required +dt.dex-engine.activity-worker.default.max-concurrency=25 + +# Defines whether the notification activity worker should be enabled. +# +# @category: Durable Execution +# @type: boolean +# dt.dex-engine.activity-worker.notification.enabled=true + +# @hidden +dt.dex-engine.activity-worker.notification.queue-name=notifications + +# Defines the maximum concurrency of the notification activity worker. +# +# @category: Durable Execution +# @type: integer +# @required +dt.dex-engine.activity-worker.notification.max-concurrency=5 + # Defines the time in milliseconds between flushes of the task event buffer. #

# Increasing this interval may yield better throughput while reducing the diff --git a/apiserver/src/main/webapp/WEB-INF/web.xml b/apiserver/src/main/webapp/WEB-INF/web.xml index a8db89a3f1..45b368d203 100644 --- a/apiserver/src/main/webapp/WEB-INF/web.xml +++ b/apiserver/src/main/webapp/WEB-INF/web.xml @@ -43,6 +43,9 @@ org.dependencytrack.plugin.PluginInitializer + + org.dependencytrack.notification.DefaultNotificationPublisherInitializer + org.dependencytrack.event.kafka.KafkaProducerInitializer @@ -53,10 +56,10 @@ org.dependencytrack.tasks.TaskSchedulerInitializer - org.dependencytrack.notification.NotificationSubsystemInitializer + org.dependencytrack.dex.DexEngineInitializer - org.dependencytrack.dex.DexEngineInitializer + org.dependencytrack.notification.NotificationSubsystemInitializer org.dependencytrack.event.kafka.processor.ProcessorInitializer diff --git a/apiserver/src/test/java/org/dependencytrack/PersistenceCapableTest.java b/apiserver/src/test/java/org/dependencytrack/PersistenceCapableTest.java index bf3813155d..b67507ea6c 100644 --- a/apiserver/src/test/java/org/dependencytrack/PersistenceCapableTest.java +++ b/apiserver/src/test/java/org/dependencytrack/PersistenceCapableTest.java @@ -115,8 +115,15 @@ protected static void truncateTables(final PostgreSQLContainer postgresContainer DO $$ DECLARE r RECORD; BEGIN - FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = CURRENT_SCHEMA()) LOOP - EXECUTE 'TRUNCATE TABLE ' || QUOTE_IDENT(r.tablename) || ' CASCADE'; + FOR r IN ( + SELECT tablename + FROM pg_tables + WHERE schemaname = CURRENT_SCHEMA() + -- Do not truncate Liquibase / Flyway changelog tables. + AND tablename != 'databasechangelog' + AND tablename !~ '^.+schema_history$' + ) LOOP + EXECUTE FORMAT('TRUNCATE TABLE %I CASCADE', r.tablename); END LOOP; END $$; """); @@ -125,8 +132,8 @@ FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = CURRENT_SCHEMA()) L DO $$ DECLARE partition_name TEXT; - today_partition_pattern TEXT := format('^(PROJECT|DEPENDENCY)METRICS_%s', TO_CHAR(CURRENT_DATE, 'YYYYMMDD')); - tomorrow_partition_pattern TEXT := format('^(PROJECT|DEPENDENCY)METRICS_%s', TO_CHAR(CURRENT_DATE + 1, 'YYYYMMDD')); + today_partition_pattern TEXT := FORMAT('^(PROJECT|DEPENDENCY)METRICS_%s', TO_CHAR(CURRENT_DATE, 'YYYYMMDD')); + tomorrow_partition_pattern TEXT := FORMAT('^(PROJECT|DEPENDENCY)METRICS_%s', TO_CHAR(CURRENT_DATE + 1, 'YYYYMMDD')); BEGIN FOR partition_name IN SELECT tablename @@ -135,7 +142,7 @@ FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = CURRENT_SCHEMA()) L AND tablename !~ today_partition_pattern AND tablename !~ tomorrow_partition_pattern LOOP - EXECUTE format('DROP TABLE "%s"', partition_name); + EXECUTE FORMAT('DROP TABLE %I', partition_name); END LOOP; END $$; """); diff --git a/apiserver/src/test/java/org/dependencytrack/dex/DexEngineInitializerTest.java b/apiserver/src/test/java/org/dependencytrack/dex/DexEngineInitializerTest.java index 6a9422fbd0..e236bac443 100644 --- a/apiserver/src/test/java/org/dependencytrack/dex/DexEngineInitializerTest.java +++ b/apiserver/src/test/java/org/dependencytrack/dex/DexEngineInitializerTest.java @@ -25,6 +25,9 @@ import org.dependencytrack.common.health.HealthCheckRegistry; import org.dependencytrack.dex.engine.api.DexEngine; import org.dependencytrack.dex.engine.migration.MigrationExecutor; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.secret.TestSecretManager; +import org.dependencytrack.secret.management.SecretManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -84,10 +87,15 @@ void shouldStartEngine() throws Exception { dataSourceRegistry = new DataSourceRegistry(config); final var healthCheckRegistry = new HealthCheckRegistry(Collections.emptyList()); + final var secretManager = new TestSecretManager(); final var servletContextMock = mock(ServletContext.class); doReturn(healthCheckRegistry) .when(servletContextMock).getAttribute(eq(HealthCheckRegistry.class.getName())); + doReturn(new PluginManager(config, secretManager::getSecretValue, Collections.emptyList())) + .when(servletContextMock).getAttribute(eq(PluginManager.class.getName())); + doReturn(secretManager) + .when(servletContextMock).getAttribute(eq(SecretManager.class.getName())); initializer = new DexEngineInitializer(config, dataSourceRegistry); initializer.contextInitialized(new ServletContextEvent(servletContextMock)); diff --git a/apiserver/src/test/java/org/dependencytrack/event/kafka/KafkaEventDispatcherTest.java b/apiserver/src/test/java/org/dependencytrack/event/kafka/KafkaEventDispatcherTest.java index b62e447301..4e46cda7ce 100644 --- a/apiserver/src/test/java/org/dependencytrack/event/kafka/KafkaEventDispatcherTest.java +++ b/apiserver/src/test/java/org/dependencytrack/event/kafka/KafkaEventDispatcherTest.java @@ -29,7 +29,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.Collections; import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -104,9 +103,4 @@ public void testDispatchEventWithUnsupportedType() { .withMessageStartingWith("Unable to convert event"); } - @Test - public void testDispatchAllNotificationProtosWithEmptyCollection() { - assertThat(eventDispatcher.dispatchAllNotificationProtos(Collections.emptyList())).isEmpty(); - } - } \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/ProcessedVulnerabilityScanResultProcessorTest.java b/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/ProcessedVulnerabilityScanResultProcessorTest.java index 01cbf8da77..25c50c0713 100644 --- a/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/ProcessedVulnerabilityScanResultProcessorTest.java +++ b/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/ProcessedVulnerabilityScanResultProcessorTest.java @@ -31,6 +31,7 @@ import org.dependencytrack.model.WorkflowState; import org.dependencytrack.model.WorkflowStatus; import org.dependencytrack.model.WorkflowStep; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.proto.v1.BomConsumedOrProcessedSubject; import org.dependencytrack.notification.proto.v1.BomProcessingFailedSubject; import org.dependencytrack.notification.proto.v1.ProjectVulnAnalysisCompleteSubject; @@ -50,6 +51,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_BOM_PROCESSED; import static org.dependencytrack.notification.proto.v1.Group.GROUP_BOM_PROCESSING_FAILED; import static org.dependencytrack.notification.proto.v1.Group.GROUP_PROJECT_VULN_ANALYSIS_COMPLETE; @@ -85,6 +87,7 @@ void afterEach() { @Test void testProcessWithFailureThresholdExceeded() throws Exception { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -166,6 +169,7 @@ void testProcessWithFailureThresholdExceeded() throws Exception { @Test void testProcessWithResultWithoutScannerResults() throws Exception { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -247,6 +251,7 @@ void testProcessWithResultWithoutScannerResults() throws Exception { @Test void testProcessWithDelayedBomProcessedNotification() throws Exception { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -304,6 +309,7 @@ void testProcessWithDelayedBomProcessedNotification() throws Exception { @Test void testProcessWithDelayedBomProcessedNotificationWhenVulnerabilityScanFailed() throws Exception { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); project.setInactiveSince(new Date()); @@ -358,6 +364,7 @@ void testProcessWithDelayedBomProcessedNotificationWhenVulnerabilityScanFailed() @Test void testProcessWithDelayedBomProcessedNotificationWithoutCompletedBomProcessingWorkflowStep() throws Exception { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -400,6 +407,7 @@ void testProcessWithDelayedBomProcessedNotificationWithoutCompletedBomProcessing @Test void testProcessWithDelayedBomProcessedNotificationWhenVulnerabilityScanTargetIsComponent() throws Exception { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); qm.persist(project); diff --git a/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessorTest.java b/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessorTest.java index aad91e73e1..5954450c7d 100644 --- a/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessorTest.java +++ b/apiserver/src/test/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessorTest.java @@ -45,6 +45,7 @@ import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAlias; import org.dependencytrack.model.VulnerabilityAnalysisLevel; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.proto.v1.NewVulnerabilitySubject; import org.dependencytrack.notification.proto.v1.NewVulnerableDependencySubject; import org.dependencytrack.notification.proto.v1.VulnerabilityAnalysisDecisionChangeSubject; @@ -65,11 +66,11 @@ import org.dependencytrack.proto.vulnanalysis.v1.Scanner; import org.dependencytrack.proto.vulnanalysis.v1.ScannerResult; import org.dependencytrack.vulndatasource.api.VulnDataSource; -import org.dependencytrack.vulndatasource.github.GitHubVulnDataSourceConfig; import org.dependencytrack.vulndatasource.github.GitHubVulnDataSourcePlugin; -import org.dependencytrack.vulndatasource.nvd.NvdVulnDataSourceConfig; +import org.dependencytrack.vulndatasource.github.GithubVulnDataSourceConfigV1; +import org.dependencytrack.vulndatasource.nvd.NvdVulnDataSourceConfigV1; import org.dependencytrack.vulndatasource.nvd.NvdVulnDataSourcePlugin; -import org.dependencytrack.vulndatasource.osv.OsvVulnDataSourceConfig; +import org.dependencytrack.vulndatasource.osv.OsvVulnDataSourceConfigV1; import org.dependencytrack.vulndatasource.osv.OsvVulnDataSourcePlugin; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -92,6 +93,7 @@ import static org.cyclonedx.proto.v1_6.ScoreMethod.SCORE_METHOD_CVSSV3; import static org.cyclonedx.proto.v1_6.ScoreMethod.SCORE_METHOD_OWASP; import static org.dependencytrack.model.AnalysisState.EXPLOITABLE; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_ANALYZER; import static org.dependencytrack.notification.proto.v1.Group.GROUP_NEW_VULNERABILITY; import static org.dependencytrack.notification.proto.v1.Group.GROUP_NEW_VULNERABLE_DEPENDENCY; @@ -148,6 +150,7 @@ void afterEach() { @Test void dropFailedScanResultTest() { + createCatchAllNotificationRule(qm, NotificationScope.SYSTEM); final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -252,6 +255,7 @@ void processSuccessfulScanResultWhenComponentDoesNotExistTest() { @Test void processSuccessfulScanResult() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -480,7 +484,7 @@ void canUpdateExistingVulnerabilityTest( final var configRegistry = pluginManager .getMutableConfigRegistry(VulnDataSource.class, "github"); configRegistry - .getOptionalRuntimeConfig(GitHubVulnDataSourceConfig.class) + .getOptionalRuntimeConfig(GithubVulnDataSourceConfigV1.class) .map(config -> config .withEnabled(mirrorSourceEnabled) .withApiToken("dummy")) @@ -490,7 +494,7 @@ void canUpdateExistingVulnerabilityTest( final var configRegistry = pluginManager .getMutableConfigRegistry(VulnDataSource.class, "nvd"); configRegistry - .getOptionalRuntimeConfig(NvdVulnDataSourceConfig.class) + .getOptionalRuntimeConfig(NvdVulnDataSourceConfigV1.class) .map(config -> config.withEnabled(mirrorSourceEnabled)) .ifPresent(configRegistry::setRuntimeConfig); } @@ -498,7 +502,7 @@ void canUpdateExistingVulnerabilityTest( final var configRegistry = pluginManager .getMutableConfigRegistry(VulnDataSource.class, "osv"); configRegistry - .getOptionalRuntimeConfig(OsvVulnDataSourceConfig.class) + .getOptionalRuntimeConfig(OsvVulnDataSourceConfigV1.class) .map(config -> config.withEnabled(mirrorSourceEnabled)) .ifPresent(configRegistry::setRuntimeConfig); } @@ -556,7 +560,7 @@ void updateExistingVulnerabilityTest() { // Disable NVD vuln source so CVE data can be modified. final var configRegistry = pluginManager .getMutableConfigRegistry(VulnDataSource.class, "nvd"); - final var config = configRegistry.getRuntimeConfig(NvdVulnDataSourceConfig.class); + final var config = configRegistry.getRuntimeConfig(NvdVulnDataSourceConfigV1.class); config.setEnabled(false); configRegistry.setRuntimeConfig(config); @@ -678,6 +682,7 @@ void updateExistingVulnerabilityTest() { @Test void analysisThroughPolicyNewAnalysisTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -870,6 +875,7 @@ void analysisThroughPolicyNewAnalysisSuppressionTest() { @Test void analysisThroughPolicyExistingDifferentAnalysisTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -1398,6 +1404,7 @@ void analysisThroughPolicyWithPoliciesNotYetValidOrNotValidAnymoreTest() { @Test void analysisThroughPolicyWithAnalysisUpdateNotOnStateOrSuppressionTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -1507,6 +1514,7 @@ void analysisThroughPolicyWithPoliciesLoggableTest() { @Test void processSuccessfulScanResultWithNoSnykVulnerabilityTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -1576,6 +1584,7 @@ void processSuccessfulScanResultWithNoSnykVulnerabilityTest() { @Test void processSuccessfulScanResultWithSnykVulnerabilityTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); diff --git a/apiserver/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java b/apiserver/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java index 6667d49df1..57f56b6b1d 100644 --- a/apiserver/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java +++ b/apiserver/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java @@ -44,14 +44,7 @@ public void testDescription() { NotificationPublisher publisher = new NotificationPublisher(); publisher.setDescription("My description"); Assertions.assertEquals("My description", publisher.getDescription()); - } - - @Test - public void testPublisherClass() { - NotificationPublisher publisher = new NotificationPublisher(); - publisher.setPublisherClass("org.acme.publisher"); - Assertions.assertEquals("org.acme.publisher", publisher.getPublisherClass()); - } + } @Test public void testTemplate() { diff --git a/apiserver/src/test/java/org/dependencytrack/notification/JdoNotificationEmitterTest.java b/apiserver/src/test/java/org/dependencytrack/notification/JdoNotificationEmitterTest.java index 16980f16e2..91b36baa3a 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/JdoNotificationEmitterTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/JdoNotificationEmitterTest.java @@ -20,6 +20,7 @@ import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.notification.api.emission.NotificationEmitter; import org.dependencytrack.notification.proto.v1.Notification; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,22 +29,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification; -public class JdoNotificationEmitterTest extends PersistenceCapableTest { +class JdoNotificationEmitterTest extends PersistenceCapableTest { - private org.dependencytrack.notification.api.emission.NotificationEmitter emitter; + private NotificationEmitter emitter; @BeforeEach - @Override - public void before() throws Exception { - super.before(); - + void beforeEach() { emitter = new JdoNotificationEmitter(qm, new SimpleMeterRegistry()); } @Test - public void emitShouldEmitNotification() { + void emitShouldEmitNotification() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + final Notification notification = createBomConsumedTestNotification(); emitter.emit(notification); @@ -52,7 +53,9 @@ public void emitShouldEmitNotification() { } @Test - public void emitAllShouldEmitNotifications() { + void emitAllShouldEmitNotifications() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + final var notifications = new ArrayList(5); for (int i = 0; i < 5; i++) { @@ -65,19 +68,19 @@ public void emitAllShouldEmitNotifications() { } @Test - public void emitShouldThrowWhenNotificationIsNull() { + void emitShouldThrowWhenNotificationIsNull() { assertThatExceptionOfType(NullPointerException.class) .isThrownBy(() -> emitter.emit(null)); } @Test - public void emitAllShouldThrowWhenNotificationsIsNull() { + void emitAllShouldThrowWhenNotificationsIsNull() { assertThatExceptionOfType(NullPointerException.class) .isThrownBy(() -> emitter.emitAll(null)); } @Test - public void emitShouldThrowWhenNotificationIdIsMissing() { + void emitShouldThrowWhenNotificationIdIsMissing() { final Notification notification = createBomConsumedTestNotification().toBuilder() .clearId() @@ -88,7 +91,7 @@ public void emitShouldThrowWhenNotificationIdIsMissing() { } @Test - public void emitShouldThrowWhenNotificationScopeIsMissing() { + void emitShouldThrowWhenNotificationScopeIsMissing() { final Notification notification = createBomConsumedTestNotification().toBuilder() .clearScope() @@ -99,7 +102,7 @@ public void emitShouldThrowWhenNotificationScopeIsMissing() { } @Test - public void emitShouldThrowWhenNotificationGroupIsMissing() { + void emitShouldThrowWhenNotificationGroupIsMissing() { final Notification notification = createBomConsumedTestNotification().toBuilder() .clearGroup() @@ -110,7 +113,7 @@ public void emitShouldThrowWhenNotificationGroupIsMissing() { } @Test - public void emitShouldThrowWhenNotificationLevelIsMissing() { + void emitShouldThrowWhenNotificationLevelIsMissing() { final Notification notification = createBomConsumedTestNotification().toBuilder() .clearLevel() @@ -121,7 +124,7 @@ public void emitShouldThrowWhenNotificationLevelIsMissing() { } @Test - public void emitShouldThrowWhenNotificationTimestampIsMissing() { + void emitShouldThrowWhenNotificationTimestampIsMissing() { final Notification notification = createBomConsumedTestNotification().toBuilder() .clearTimestamp() @@ -132,7 +135,7 @@ public void emitShouldThrowWhenNotificationTimestampIsMissing() { } @Test - public void emitShouldThrowWhenNotificationTitleIsMissing() { + void emitShouldThrowWhenNotificationTitleIsMissing() { final Notification notification = createBomConsumedTestNotification().toBuilder() .clearTitle() @@ -143,7 +146,7 @@ public void emitShouldThrowWhenNotificationTitleIsMissing() { } @Test - public void emitShouldThrowWhenNotificationContentIsMissing() { + void emitShouldThrowWhenNotificationContentIsMissing() { final Notification notification = createBomConsumedTestNotification().toBuilder() .clearContent() diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java index aecb5c4a4a..70fa47bad0 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java @@ -21,10 +21,10 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -public class NotificationGroupTest { +class NotificationGroupTest { @Test - public void testEnums() { + void testEnums() { // System Groups Assertions.assertEquals("CONFIGURATION", NotificationGroup.CONFIGURATION.name()); Assertions.assertEquals("DATASOURCE_MIRRORING", NotificationGroup.DATASOURCE_MIRRORING.name()); diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationOutboxRelayTest.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationOutboxRelayTest.java index 6053c19e4d..f118b23e37 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/NotificationOutboxRelayTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationOutboxRelayTest.java @@ -19,82 +19,114 @@ package org.dependencytrack.notification; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.apache.kafka.clients.producer.MockProducer; -import org.apache.kafka.common.serialization.ByteArraySerializer; +import io.smallrye.config.SmallRyeConfigBuilder; import org.dependencytrack.PersistenceCapableTest; -import org.dependencytrack.event.kafka.KafkaEventDispatcher; -import org.dependencytrack.event.kafka.KafkaTopics; +import org.dependencytrack.dex.engine.api.DexEngine; +import org.dependencytrack.dex.engine.api.request.CreateWorkflowRunRequest; +import org.dependencytrack.filestorage.api.FileStorage; +import org.dependencytrack.filestorage.memory.MemoryFileStoragePlugin; +import org.dependencytrack.filestorage.proto.v1.FileMetadata; +import org.dependencytrack.model.NotificationPublisher; +import org.dependencytrack.model.NotificationRule; import org.dependencytrack.notification.api.TestNotificationFactory; import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationWorkflowArg; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; - +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.io.InputStream; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.awaitility.Awaitility.await; -import static org.dependencytrack.util.KafkaTestUtil.deserializeKey; -import static org.dependencytrack.util.KafkaTestUtil.deserializeValue; - -public class NotificationOutboxRelayTest extends PersistenceCapableTest { - - private MockProducer mockProducer; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +class NotificationOutboxRelayTest extends PersistenceCapableTest { + + private DexEngine dexEngineMock; + private PluginManager pluginManager; + private NotificationRouter routerMock; private NotificationOutboxRelay relay; + private final int largeNotificationThresholdBytes = 512; @BeforeEach - @Override - public void before() throws Exception { - super.before(); - - mockProducer = new MockProducer<>( - /* autoComplete */ false, - /* partitioner */ null, - new ByteArraySerializer(), - new ByteArraySerializer()); + void beforeEach() { + dexEngineMock = mock(DexEngine.class); + pluginManager = new PluginManager( + new SmallRyeConfigBuilder().build(), + secretName -> null, + List.of(FileStorage.class)); + pluginManager.loadPlugins(List.of(new MemoryFileStoragePlugin())); + routerMock = mock(NotificationRouter.class); relay = new NotificationOutboxRelay( - new KafkaEventDispatcher(mockProducer), + dexEngineMock, + pluginManager, + ignored -> routerMock, new SimpleMeterRegistry(), - /* routerEnabled */ true, /* pollIntervalMillis */ 10, - /* batchSize */ 10); + /* batchSize */ 10, + largeNotificationThresholdBytes); } @AfterEach - @Override - public void after() { + void afterEach() { if (relay != null) { relay.close(); } - if (mockProducer != null) { - mockProducer.close(); + if (pluginManager != null) { + pluginManager.close(); } - - super.after(); } @Test - public void shouldRelayNotification() { - final Notification notification = org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification(); + void shouldRelayNotification() { + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + final NotificationRule rule = createMatchingRule(notification); new JdoNotificationEmitter(qm).emit(notification); - relay.start(); + doReturn(List.of(new NotificationRouter.Result(notification, Set.of(rule.getName())))) + .when(routerMock).route(anyCollection()); - await("Kafka producer send completion") - .atMost(1, TimeUnit.SECONDS) - .until(() -> mockProducer.completeNext()); + relay.start(); - assertThat(mockProducer.history()).satisfiesExactly(record -> { - final String key = deserializeKey( - KafkaTopics.NOTIFICATION_BOM, record); - assertThat(key).isEqualTo("c9c9539a-e381-4b36-ac52-6a7ab83b2c95"); + //noinspection unchecked + final ArgumentCaptor>> createRunsCaptor = + ArgumentCaptor.forClass(Collection.class); - final Notification value = deserializeValue( - KafkaTopics.NOTIFICATION_BOM, record); - assertThat(value).isEqualTo(notification); + await("Workflow run creation") + .atMost(1, TimeUnit.SECONDS) + .untilAsserted(() -> Mockito.verify(dexEngineMock).createRuns(createRunsCaptor.capture())); + + assertThat(createRunsCaptor.getValue()).satisfiesExactly(request -> { + assertThat(request.workflowName()).isEqualTo("publish-notification"); + assertThat(request.workflowVersion()).isEqualTo(1); + assertThat(request.workflowInstanceId()).isEqualTo("publish-notification:" + notification.getId()); + assertThat(request.argument()).isInstanceOf(PublishNotificationWorkflowArg.class); + + final var workflowArg = (PublishNotificationWorkflowArg) request.argument(); + assertThat(workflowArg.getNotificationId()).isEqualTo(notification.getId()); + assertThat(workflowArg.getNotificationRuleNamesList()).containsOnly(rule.getName()); + assertThat(workflowArg.getNotification()).isEqualTo(notification); + assertThat(workflowArg.hasNotificationFileMetadata()).isFalse(); }); await("Outbox record removal") @@ -103,35 +135,59 @@ public void shouldRelayNotification() { } @Test - public void shouldRetryOnFailedSend() { + void shouldNotRelayNotificationWhenNoMatchingRuleExists() { final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + final NotificationRule rule = createMatchingRule(notification); + new JdoNotificationEmitter(qm).emit(notification); + qm.delete(rule); + + doReturn(Collections.emptyList()) + .when(routerMock).route(anyCollection()); + relay.start(); - await("Kafka producer send failure") + await("Outbox record removal") .atMost(1, TimeUnit.SECONDS) - .until(() -> mockProducer.errorNext(new IllegalStateException("Boom!"))); + .untilAsserted(() -> assertThat(qm.getNotificationOutbox()).isEmpty()); - assertThat(qm.getNotificationOutbox()).hasSize(1); + Mockito.verify(dexEngineMock, never()).createRuns(anyCollection()); + } + + @Test + void shouldRetryOnFailedSend() { + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + final NotificationRule rule = createMatchingRule(notification); + + new JdoNotificationEmitter(qm).emit(notification); + + doReturn(List.of(new NotificationRouter.Result(notification, Set.of(rule.getName())))) + .when(routerMock).route(anyCollection()); - await("Kafka producer send completion") + relay.start(); + + doThrow(new IllegalStateException("Boom!")) + .doReturn(List.of(UUID.fromString("2777be5d-5a95-40b3-9226-311874a21bf6"))) + .when(dexEngineMock).createRuns(anyCollection()); + + //noinspection unchecked + final ArgumentCaptor>> requestsCaptor = + ArgumentCaptor.forClass(Collection.class); + + await("Workflow run creation") .atMost(1, TimeUnit.SECONDS) - .until(() -> mockProducer.completeNext()); + .untilAsserted(() -> Mockito.verify(dexEngineMock, times(2)).createRuns(requestsCaptor.capture())); - // Mock producer keeps all records in its history, - // even if delivery of them failed. - assertThat(mockProducer.history()) + assertThat(requestsCaptor.getAllValues()) .hasSizeGreaterThanOrEqualTo(2) - .anySatisfy(record -> { - final String key = deserializeKey( - KafkaTopics.NOTIFICATION_BOM, record); - assertThat(key).isEqualTo("c9c9539a-e381-4b36-ac52-6a7ab83b2c95"); - - final Notification value = deserializeValue( - KafkaTopics.NOTIFICATION_BOM, record); - assertThat(value).isEqualTo(notification); + .allSatisfy(requests -> { + assertThat(requests).satisfiesExactly(request -> { + assertThat(request.workflowName()).isEqualTo("publish-notification"); + assertThat(request.workflowVersion()).isEqualTo(1); + }); }); await("Outbox record removal") @@ -140,55 +196,140 @@ public void shouldRetryOnFailedSend() { } @Test - @SuppressWarnings("resource") - public void constructorShouldThrowWhenDelegateDispatcherIsNull() { - assertThatExceptionOfType(NullPointerException.class) - .isThrownBy(() -> new NotificationOutboxRelay( - null, - new SimpleMeterRegistry(), - true, - 100, - 10)); - } + void shouldOffloadLargeNotificationsToFileStorage() { + final Notification notification = TestNotificationFactory + .createBomConsumedTestNotification() + .toBuilder() + .setContent("a".repeat(largeNotificationThresholdBytes)) + .build(); - @Test - @SuppressWarnings("resource") - public void constructorShouldThrowWhenMeterRegistryIsNull() { - assertThatExceptionOfType(NullPointerException.class) - .isThrownBy(() -> new NotificationOutboxRelay( - new KafkaEventDispatcher(mockProducer), - null, - true, - 100, - 10)); - } + final NotificationRule rule = createMatchingRule(notification); - @Test - @SuppressWarnings("resource") - public void constructorShouldThrowWhenPollIntervalIsZero() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> new NotificationOutboxRelay( - new KafkaEventDispatcher(mockProducer), - new SimpleMeterRegistry(), - true, - 0, - 10)); + new JdoNotificationEmitter(qm).emit(notification); + + doReturn(List.of(new NotificationRouter.Result(notification, Set.of(rule.getName())))) + .when(routerMock).route(anyCollection()); + + relay.start(); + + //noinspection unchecked + final ArgumentCaptor>> createRunsCaptor = + ArgumentCaptor.forClass(Collection.class); + + await("Workflow run creation") + .atMost(1, TimeUnit.SECONDS) + .untilAsserted(() -> Mockito.verify(dexEngineMock).createRuns(createRunsCaptor.capture())); + + assertThat(createRunsCaptor.getValue()).satisfiesExactly(request -> { + assertThat(request.workflowName()).isEqualTo("publish-notification"); + assertThat(request.workflowVersion()).isEqualTo(1); + assertThat(request.argument()).isInstanceOf(PublishNotificationWorkflowArg.class); + + final var workflowArg = (PublishNotificationWorkflowArg) request.argument(); + assertThat(workflowArg.getNotificationId()).isEqualTo(notification.getId()); + assertThat(workflowArg.getNotificationRuleNamesList()).containsOnly(rule.getName()); + assertThat(workflowArg.hasNotification()).isFalse(); + assertThat(workflowArg.hasNotificationFileMetadata()).isTrue(); + + final FileMetadata fileMetadata = workflowArg.getNotificationFileMetadata(); + try (final var fileStorage = pluginManager.getExtension(FileStorage.class); + final InputStream fileInputStream = fileStorage.get(fileMetadata)) { + assertThat(fileInputStream).isNotNull(); + final var storedNotification = Notification.parseFrom(fileInputStream); + assertThat(storedNotification).isEqualTo(notification); + } + }); + + await("Outbox record removal") + .atMost(1, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(qm.getNotificationOutbox()).isEmpty()); } - @Test - @SuppressWarnings("resource") - public void constructorShouldThrowWhenBatchSizeIsZero() { - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> new NotificationOutboxRelay( - new KafkaEventDispatcher(mockProducer), - new SimpleMeterRegistry(), - true, - 100, - 0)); + @Nested + class ConstructorTest { + + @Test + void shouldThrowWhenDexEngineIsNull() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new NotificationOutboxRelay( + null, + pluginManager, + ignored -> routerMock, + new SimpleMeterRegistry(), + /* pollIntervalMillis */ 100, + /* batchSize */ 10, + /* largeNotificationThresholdBytes */ 128 * 1024)); + } + + @Test + void shouldThrowWhenPluginManagerIsNull() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new NotificationOutboxRelay( + dexEngineMock, + null, + ignored -> routerMock, + new SimpleMeterRegistry(), + /* pollIntervalMillis */ 100, + /* batchSize */ 10, + /* largeNotificationThresholdBytes */ 128 * 1024)); + } + + @Test + void shouldThrowWhenRouterFactoryIsNull() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new NotificationOutboxRelay( + dexEngineMock, + pluginManager, + null, + new SimpleMeterRegistry(), + /* pollIntervalMillis */ 100, + /* batchSize */ 10, + /* largeNotificationThresholdBytes */ 128 * 1024)); + } + + @Test + void shouldThrowWhenMeterRegistryIsNull() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new NotificationOutboxRelay( + dexEngineMock, + pluginManager, + ignored -> routerMock, + null, + /* pollIntervalMillis */ 100, + /* batchSize */ 10, + /* largeNotificationThresholdBytes */ 128 * 1024)); + } + + @Test + void shouldThrowWhenPollIntervalIsZero() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new NotificationOutboxRelay( + dexEngineMock, + pluginManager, + ignored -> routerMock, + new SimpleMeterRegistry(), + /* pollIntervalMillis */ 0, + /* batchSize */ 10, + /* largeNotificationThresholdBytes */ 128 * 1024)); + } + + @Test + void shouldThrowWhenBatchSizeIsZero() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new NotificationOutboxRelay( + dexEngineMock, + pluginManager, + ignored -> routerMock, + new SimpleMeterRegistry(), + /* pollIntervalMillis */ 100, + /* batchSize */ 0, + /* largeNotificationThresholdBytes */ 128 * 1024)); + } + } @Test - public void startShouldThrowWhenCalledMultipleTimes() { + void startShouldThrowWhenCalledMultipleTimes() { assertThatNoException().isThrownBy(() -> relay.start()); assertThatExceptionOfType(IllegalStateException.class) @@ -196,4 +337,18 @@ public void startShouldThrowWhenCalledMultipleTimes() { .withMessage("Already started"); } + private NotificationRule createMatchingRule(Notification notification) { + return qm.callInTransaction(() -> { + final NotificationPublisher publisher = qm.createNotificationPublisher( + "publisherName", "description", "extensionName", "templateContent", "templateMimeType", false); + final NotificationRule rule = qm.createNotificationRule( + "ruleName", + NotificationModelConverter.convert(notification.getScope()), + NotificationModelConverter.convert(notification.getLevel()), + publisher); + rule.setNotifyOn(Set.of(NotificationModelConverter.convert(notification.getGroup()))); + return rule; + }); + } + } \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java index d512b60c4f..1149730c2c 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java @@ -33,6 +33,7 @@ import org.jdbi.v3.core.Handle; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -47,316 +48,308 @@ import static org.dependencytrack.notification.NotificationModelConverter.convert; import static org.dependencytrack.persistence.jdbi.JdbiFactory.openJdbiHandle; -public class NotificationRouterTest extends PersistenceCapableTest { +class NotificationRouterTest extends PersistenceCapableTest { private Handle jdbiHandle; private NotificationRouter router; @BeforeEach - @Override - public void before() throws Exception { - super.before(); - + void beforeEach() { jdbiHandle = openJdbiHandle(); router = new NotificationRouter(jdbiHandle, new SimpleMeterRegistry()); } @AfterEach - @Override - public void after() { + void afterEach() { if (jdbiHandle != null) { jdbiHandle.close(); } - - super.after(); } - @Test - public void constructorShouldThrowWhenJdbiHandleIsNull() { - assertThatExceptionOfType(NullPointerException.class) - .isThrownBy(() -> new NotificationRouter(null, new SimpleMeterRegistry())) - .withMessage("jdbiHandle must not be null"); - } + @Nested + class ConstructorTest { - @Test - public void constructorShouldThrowWhenMeterRegistryIsNull() { - assertThatExceptionOfType(NullPointerException.class) - .isThrownBy(() -> new NotificationRouter(jdbiHandle, null)) - .withMessage("meterRegistry must not be null"); - } + @Test + void constructorShouldThrowWhenJdbiHandleIsNull() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new NotificationRouter(null, new SimpleMeterRegistry())) + .withMessage("jdbiHandle must not be null"); + } - @Test - public void routeShouldThrowWhenNotificationsIsNull() { - assertThatExceptionOfType(NullPointerException.class) - .isThrownBy(() -> router.route(null)) - .withMessage("notifications must not be null"); - } + @Test + void constructorShouldThrowWhenMeterRegistryIsNull() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new NotificationRouter(jdbiHandle, null)) + .withMessage("meterRegistry must not be null"); + } - @Test - public void routeShouldReturnEmptyListWhenNotificationsIsEmpty() { - assertThat(router.route(Collections.emptyList())).isEmpty(); } - @Test - public void routeShouldMatchEnabledRules() { - // Create a rule that is enabled. - final var enabledRule = new NotificationRule(); - enabledRule.setName("A"); - enabledRule.setScope(NotificationScope.PORTFOLIO); - enabledRule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - enabledRule.setNotificationLevel(NotificationLevel.INFORMATIONAL); - enabledRule.setEnabled(true); - qm.persist(enabledRule); - - // Create a rule that disabled. - final var disabledRule = new NotificationRule(); - disabledRule.setName("B"); - disabledRule.setScope(NotificationScope.PORTFOLIO); - disabledRule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - disabledRule.setNotificationLevel(NotificationLevel.INFORMATIONAL); - disabledRule.setEnabled(false); - qm.persist(disabledRule); - - final Notification notification = org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification(); - - // Only the enabled rule should have matched. - assertThat(router.route(List.of(notification))).satisfiesExactly(task -> { - assertThat(task.ruleId()).isEqualTo(enabledRule.getId()); - assertThat(task.ruleName()).isEqualTo(enabledRule.getName()); - assertThat(task.notification()).isEqualTo(notification); - }); - } + @Nested + class RouteTest { - @Test - public void routeShouldMatchRulesWithMatchingProject() throws Exception { - final var projectA = new Project(); - projectA.setName("acme-app-a"); - qm.persist(projectA); - - final var projectB = new Project(); - projectB.setName("acme-app-b"); - qm.persist(projectB); - - // Create a rule that is limited to project A. - final var ruleProjectA = new NotificationRule(); - ruleProjectA.setName("A"); - ruleProjectA.setScope(NotificationScope.PORTFOLIO); - ruleProjectA.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleProjectA.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleProjectA.setEnabled(true); - ruleProjectA.setProjects(List.of(projectA)); - qm.persist(ruleProjectA); - - // Create a rule that is limited to project B. - final var ruleProjectB = new NotificationRule(); - ruleProjectB.setName("B"); - ruleProjectB.setScope(NotificationScope.PORTFOLIO); - ruleProjectB.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleProjectB.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleProjectB.setEnabled(true); - ruleProjectB.setProjects(List.of(projectB)); - qm.persist(ruleProjectB); - - // Create a notification for project A. - final Notification.Builder notificationBuilder = - org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification().toBuilder(); - final BomConsumedOrProcessedSubject.Builder subjectBuilder = - notificationBuilder.getSubject().unpack(BomConsumedOrProcessedSubject.class).toBuilder(); - subjectBuilder.setProject( - org.dependencytrack.notification.proto.v1.Project.newBuilder() - .setUuid(projectA.getUuid().toString()) - .setName(projectA.getName()) - .build()); - final Notification notification = notificationBuilder - .setSubject(Any.pack(subjectBuilder.build())) - .build(); - - // Only the rule limited to project A must have matched. - assertThat(router.route(List.of(notification))).satisfiesExactly(task -> { - assertThat(task.ruleId()).isEqualTo(ruleProjectA.getId()); - assertThat(task.ruleName()).isEqualTo(ruleProjectA.getName()); - assertThat(task.notification()).isEqualTo(notification); - }); - } + @Test + void routeShouldThrowWhenNotificationsIsNull() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> router.route(null)) + .withMessage("notifications must not be null"); + } - @Test - public void routeShouldMatchRulesWithMatchingParentProject() throws Exception { - final var parentProject = new Project(); - parentProject.setName("acme-app-parent"); - qm.persist(parentProject); - - final var childProject = new Project(); - childProject.setParent(parentProject); - childProject.setName("acme-app-child"); - qm.persist(childProject); - - // Create a rule that is limited to the parent project, - // but has the "notify children" feature ENABLED. - final var ruleNotifyChildren = new NotificationRule(); - ruleNotifyChildren.setName("A"); - ruleNotifyChildren.setScope(NotificationScope.PORTFOLIO); - ruleNotifyChildren.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleNotifyChildren.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleNotifyChildren.setEnabled(true); - ruleNotifyChildren.setProjects(List.of(parentProject)); - ruleNotifyChildren.setNotifyChildren(true); - qm.persist(ruleNotifyChildren); - - // Create a rule that is limited to the parent project, - // but has the "notify children" feature DISABLED. - final var ruleDoNotNotifyChildren = new NotificationRule(); - ruleDoNotNotifyChildren.setName("B"); - ruleDoNotNotifyChildren.setScope(NotificationScope.PORTFOLIO); - ruleDoNotNotifyChildren.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleDoNotNotifyChildren.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleDoNotNotifyChildren.setEnabled(true); - ruleDoNotNotifyChildren.setProjects(List.of(parentProject)); - ruleDoNotNotifyChildren.setNotifyChildren(false); - qm.persist(ruleDoNotNotifyChildren); - - // Create a notification for the child project. - final Notification.Builder notificationBuilder = - org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification().toBuilder(); - final BomConsumedOrProcessedSubject.Builder subjectBuilder = - notificationBuilder.getSubject().unpack(BomConsumedOrProcessedSubject.class).toBuilder(); - subjectBuilder.setProject( - org.dependencytrack.notification.proto.v1.Project.newBuilder() - .setUuid(childProject.getUuid().toString()) - .setName(childProject.getName()) - .build()); - final Notification notification = notificationBuilder - .setSubject(Any.pack(subjectBuilder.build())) - .build(); - - // Only the rule with "notify children" ENABLED should have matched. - assertThat(router.route(List.of(notification))).satisfiesExactly(task -> { - assertThat(task.ruleId()).isEqualTo(ruleNotifyChildren.getId()); - assertThat(task.ruleName()).isEqualTo(ruleNotifyChildren.getName()); - assertThat(task.notification()).isEqualTo(notification); - }); - } + @Test + void routeShouldReturnEmptyListWhenNotificationsIsEmpty() { + assertThat(router.route(Collections.emptyList())).isEmpty(); + } - @Test - public void routeShouldMatchRulesWithMatchingProjectTags() throws Exception { - final var tagA = qm.persist(new Tag("a")); - final var tagB = qm.persist(new Tag("b")); - - // Create a project tagged with tag A. - final var project = new Project(); - project.setName("acme-app"); - project.setTags(Set.of(tagA)); - qm.persist(project); - - // Create a rule limited to tag A. - final var ruleTagA = new NotificationRule(); - ruleTagA.setName("A"); - ruleTagA.setScope(NotificationScope.PORTFOLIO); - ruleTagA.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleTagA.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleTagA.setEnabled(true); - ruleTagA.setTags(Set.of(tagA)); - qm.persist(ruleTagA); - - // Create a rule limited to tag B. - final var ruleTagB = new NotificationRule(); - ruleTagB.setName("B"); - ruleTagB.setScope(NotificationScope.PORTFOLIO); - ruleTagB.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleTagB.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleTagB.setEnabled(true); - ruleTagB.setTags(Set.of(tagB)); - qm.persist(ruleTagB); - - // Create a notification for the project tagged with tag A. - final Notification.Builder notificationBuilder = - org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification().toBuilder(); - final BomConsumedOrProcessedSubject.Builder subjectBuilder = - notificationBuilder.getSubject().unpack(BomConsumedOrProcessedSubject.class).toBuilder(); - subjectBuilder.setProject( - org.dependencytrack.notification.proto.v1.Project.newBuilder() - .setUuid(project.getUuid().toString()) - .setName(project.getName()) - .addTags(tagA.getName()) - .build()); - final Notification notification = notificationBuilder - .setSubject(Any.pack(subjectBuilder.build())) - .build(); - - // Only the rule limited to tag A must have matched. - assertThat(router.route(List.of(notification))).satisfiesExactly(task -> { - assertThat(task.ruleId()).isEqualTo(ruleTagA.getId()); - assertThat(task.ruleName()).isEqualTo(ruleTagA.getName()); - assertThat(task.notification()).isEqualTo(notification); - }); - } + @Test + void routeShouldMatchEnabledRules() { + // Create a rule that is enabled. + final var enabledRule = new NotificationRule(); + enabledRule.setName("A"); + enabledRule.setScope(NotificationScope.PORTFOLIO); + enabledRule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + enabledRule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + enabledRule.setEnabled(true); + qm.persist(enabledRule); + + // Create a rule that disabled. + final var disabledRule = new NotificationRule(); + disabledRule.setName("B"); + disabledRule.setScope(NotificationScope.PORTFOLIO); + disabledRule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + disabledRule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + disabledRule.setEnabled(false); + qm.persist(disabledRule); + + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + // Only the enabled rule should have matched. + assertThat(router.route(List.of(notification))).satisfiesExactly(result -> { + assertThat(result.ruleNames()).containsOnly(enabledRule.getName()); + assertThat(result.notification()).isEqualTo(notification); + }); + } - @Test - public void routeShouldMatchMultipleRules() { - final var ruleA = new NotificationRule(); - ruleA.setName("A"); - ruleA.setScope(NotificationScope.PORTFOLIO); - ruleA.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleA.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleA.setEnabled(true); - qm.persist(ruleA); - - final var ruleB = new NotificationRule(); - ruleB.setName("B"); - ruleB.setScope(NotificationScope.PORTFOLIO); - ruleB.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); - ruleB.setNotificationLevel(NotificationLevel.INFORMATIONAL); - ruleB.setEnabled(true); - qm.persist(ruleB); - - final Notification notification = org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification(); - - assertThat(router.route(List.of(notification))).satisfiesExactlyInAnyOrder( - task -> { - assertThat(task.ruleId()).isEqualTo(ruleA.getId()); - assertThat(task.ruleName()).isEqualTo(ruleA.getName()); - assertThat(task.notification()).isEqualTo(notification); - }, - task -> { - assertThat(task.ruleId()).isEqualTo(ruleB.getId()); - assertThat(task.ruleName()).isEqualTo(ruleB.getName()); - assertThat(task.notification()).isEqualTo(notification); - }); - } + @Test + void routeShouldMatchRulesWithMatchingProject() throws Exception { + final var projectA = new Project(); + projectA.setName("acme-app-a"); + qm.persist(projectA); + + final var projectB = new Project(); + projectB.setName("acme-app-b"); + qm.persist(projectB); + + // Create a rule that is limited to project A. + final var ruleProjectA = new NotificationRule(); + ruleProjectA.setName("A"); + ruleProjectA.setScope(NotificationScope.PORTFOLIO); + ruleProjectA.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleProjectA.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleProjectA.setEnabled(true); + ruleProjectA.setProjects(List.of(projectA)); + qm.persist(ruleProjectA); + + // Create a rule that is limited to project B. + final var ruleProjectB = new NotificationRule(); + ruleProjectB.setName("B"); + ruleProjectB.setScope(NotificationScope.PORTFOLIO); + ruleProjectB.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleProjectB.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleProjectB.setEnabled(true); + ruleProjectB.setProjects(List.of(projectB)); + qm.persist(ruleProjectB); + + // Create a notification for project A. + final Notification.Builder notificationBuilder = + TestNotificationFactory.createBomConsumedTestNotification().toBuilder(); + final BomConsumedOrProcessedSubject.Builder subjectBuilder = + notificationBuilder.getSubject().unpack(BomConsumedOrProcessedSubject.class).toBuilder(); + subjectBuilder.setProject( + org.dependencytrack.notification.proto.v1.Project.newBuilder() + .setUuid(projectA.getUuid().toString()) + .setName(projectA.getName()) + .build()); + final Notification notification = notificationBuilder + .setSubject(Any.pack(subjectBuilder.build())) + .build(); + + // Only the rule limited to project A must have matched. + assertThat(router.route(List.of(notification))).satisfiesExactly(result -> { + assertThat(result.ruleNames()).containsOnly(ruleProjectA.getName()); + assertThat(result.notification()).isEqualTo(notification); + }); + } + + @Test + void routeShouldMatchRulesWithMatchingParentProject() throws Exception { + final var parentProject = new Project(); + parentProject.setName("acme-app-parent"); + qm.persist(parentProject); + + final var childProject = new Project(); + childProject.setParent(parentProject); + childProject.setName("acme-app-child"); + qm.persist(childProject); + + // Create a rule that is limited to the parent project, + // but has the "notify children" feature ENABLED. + final var ruleNotifyChildren = new NotificationRule(); + ruleNotifyChildren.setName("A"); + ruleNotifyChildren.setScope(NotificationScope.PORTFOLIO); + ruleNotifyChildren.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleNotifyChildren.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleNotifyChildren.setEnabled(true); + ruleNotifyChildren.setProjects(List.of(parentProject)); + ruleNotifyChildren.setNotifyChildren(true); + qm.persist(ruleNotifyChildren); + + // Create a rule that is limited to the parent project, + // but has the "notify children" feature DISABLED. + final var ruleDoNotNotifyChildren = new NotificationRule(); + ruleDoNotNotifyChildren.setName("B"); + ruleDoNotNotifyChildren.setScope(NotificationScope.PORTFOLIO); + ruleDoNotNotifyChildren.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleDoNotNotifyChildren.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleDoNotNotifyChildren.setEnabled(true); + ruleDoNotNotifyChildren.setProjects(List.of(parentProject)); + ruleDoNotNotifyChildren.setNotifyChildren(false); + qm.persist(ruleDoNotNotifyChildren); + + // Create a notification for the child project. + final Notification.Builder notificationBuilder = + TestNotificationFactory.createBomConsumedTestNotification().toBuilder(); + final BomConsumedOrProcessedSubject.Builder subjectBuilder = + notificationBuilder.getSubject().unpack(BomConsumedOrProcessedSubject.class).toBuilder(); + subjectBuilder.setProject( + org.dependencytrack.notification.proto.v1.Project.newBuilder() + .setUuid(childProject.getUuid().toString()) + .setName(childProject.getName()) + .build()); + final Notification notification = notificationBuilder + .setSubject(Any.pack(subjectBuilder.build())) + .build(); + + // Only the rule with "notify children" ENABLED should have matched. + assertThat(router.route(List.of(notification))).satisfiesExactly(result -> { + assertThat(result.ruleNames()).containsOnly(ruleNotifyChildren.getName()); + assertThat(result.notification()).isEqualTo(notification); + }); + } - @SuppressWarnings("unused") - private static List routeShouldHandleAllNotificationTypesParams() { - final var notifications = new ArrayList(); + @Test + void routeShouldMatchRulesWithMatchingProjectTags() throws Exception { + final var tagA = qm.persist(new Tag("a")); + final var tagB = qm.persist(new Tag("b")); + + // Create a project tagged with tag A. + final var project = new Project(); + project.setName("acme-app"); + project.setTags(Set.of(tagA)); + qm.persist(project); + + // Create a rule limited to tag A. + final var ruleTagA = new NotificationRule(); + ruleTagA.setName("A"); + ruleTagA.setScope(NotificationScope.PORTFOLIO); + ruleTagA.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleTagA.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleTagA.setEnabled(true); + ruleTagA.setTags(Set.of(tagA)); + qm.persist(ruleTagA); + + // Create a rule limited to tag B. + final var ruleTagB = new NotificationRule(); + ruleTagB.setName("B"); + ruleTagB.setScope(NotificationScope.PORTFOLIO); + ruleTagB.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleTagB.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleTagB.setEnabled(true); + ruleTagB.setTags(Set.of(tagB)); + qm.persist(ruleTagB); + + // Create a notification for the project tagged with tag A. + final Notification.Builder notificationBuilder = + TestNotificationFactory.createBomConsumedTestNotification().toBuilder(); + final BomConsumedOrProcessedSubject.Builder subjectBuilder = + notificationBuilder.getSubject().unpack(BomConsumedOrProcessedSubject.class).toBuilder(); + subjectBuilder.setProject( + org.dependencytrack.notification.proto.v1.Project.newBuilder() + .setUuid(project.getUuid().toString()) + .setName(project.getName()) + .addTags(tagA.getName()) + .build()); + final Notification notification = notificationBuilder + .setSubject(Any.pack(subjectBuilder.build())) + .build(); + + // Only the rule limited to tag A must have matched. + assertThat(router.route(List.of(notification))).satisfiesExactly(result -> { + assertThat(result.ruleNames()).containsOnly(ruleTagA.getName()); + assertThat(result.notification()).isEqualTo(notification); + }); + } + + @Test + void routeShouldMatchMultipleRules() { + final var ruleA = new NotificationRule(); + ruleA.setName("A"); + ruleA.setScope(NotificationScope.PORTFOLIO); + ruleA.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleA.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleA.setEnabled(true); + qm.persist(ruleA); + + final var ruleB = new NotificationRule(); + ruleB.setName("B"); + ruleB.setScope(NotificationScope.PORTFOLIO); + ruleB.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + ruleB.setNotificationLevel(NotificationLevel.INFORMATIONAL); + ruleB.setEnabled(true); + qm.persist(ruleB); + + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + assertThat(router.route(List.of(notification))).satisfiesExactlyInAnyOrder(result -> { + assertThat(result.ruleNames()).containsOnly(ruleA.getName(), ruleB.getName()); + assertThat(result.notification()).isEqualTo(notification); + }); + } - for (final var scope : Scope.values()) { - for (final var group : Group.values()) { - for (final var level : Level.values()) { - final Notification notification = TestNotificationFactory.createTestNotification(scope, group, level); - if (notification != null) { - notifications.add(notification); + @SuppressWarnings("unused") + private static List routeShouldHandleAllNotificationTypesParams() { + final var notifications = new ArrayList(); + + for (final var scope : Scope.values()) { + for (final var group : Group.values()) { + for (final var level : Level.values()) { + final Notification notification = TestNotificationFactory.createTestNotification(scope, group, level); + if (notification != null) { + notifications.add(notification); + } } } } + + return notifications; } - return notifications; - } + @ParameterizedTest + @MethodSource("routeShouldHandleAllNotificationTypesParams") + void routeShouldHandleAllNotificationTypes(final Notification notification) { + final var rule = new NotificationRule(); + rule.setName("foo"); + rule.setScope(convert(notification.getScope())); + rule.setNotifyOn(Set.of(convert(notification.getGroup()))); + rule.setNotificationLevel(convert(notification.getLevel())); + rule.setEnabled(true); + qm.persist(rule); + + assertThat(router.route(List.of(notification))).satisfiesExactly(result -> { + assertThat(result.ruleNames()).containsOnly(rule.getName()); + assertThat(result.notification()).isEqualTo(notification); + }); + } - @ParameterizedTest - @MethodSource("routeShouldHandleAllNotificationTypesParams") - public void routeShouldHandleAllNotificationTypes(final Notification notification) { - final var rule = new NotificationRule(); - rule.setName("foo"); - rule.setScope(convert(notification.getScope())); - rule.setNotifyOn(Set.of(convert(notification.getGroup()))); - rule.setNotificationLevel(convert(notification.getLevel())); - rule.setEnabled(true); - qm.persist(rule); - - assertThat(router.route(List.of(notification))).satisfiesExactly(task -> { - assertThat(task.ruleId()).isEqualTo(rule.getId()); - assertThat(task.ruleName()).isEqualTo(rule.getName()); - assertThat(task.notification()).isEqualTo(notification); - }); } } \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationRuleContactsSupplierTest.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationRuleContactsSupplierTest.java index 7564af0ecc..b8fbeff7d3 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/NotificationRuleContactsSupplierTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationRuleContactsSupplierTest.java @@ -40,7 +40,7 @@ void beforeEach() { final NotificationPublisher publisher = qm.createNotificationPublisher( "test", "description", - "publisherClass", + "extensionName", "templateContent", "templateMimeType", false); @@ -54,7 +54,7 @@ void beforeEach() { @Test void shouldReturnEmptySetWhenRuleHasNoTeams() { - final var supplier = new NotificationRuleContactsSupplier(rule.getId()); + final var supplier = new NotificationRuleContactsSupplier(rule.getName()); assertThat(supplier.get()).isEmpty(); } @@ -67,7 +67,7 @@ void shouldReturnEmptySetWhenTeamHasNoUsers() { rule.setTeams(Set.of(team)); - final var supplier = new NotificationRuleContactsSupplier(rule.getId()); + final var supplier = new NotificationRuleContactsSupplier(rule.getName()); assertThat(supplier.get()).isEmpty(); } @@ -86,7 +86,7 @@ void shouldReturnSetOfUserContacts() { rule.setTeams(Set.of(team)); - final var supplier = new NotificationRuleContactsSupplier(rule.getId()); + final var supplier = new NotificationRuleContactsSupplier(rule.getName()); assertThat(supplier.get()).satisfiesExactly(contact -> { assertThat(contact.username()).isEqualTo("test"); diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationScopeTest.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationScopeTest.java index 9fd90749c4..e92bb8fb39 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/NotificationScopeTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationScopeTest.java @@ -21,10 +21,10 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -public class NotificationScopeTest { +class NotificationScopeTest { @Test - public void testEnums() { + void testEnums() { Assertions.assertEquals("SYSTEM", NotificationScope.SYSTEM.name()); Assertions.assertEquals("PORTFOLIO", NotificationScope.PORTFOLIO.name()); } diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationSubsystemArchitectureTest.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationSubsystemArchitectureTest.java index 37cf415be8..2c9de9345d 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/NotificationSubsystemArchitectureTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationSubsystemArchitectureTest.java @@ -38,10 +38,10 @@ DoNotIncludeJars.class, DoNotIncludeTests.class, }) -public class NotificationSubsystemArchitectureTest { +class NotificationSubsystemArchitectureTest { @ArchTest - public static final ArchRule mustOnlyBeCreatedThroughNotificationFactory = + static final ArchRule mustOnlyBeCreatedThroughNotificationFactory = noClasses() .that().resideOutsideOfPackages( "org.dependencytrack.notification..", @@ -52,7 +52,7 @@ public class NotificationSubsystemArchitectureTest { This ensures that critical fields such as ID and timestamp are always set."""); @ArchTest - public static final ArchRule mustNotModifyCoreNotificationFieldsOutsideOfNotificationFactory = + static final ArchRule mustNotModifyCoreNotificationFieldsOutsideOfNotificationFactory = noClasses() .that().areNotAssignableTo(org.dependencytrack.notification.api.NotificationFactory.class) .and().areNotAssignableTo(org.dependencytrack.notification.api.TestNotificationFactory.class) @@ -65,7 +65,7 @@ public class NotificationSubsystemArchitectureTest { only be set by NotificationFactory. This ensures consistency."""); @ArchTest - public static final ArchRule mustNotUseJdoApi = + static final ArchRule mustNotUseJdoApi = noClasses() .that().resideInAPackage("org.dependencytrack.notification..") .and().areNotAssignableTo(JdoNotificationEmitter.class) diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationTestUtil.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationTestUtil.java new file mode 100644 index 0000000000..cbe3874bce --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationTestUtil.java @@ -0,0 +1,58 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.notification; + +import org.dependencytrack.model.NotificationPublisher; +import org.dependencytrack.model.NotificationRule; +import org.dependencytrack.persistence.QueryManager; + +import java.util.Set; + +/** + * @since 5.7.0 + */ +public final class NotificationTestUtil { + + private NotificationTestUtil() { + } + + public static NotificationRule createCatchAllNotificationRule( + QueryManager qm, + NotificationScope scope) { + return qm.callInTransaction(() -> { + final NotificationPublisher publisher = qm.createNotificationPublisher( + "catchAllPublisher", + "description", + "extensionName", + "templateContent", + "templateMimeType", + /* isDefault */ false); + + final NotificationRule rule = qm.createNotificationRule( + "catchAll", + scope, + NotificationLevel.INFORMATIONAL, + publisher); + rule.setNotifyOn(Set.of(NotificationGroup.values())); + + return rule; + }); + } + +} diff --git a/apiserver/src/test/java/org/dependencytrack/notification/PublishNotificationWorkflowTest.java b/apiserver/src/test/java/org/dependencytrack/notification/PublishNotificationWorkflowTest.java new file mode 100644 index 0000000000..0af6fc2bc4 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/notification/PublishNotificationWorkflowTest.java @@ -0,0 +1,302 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.notification; + +import io.github.resilience4j.core.IntervalFunction; +import io.smallrye.config.SmallRyeConfigBuilder; +import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.dex.activity.DeleteFilesActivity; +import org.dependencytrack.dex.engine.api.DexEngine; +import org.dependencytrack.dex.engine.api.TaskType; +import org.dependencytrack.dex.engine.api.TaskWorkerOptions; +import org.dependencytrack.dex.engine.api.WorkflowRun; +import org.dependencytrack.dex.engine.api.WorkflowRunStatus; +import org.dependencytrack.dex.engine.api.request.CreateTaskQueueRequest; +import org.dependencytrack.dex.engine.api.request.CreateWorkflowRunRequest; +import org.dependencytrack.dex.testing.WorkflowTestExtension; +import org.dependencytrack.filestorage.api.FileStorage; +import org.dependencytrack.filestorage.memory.MemoryFileStoragePlugin; +import org.dependencytrack.filestorage.proto.v1.FileMetadata; +import org.dependencytrack.model.NotificationRule; +import org.dependencytrack.notification.api.publishing.NotificationPublisher; +import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.notification.publishing.DefaultNotificationPublishersPlugin; +import org.dependencytrack.notification.templating.pebble.PebbleNotificationTemplateRendererFactory; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.proto.internal.workflow.v1.DeleteFilesArgument; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationActivityArg; +import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationWorkflowArg; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.io.ByteArrayInputStream; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.dependencytrack.dex.api.payload.PayloadConverters.protoConverter; +import static org.dependencytrack.dex.api.payload.PayloadConverters.voidConverter; +import static org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification; +import static org.dependencytrack.notification.api.TestNotificationFactory.createBomProcessedTestNotification; + +class PublishNotificationWorkflowTest extends PersistenceCapableTest { + + @RegisterExtension + private final WorkflowTestExtension workflowTest = + new WorkflowTestExtension(postgresContainer); + + private PluginManager pluginManager; + + @BeforeEach + void beforeEach() { + pluginManager = new PluginManager( + new SmallRyeConfigBuilder().build(), + secretName -> null, + List.of(FileStorage.class, NotificationPublisher.class)); + pluginManager.loadPlugins(List.of( + new MemoryFileStoragePlugin(), + new DefaultNotificationPublishersPlugin())); + + final DexEngine engine = workflowTest.getEngine(); + + engine.registerWorkflow( + new PublishNotificationWorkflow(), + protoConverter(PublishNotificationWorkflowArg.class), + voidConverter(), + Duration.ofSeconds(15)); + engine.registerActivity( + new PublishNotificationActivity( + pluginManager, + secretName -> null, + new PebbleNotificationTemplateRendererFactory(Collections.emptyMap())), + protoConverter(PublishNotificationActivityArg.class), + voidConverter(), + Duration.ofSeconds(15)); + engine.registerActivity( + new DeleteFilesActivity(pluginManager), + protoConverter(DeleteFilesArgument.class), + voidConverter(), + Duration.ofSeconds(15)); + + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "default", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "default", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "notifications", 1)); + + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.WORKFLOW, "workflow-worker", "default", 1) + .withMinPollInterval(Duration.ofMillis(25)) + .withPollBackoffFunction(IntervalFunction.of(25))); + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.ACTIVITY, "activity-worker-default", "default", 1) + .withMinPollInterval(Duration.ofMillis(25)) + .withPollBackoffFunction(IntervalFunction.of(25))); + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.ACTIVITY, "activity-worker-notification", "notifications", 1) + .withMinPollInterval(Duration.ofMillis(25)) + .withPollBackoffFunction(IntervalFunction.of(25))); + + engine.start(); + } + + @AfterEach + void afterEach() { + if (pluginManager != null) { + pluginManager.close(); + } + } + + @Test + void shouldFailWhenArgumentIsNull() { + final UUID runId = workflowTest.getEngine().createRun( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class)); + + final WorkflowRun run = workflowTest.awaitRunStatus(runId, WorkflowRunStatus.FAILED); + assertThat(run).isNotNull(); + assertThat(run.failure()).isNotNull(); + assertThat(run.failure().getMessage()).isEqualTo("No argument provided"); + } + + @Test + void shouldFailWhenRuleDoesNotExist() { + final Notification notification = createBomConsumedTestNotification(); + + final var argument = PublishNotificationWorkflowArg.newBuilder() + .setNotificationId(notification.getId()) + .addNotificationRuleNames("foo") + .setNotification(notification) + .build(); + + final UUID runId = workflowTest.getEngine().createRun( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class) + .withArgument(argument)); + + final WorkflowRun run = workflowTest.awaitRunStatus(runId, WorkflowRunStatus.FAILED); + assertThat(run).isNotNull(); + assertThat(run.eventHistory()).anySatisfy(event -> { + assertThat(event.hasActivityTaskFailed()).isTrue(); + assertThat(event.getActivityTaskFailed().getFailure().getMessage()) + .isEqualTo("Notification rule 'foo' does not exist"); + }); + } + + @Test + void shouldFailWhenPublisherExtensionDoesNotExist() { + final Notification notification = createBomConsumedTestNotification(); + + final NotificationRule rule = createRule("nonexistent-publisher"); + + final var argument = PublishNotificationWorkflowArg.newBuilder() + .setNotificationId(notification.getId()) + .addNotificationRuleNames(rule.getName()) + .setNotification(notification) + .build(); + + final UUID runId = workflowTest.getEngine().createRun( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class) + .withArgument(argument)); + + final WorkflowRun run = workflowTest.awaitRunStatus(runId, WorkflowRunStatus.FAILED); + assertThat(run).isNotNull(); + assertThat(run.eventHistory()).anySatisfy(event -> { + assertThat(event.hasActivityTaskFailed()).isTrue(); + assertThat(event.getActivityTaskFailed().getFailure().getCause().getMessage()) + .startsWith("No extension named 'nonexistent-publisher' exists"); + }); + } + + @Test + void shouldSucceedWhenPublishingNotificationWithInlineNotification() { + final Notification notification = createBomConsumedTestNotification(); + + final NotificationRule rule = createRule("console"); + + final var argument = PublishNotificationWorkflowArg.newBuilder() + .setNotificationId(notification.getId()) + .addNotificationRuleNames(rule.getName()) + .setNotification(notification) + .build(); + + final UUID runId = workflowTest.getEngine().createRun( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class) + .withArgument(argument)); + + final WorkflowRun run = workflowTest.awaitRunStatus(runId, WorkflowRunStatus.COMPLETED); + assertThat(run).isNotNull(); + assertThat(run.status()).isEqualTo(WorkflowRunStatus.COMPLETED); + } + + @Test + void shouldSucceedWhenPublishingNotificationFromFileStorage() throws Exception { + final Notification notification = createBomProcessedTestNotification(); + + final NotificationRule rule = createRule("console"); + + final FileMetadata fileMetadata; + try (final var fileStorage = pluginManager.getExtension(FileStorage.class)) { + fileMetadata = fileStorage.store( + "notification-%s.bin".formatted(notification.getId()), + "application/protobuf", + new ByteArrayInputStream(notification.toByteArray())); + } + + final var argument = PublishNotificationWorkflowArg.newBuilder() + .setNotificationId(notification.getId()) + .addNotificationRuleNames(rule.getName()) + .setNotificationFileMetadata(fileMetadata) + .build(); + + final UUID runId = workflowTest.getEngine().createRun( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class) + .withArgument(argument)); + + final WorkflowRun run = workflowTest.awaitRunStatus(runId, WorkflowRunStatus.COMPLETED); + assertThat(run).isNotNull(); + assertThat(run.status()).isEqualTo(WorkflowRunStatus.COMPLETED); + + try (final var fileStorage = pluginManager.getExtension(FileStorage.class)) { + assertThatExceptionOfType(java.nio.file.NoSuchFileException.class) + .isThrownBy(() -> fileStorage.get(fileMetadata)); + } + } + + + @Test + void shouldFailWhenNoNotificationProvided() { + final NotificationRule rule = createRule("console"); + + final var argument = PublishNotificationWorkflowArg.newBuilder() + .addNotificationRuleNames(rule.getName()) + .build(); + + final UUID runId = workflowTest.getEngine().createRun( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class) + .withArgument(argument)); + + final WorkflowRun run = workflowTest.awaitRunStatus(runId, WorkflowRunStatus.FAILED); + assertThat(run).isNotNull(); + assertThat(run.failure()).isNotNull(); + assertThat(run.failure().getMessage()).isEqualTo("Neither notification nor notification file metadata provided"); + } + + @Test + void shouldSucceedWithoutDeletingFileWhenNoFileMetadataProvided() { + final Notification notification = createBomConsumedTestNotification(); + + final NotificationRule rule = createRule("console"); + + final var argument = PublishNotificationWorkflowArg.newBuilder() + .setNotificationId(notification.getId()) + .addNotificationRuleNames(rule.getName()) + .setNotification(notification) + .build(); + + final UUID runId = workflowTest.getEngine().createRun( + new CreateWorkflowRunRequest<>(PublishNotificationWorkflow.class) + .withArgument(argument)); + + final WorkflowRun run = workflowTest.awaitRunStatus(runId, WorkflowRunStatus.COMPLETED); + assertThat(run).isNotNull(); + assertThat(run.status()).isEqualTo(WorkflowRunStatus.COMPLETED); + } + + private NotificationRule createRule(String publisherExtensionName) { + final var publisher = new org.dependencytrack.model.NotificationPublisher(); + publisher.setName("Test Publisher"); + publisher.setExtensionName(publisherExtensionName); + publisher.setTemplate("{{ notification.subject.project.name }}"); + publisher.setTemplateMimeType("text/plain"); + qm.persist(publisher); + + final var rule = new NotificationRule(); + rule.setName("Test Rule"); + rule.setEnabled(true); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + rule.setPublisher(publisher); + return qm.persist(rule); + } + +} \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java b/apiserver/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java deleted file mode 100644 index c11a90938f..0000000000 --- a/apiserver/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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) OWASP Foundation. All Rights Reserved. - */ -package org.dependencytrack.notification.publisher; - -import jakarta.ws.rs.core.MediaType; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import static org.dependencytrack.notification.publisher.PublisherClass.ConsolePublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.JiraPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.MattermostPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.MsTeamsPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.SendMailPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.SlackPublisher; -import static org.dependencytrack.notification.publisher.PublisherClass.WebhookPublisher; - -public class DefaultNotificationPublishersTest { - - @Test - public void testEnums() { - Assertions.assertEquals("SLACK", DefaultNotificationPublishers.SLACK.name()); - Assertions.assertEquals("MS_TEAMS", DefaultNotificationPublishers.MS_TEAMS.name()); - Assertions.assertEquals("MATTERMOST", DefaultNotificationPublishers.MATTERMOST.name()); - Assertions.assertEquals("EMAIL", DefaultNotificationPublishers.EMAIL.name()); - Assertions.assertEquals("CONSOLE", DefaultNotificationPublishers.CONSOLE.name()); - Assertions.assertEquals("WEBHOOK", DefaultNotificationPublishers.WEBHOOK.name()); - Assertions.assertEquals("JIRA", DefaultNotificationPublishers.JIRA.name()); - } - - @Test - public void testSlack() { - Assertions.assertEquals("Slack", DefaultNotificationPublishers.SLACK.getPublisherName()); - Assertions.assertEquals("Publishes notifications to a Slack channel", DefaultNotificationPublishers.SLACK.getPublisherDescription()); - Assertions.assertEquals(SlackPublisher, DefaultNotificationPublishers.SLACK.getPublisherClass()); - Assertions.assertEquals("/org/dependencytrack/notification/publishing/slack/DefaultTemplate.peb", DefaultNotificationPublishers.SLACK.getPublisherTemplateFile()); - Assertions.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.SLACK.getTemplateMimeType()); - Assertions.assertTrue(DefaultNotificationPublishers.SLACK.isDefaultPublisher()); - } - - @Test - public void testMsTeams() { - Assertions.assertEquals("Microsoft Teams", DefaultNotificationPublishers.MS_TEAMS.getPublisherName()); - Assertions.assertEquals("Publishes notifications to a Microsoft Teams channel", DefaultNotificationPublishers.MS_TEAMS.getPublisherDescription()); - Assertions.assertEquals(MsTeamsPublisher, DefaultNotificationPublishers.MS_TEAMS.getPublisherClass()); - Assertions.assertEquals("/org/dependencytrack/notification/publishing/msteams/DefaultTemplate.peb", DefaultNotificationPublishers.MS_TEAMS.getPublisherTemplateFile()); - Assertions.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.MS_TEAMS.getTemplateMimeType()); - Assertions.assertTrue(DefaultNotificationPublishers.MS_TEAMS.isDefaultPublisher()); - } - - @Test - public void testMattermost() { - Assertions.assertEquals("Mattermost", DefaultNotificationPublishers.MATTERMOST.getPublisherName()); - Assertions.assertEquals("Publishes notifications to a Mattermost channel", DefaultNotificationPublishers.MATTERMOST.getPublisherDescription()); - Assertions.assertEquals(MattermostPublisher, DefaultNotificationPublishers.MATTERMOST.getPublisherClass()); - Assertions.assertEquals("/org/dependencytrack/notification/publishing/mattermost/DefaultTemplate.peb", DefaultNotificationPublishers.MATTERMOST.getPublisherTemplateFile()); - Assertions.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.MATTERMOST.getTemplateMimeType()); - Assertions.assertTrue(DefaultNotificationPublishers.MATTERMOST.isDefaultPublisher()); - } - - @Test - public void testEmail() { - Assertions.assertEquals("Email", DefaultNotificationPublishers.EMAIL.getPublisherName()); - Assertions.assertEquals("Sends notifications to an email address", DefaultNotificationPublishers.EMAIL.getPublisherDescription()); - Assertions.assertEquals(SendMailPublisher, DefaultNotificationPublishers.EMAIL.getPublisherClass()); - Assertions.assertEquals("/org/dependencytrack/notification/publishing/email/DefaultTemplate.peb", DefaultNotificationPublishers.EMAIL.getPublisherTemplateFile()); - Assertions.assertEquals(MediaType.TEXT_PLAIN, DefaultNotificationPublishers.EMAIL.getTemplateMimeType()); - Assertions.assertTrue(DefaultNotificationPublishers.EMAIL.isDefaultPublisher()); - } - - @Test - public void testConsole() { - Assertions.assertEquals("Console", DefaultNotificationPublishers.CONSOLE.getPublisherName()); - Assertions.assertEquals("Displays notifications on the system console", DefaultNotificationPublishers.CONSOLE.getPublisherDescription()); - Assertions.assertEquals(ConsolePublisher, DefaultNotificationPublishers.CONSOLE.getPublisherClass()); - Assertions.assertEquals("/org/dependencytrack/notification/publishing/console/DefaultTemplate.peb", DefaultNotificationPublishers.CONSOLE.getPublisherTemplateFile()); - Assertions.assertEquals(MediaType.TEXT_PLAIN, DefaultNotificationPublishers.CONSOLE.getTemplateMimeType()); - Assertions.assertTrue(DefaultNotificationPublishers.CONSOLE.isDefaultPublisher()); - } - - @Test - public void testWebhook() { - Assertions.assertEquals("Outbound Webhook", DefaultNotificationPublishers.WEBHOOK.getPublisherName()); - Assertions.assertEquals("Publishes notifications to a configurable endpoint", DefaultNotificationPublishers.WEBHOOK.getPublisherDescription()); - Assertions.assertEquals(WebhookPublisher, DefaultNotificationPublishers.WEBHOOK.getPublisherClass()); - Assertions.assertEquals("/org/dependencytrack/notification/publishing/webhook/DefaultTemplate.peb", DefaultNotificationPublishers.WEBHOOK.getPublisherTemplateFile()); - Assertions.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.WEBHOOK.getTemplateMimeType()); - Assertions.assertTrue(DefaultNotificationPublishers.WEBHOOK.isDefaultPublisher()); - } - - @Test - public void testJira() { - Assertions.assertEquals("Jira", DefaultNotificationPublishers.JIRA.getPublisherName()); - Assertions.assertEquals("Creates a Jira issue in a configurable Jira instance and queue", DefaultNotificationPublishers.JIRA.getPublisherDescription()); - Assertions.assertEquals(JiraPublisher, DefaultNotificationPublishers.JIRA.getPublisherClass()); - Assertions.assertEquals("/org/dependencytrack/notification/publishing/jira/DefaultTemplate.peb", DefaultNotificationPublishers.JIRA.getPublisherTemplateFile()); - Assertions.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.JIRA.getTemplateMimeType()); - Assertions.assertTrue(DefaultNotificationPublishers.JIRA.isDefaultPublisher()); - } -} diff --git a/apiserver/src/test/java/org/dependencytrack/persistence/DatabaseSeedingInitTaskTest.java b/apiserver/src/test/java/org/dependencytrack/persistence/DatabaseSeedingInitTaskTest.java index 2cd16eac80..997309dae1 100644 --- a/apiserver/src/test/java/org/dependencytrack/persistence/DatabaseSeedingInitTaskTest.java +++ b/apiserver/src/test/java/org/dependencytrack/persistence/DatabaseSeedingInitTaskTest.java @@ -29,10 +29,8 @@ import org.dependencytrack.model.DefaultRepository; import org.dependencytrack.model.License; import org.dependencytrack.model.LicenseGroup; -import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.Repository; import org.dependencytrack.model.Role; -import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; import org.eclipse.microprofile.config.ConfigProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -128,17 +126,6 @@ public void test() throws Exception { assertThat(licenseGroup.getLicenses()).isNotEmpty(); }); - final List notificationPublishers = qm.getAllNotificationPublishers(); - assertThat(notificationPublishers).hasSize(DefaultNotificationPublishers.values().length); - assertThat(notificationPublishers).allSatisfy(notificationPublisher -> { - assertThat(notificationPublisher.getName()).isNotBlank(); - assertThat(notificationPublisher.getPublisherClass()).isNotBlank(); - assertThat(notificationPublisher.getDescription()).isNotBlank(); - assertThat(notificationPublisher.getTemplate()).isNotBlank(); - assertThat(notificationPublisher.getTemplateMimeType()).isNotBlank(); - assertThat(notificationPublisher.isDefaultPublisher()).isTrue(); - }); - final List repositories = qm.getRepositories().getList(Repository.class); assertThat(repositories).hasSize(DefaultRepository.values().length); assertThat(repositories).allSatisfy(repository -> { diff --git a/apiserver/src/test/java/org/dependencytrack/persistence/NotificationQueryManagerTest.java b/apiserver/src/test/java/org/dependencytrack/persistence/NotificationQueryManagerTest.java deleted file mode 100644 index 1adb5dbb67..0000000000 --- a/apiserver/src/test/java/org/dependencytrack/persistence/NotificationQueryManagerTest.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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) OWASP Foundation. All Rights Reserved. - */ -package org.dependencytrack.persistence; - -import org.dependencytrack.PersistenceCapableTest; -import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiTransaction; - -public class NotificationQueryManagerTest extends PersistenceCapableTest { - - @Test - public void testGetNotificationPublisher() { - useJdbiTransaction(DatabaseSeedingInitTask::seedDefaultNotificationPublishers); - - var publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - Assertions.assertEquals("SlackPublisher", publisher.getPublisherClass()); - } - - @Test - public void testGetDefaultNotificationPublisher() { - useJdbiTransaction(DatabaseSeedingInitTask::seedDefaultNotificationPublishers); - - var publisher = qm.getDefaultNotificationPublisherByName(DefaultNotificationPublishers.SLACK.getPublisherName()); - Assertions.assertEquals("Slack", publisher.getName()); - Assertions.assertEquals("SlackPublisher", publisher.getPublisherClass()); - - publisher.setPublisherClass("UpdatedClassName"); - qm.updateNotificationPublisher(publisher); - publisher = qm.getDefaultNotificationPublisherByName(DefaultNotificationPublishers.SLACK.getPublisherName()); - Assertions.assertEquals("Slack", publisher.getName()); - Assertions.assertEquals("UpdatedClassName", publisher.getPublisherClass()); - } -} diff --git a/apiserver/src/test/java/org/dependencytrack/persistence/jdbi/ProjectDaoTest.java b/apiserver/src/test/java/org/dependencytrack/persistence/jdbi/ProjectDaoTest.java index 443e461739..e8affa69ff 100644 --- a/apiserver/src/test/java/org/dependencytrack/persistence/jdbi/ProjectDaoTest.java +++ b/apiserver/src/test/java/org/dependencytrack/persistence/jdbi/ProjectDaoTest.java @@ -208,7 +208,7 @@ public void testCascadeDeleteProject() { qm.persist(vex); // Create a notification rule and associate projectChild with it. - final NotificationPublisher notificationPublisher = qm.createNotificationPublisher("name", "description", "publisherClass", "templateContent", "templateMimeType", true); + final NotificationPublisher notificationPublisher = qm.createNotificationPublisher("name", "description", "extensionName", "templateContent", "templateMimeType", true); final NotificationRule notificationRule = qm.createNotificationRule("name", NotificationScope.PORTFOLIO, NotificationLevel.WARNING, notificationPublisher); notificationRule.getProjects().add(projectChild); qm.persist(notificationRule); diff --git a/apiserver/src/test/java/org/dependencytrack/plugin/PluginInitializerTest.java b/apiserver/src/test/java/org/dependencytrack/plugin/PluginInitializerTest.java index ff9fee18eb..ad26cbaa77 100644 --- a/apiserver/src/test/java/org/dependencytrack/plugin/PluginInitializerTest.java +++ b/apiserver/src/test/java/org/dependencytrack/plugin/PluginInitializerTest.java @@ -24,7 +24,7 @@ import org.dependencytrack.filestorage.local.LocalFileStoragePlugin; import org.dependencytrack.filestorage.memory.MemoryFileStoragePlugin; import org.dependencytrack.filestorage.s3.S3FileStoragePlugin; -import org.dependencytrack.notification.publishing.DefaultNotificationPublisherPlugin; +import org.dependencytrack.notification.publishing.DefaultNotificationPublishersPlugin; import org.dependencytrack.secret.TestSecretManager; import org.dependencytrack.secret.management.SecretManager; import org.dependencytrack.vulndatasource.github.GitHubVulnDataSourcePlugin; @@ -86,7 +86,7 @@ void shouldLoadAndUnloadPlugins() { "notification-publisher", "vuln-data-source"); assertThat(pluginManager.getLoadedPlugins()).satisfiesExactlyInAnyOrder( - plugin -> assertThat(plugin).isInstanceOf(DefaultNotificationPublisherPlugin.class), + plugin -> assertThat(plugin).isInstanceOf(DefaultNotificationPublishersPlugin.class), plugin -> assertThat(plugin).isInstanceOf(GitHubVulnDataSourcePlugin.class), plugin -> assertThat(plugin).isInstanceOf(LocalFileStoragePlugin.class), plugin -> assertThat(plugin).isInstanceOf(MemoryFileStoragePlugin.class), diff --git a/apiserver/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java b/apiserver/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java index 920f172243..d10e478965 100644 --- a/apiserver/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java +++ b/apiserver/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java @@ -27,7 +27,6 @@ import org.dependencytrack.model.Classifier; import org.dependencytrack.model.Component; import org.dependencytrack.model.ComponentIdentity; -import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Epss; import org.dependencytrack.model.FetchStatus; import org.dependencytrack.model.IntegrityMetaComponent; @@ -48,12 +47,7 @@ import org.dependencytrack.model.VulnerabilityAlias; import org.dependencytrack.persistence.command.MakeViolationAnalysisCommand; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; -import uk.org.webcompere.systemstubs.jupiter.SystemStub; -import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; import java.math.BigDecimal; import java.time.Instant; @@ -66,24 +60,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; -@ExtendWith(SystemStubsExtension.class) -public class CelPolicyEngineTest extends PersistenceCapableTest { - - @SystemStub - public final EnvironmentVariables environmentVariables = new EnvironmentVariables() - .set("FILE_STORAGE_EXTENSION_MEMORY_ENABLED", "true") - .set("FILE_STORAGE_DEFAULT_EXTENSION", "memory"); - - @BeforeEach - public void before() throws Exception { - super.before(); - - // Enable processing of CycloneDX BOMs - qm.createConfigProperty(ConfigPropertyConstants.ACCEPT_ARTIFACT_CYCLONEDX.getGroupName(), - ConfigPropertyConstants.ACCEPT_ARTIFACT_CYCLONEDX.getPropertyName(), "true", - ConfigPropertyConstants.ACCEPT_ARTIFACT_CYCLONEDX.getPropertyType(), - ConfigPropertyConstants.ACCEPT_ARTIFACT_CYCLONEDX.getDescription()); - } +class CelPolicyEngineTest extends PersistenceCapableTest { /** * (Regression-)Test for ensuring that all data available in the policy expression context @@ -97,7 +74,7 @@ public void before() throws Exception { * */ @Test - public void testEvaluateProjectWithAllFields() { + void testEvaluateProjectWithAllFields() { final var project = new Project(); project.setUuid(UUID.fromString("d7173786-60aa-4a4f-a950-c92fe6422307")); project.setGroup("projectGroup"); @@ -382,7 +359,7 @@ && has(project.last_bom_import) } @Test - public void testEvaluateProjectWithPolicyOperatorAnyAndAllConditionsMatching() { + void testEvaluateProjectWithPolicyOperatorAnyAndAllConditionsMatching() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.name == "acme-app" @@ -405,7 +382,7 @@ public void testEvaluateProjectWithPolicyOperatorAnyAndAllConditionsMatching() { } @Test - public void testEvaluateProjectWithPolicyOperatorForComponentAgeLessThan() throws MalformedPackageURLException { + void testEvaluateProjectWithPolicyOperatorForComponentAgeLessThan() throws MalformedPackageURLException { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.compare_age("NUMERIC_LESS_THAN", "P666D") @@ -435,7 +412,7 @@ public void testEvaluateProjectWithPolicyOperatorForComponentAgeLessThan() throw } @Test - public void testEvaluateProjectWithPolicyOperatorForVersionDistance() { + void testEvaluateProjectWithPolicyOperatorForVersionDistance() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.version_distance(">=", v1.VersionDistance{ major: \"0\", minor: \"1\", patch: \"?\" }) @@ -472,7 +449,7 @@ public void testEvaluateProjectWithPolicyOperatorForVersionDistance() { } @Test - public void testEvaluateProjectWithPolicyOperatorForComponentAgeGreaterThan() throws MalformedPackageURLException { + void testEvaluateProjectWithPolicyOperatorForComponentAgeGreaterThan() throws MalformedPackageURLException { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.compare_age("<", "P666D") @@ -503,7 +480,7 @@ public void testEvaluateProjectWithPolicyOperatorForComponentAgeGreaterThan() th } @Test - public void testEvaluateProjectWithPublishedAtComparisonGreaterThan() throws Exception { + void testEvaluateProjectWithPublishedAtComparisonGreaterThan() throws Exception { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ (now - component.published_at) > duration("365d") @@ -532,7 +509,7 @@ public void testEvaluateProjectWithPublishedAtComparisonGreaterThan() throws Exc } @Test - public void testEvaluateProjectWithPublishedAtComparisonLessThan() throws Exception { + void testEvaluateProjectWithPublishedAtComparisonLessThan() throws Exception { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ (now - component.published_at) < duration("365d") @@ -561,7 +538,7 @@ public void testEvaluateProjectWithPublishedAtComparisonLessThan() throws Except } @Test - public void testEvaluateProjectWithPublishedAtComparisonUnknown() throws Exception { + void testEvaluateProjectWithPublishedAtComparisonUnknown() throws Exception { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ (now - component.published_at) > duration("365d") @@ -593,7 +570,7 @@ public void testEvaluateProjectWithPublishedAtComparisonUnknown() throws Excepti } @Test - public void testEvaluateProjectWithPublishedAtComparisonUnknownAndHasCheck() throws Exception { + void testEvaluateProjectWithPublishedAtComparisonUnknownAndHasCheck() throws Exception { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ has(component.published_at) && (now - component.published_at) > duration("365d") @@ -623,7 +600,7 @@ public void testEvaluateProjectWithPublishedAtComparisonUnknownAndHasCheck() thr } @Test - public void testEvaluateProjectWithPolicyOperatorAnyAndNotAllConditionsMatching() { + void testEvaluateProjectWithPolicyOperatorAnyAndNotAllConditionsMatching() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.name == "acme-app" @@ -646,7 +623,7 @@ public void testEvaluateProjectWithPolicyOperatorAnyAndNotAllConditionsMatching( } @Test - public void testEvaluateProjectWithPolicyOperatorAnyAndNoConditionsMatching() { + void testEvaluateProjectWithPolicyOperatorAnyAndNoConditionsMatching() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.name == "someOtherProjectThatIsNotAcmeApp" @@ -669,7 +646,7 @@ public void testEvaluateProjectWithPolicyOperatorAnyAndNoConditionsMatching() { } @Test - public void testEvaluateProjectWithPolicyOperatorAllAndAllConditionsMatching() { + void testEvaluateProjectWithPolicyOperatorAllAndAllConditionsMatching() { final var policy = qm.createPolicy("policy", Policy.Operator.ALL, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.name == "acme-app" @@ -692,7 +669,7 @@ public void testEvaluateProjectWithPolicyOperatorAllAndAllConditionsMatching() { } @Test - public void testEvaluateProjectWithPolicyOperatorAllAndNotAllConditionsMatching() { + void testEvaluateProjectWithPolicyOperatorAllAndNotAllConditionsMatching() { final var policy = qm.createPolicy("policy", Policy.Operator.ALL, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.name == "acme-app" @@ -715,7 +692,7 @@ public void testEvaluateProjectWithPolicyOperatorAllAndNotAllConditionsMatching( } @Test - public void testEvaluateProjectWithPolicyOperatorAllAndNoConditionsMatching() { + void testEvaluateProjectWithPolicyOperatorAllAndNoConditionsMatching() { final var policy = qm.createPolicy("policy", Policy.Operator.ALL, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.name == "someOtherProjectThatIsNotAcmeApp" @@ -738,7 +715,7 @@ public void testEvaluateProjectWithPolicyOperatorAllAndNoConditionsMatching() { } @Test - public void testEvaluateProjectWithPolicyAssignedToProject() { + void testEvaluateProjectWithPolicyAssignedToProject() { final var policyA = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policyA, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.name.startsWith("acme-lib") @@ -775,7 +752,7 @@ public void testEvaluateProjectWithPolicyAssignedToProject() { } @Test - public void testEvaluateProjectWithPolicyAssignedToProjectParent() { + void testEvaluateProjectWithPolicyAssignedToProjectParent() { final var policyA = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policyA, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.name.startsWith("acme-lib") @@ -818,7 +795,7 @@ public void testEvaluateProjectWithPolicyAssignedToProjectParent() { } @Test - public void testEvaluateProjectWithPolicyAssignedToTag() { + void testEvaluateProjectWithPolicyAssignedToTag() { final Tag tag = qm.createTag("foo"); final var policyA = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); @@ -858,7 +835,7 @@ public void testEvaluateProjectWithPolicyAssignedToTag() { } @Test - public void testEvaluateProjectWithInvalidScript() { + void testEvaluateProjectWithInvalidScript() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.doesNotExist == "foo" @@ -884,7 +861,7 @@ public void testEvaluateProjectWithInvalidScript() { } @Test - public void testEvaluateProjectWithScriptExecutionException() { + void testEvaluateProjectWithScriptExecutionException() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.last_bom_import == timestamp("invalid") @@ -910,7 +887,7 @@ public void testEvaluateProjectWithScriptExecutionException() { } @Test - public void testEvaluateProjectWithFuncProjectDependsOnComponent() { + void testEvaluateProjectWithFuncProjectDependsOnComponent() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.depends_on(v1.Component{name: "acme-lib-a"}) @@ -943,7 +920,7 @@ public void testEvaluateProjectWithFuncProjectDependsOnComponent() { } @Test - public void testEvaluateProjectWithFuncProjectDependsOnComponentWithRegexAndVers() { + void testEvaluateProjectWithFuncProjectDependsOnComponentWithRegexAndVers() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.depends_on(v1.Component{name: "re:^acme-lib-.*$", version: "vers:generic/>1|<2.0"}) @@ -978,7 +955,7 @@ public void testEvaluateProjectWithFuncProjectDependsOnComponentWithRegexAndVers } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfComponent() { + void testEvaluateProjectWithFuncComponentIsDependencyOfComponent() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.is_dependency_of(v1.Component{name: "acme-lib-a"}) @@ -1010,7 +987,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfComponent() { } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfComponentWithRegex() { + void testEvaluateProjectWithFuncComponentIsDependencyOfComponentWithRegex() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.is_dependency_of(v1.Component{name: "re:.*-lib-.*"}) @@ -1042,7 +1019,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfComponentWithRegex } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfComponentWithVersRange() { + void testEvaluateProjectWithFuncComponentIsDependencyOfComponentWithVersRange() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.is_dependency_of(v1.Component{ @@ -1078,7 +1055,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfComponentWithVersR } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithSinglePath() { + void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithSinglePath() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1162,7 +1139,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponent } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths() { + void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1248,7 +1225,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponent } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths2() { + void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths2() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1324,7 +1301,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponent } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths3() { + void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths3() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1412,7 +1389,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponent } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths4() { + void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths4() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1595,7 +1572,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponent } @Test - public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths5() { + void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponentWithMultiplePaths5() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1643,7 +1620,7 @@ public void testEvaluateProjectWithFuncComponentIsDependencyOfExclusiveComponent } @Test - public void testEvaluateProjectWithFuncMatchesRange() { + void testEvaluateProjectWithFuncMatchesRange() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.matches_range("vers:generic/<1") @@ -1673,7 +1650,7 @@ public void testEvaluateProjectWithFuncMatchesRange() { } @Test - public void testEvaluateProjectWithFuncMatchesRangeWithInvalidRange() { + void testEvaluateProjectWithFuncMatchesRangeWithInvalidRange() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.matches_range("foo") @@ -1703,7 +1680,7 @@ public void testEvaluateProjectWithFuncMatchesRangeWithInvalidRange() { } @Test - public void testEvaluateProjectWithToolMetadata() { + void testEvaluateProjectWithToolMetadata() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.metadata.tools.components.exists(tool, @@ -1740,12 +1717,12 @@ public void testEvaluateProjectWithToolMetadata() { } @Test - public void testEvaluateProjectWhenProjectDoesNotExist() { + void testEvaluateProjectWhenProjectDoesNotExist() { assertThatNoException().isThrownBy(() -> new CelPolicyEngine().evaluateProject(UUID.randomUUID())); } @Test - public void testEvaluateComponent() { + void testEvaluateComponent() { final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ component.name == "acme-lib" @@ -1765,12 +1742,12 @@ public void testEvaluateComponent() { } @Test - public void testEvaluateComponentWhenComponentDoesNotExist() { + void testEvaluateComponentWhenComponentDoesNotExist() { assertThatNoException().isThrownBy(() -> new CelPolicyEngine().evaluateComponent(UUID.randomUUID())); } @Test - public void issue1924() { + void issue1924() { Policy policy = qm.createPolicy("Policy 1924", Policy.Operator.ALL, Policy.ViolationState.INFO); qm.createPolicyCondition(policy, PolicyCondition.Subject.SEVERITY, PolicyCondition.Operator.IS, Severity.CRITICAL.name()); qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.NO_MATCH, "pkg:deb"); @@ -1844,7 +1821,7 @@ public void issue1924() { } @Test - public void issue2455() { + void issue2455() { Policy policy = qm.createPolicy("Policy 1924", Policy.Operator.ALL, Policy.ViolationState.INFO); License license = new License(); @@ -1901,7 +1878,7 @@ public void issue2455() { } @Test - public void testEvaluateProjectWithNoLongerApplicableViolationWithAnalysis() { + void testEvaluateProjectWithNoLongerApplicableViolationWithAnalysis() { final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -1943,7 +1920,7 @@ public void testEvaluateProjectWithNoLongerApplicableViolationWithAnalysis() { } @Test - public void testEvaluateProjectWithFuncComponentIsDirectDependencyOfComponent() { + void testEvaluateProjectWithFuncComponentIsDirectDependencyOfComponent() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1988,7 +1965,7 @@ public void testEvaluateProjectWithFuncComponentIsDirectDependencyOfComponent() } @Test - public void testEvaluateProjectWithFuncComponentIsDirectDependencyOfExclusiveComponent() { + void testEvaluateProjectWithFuncComponentIsDirectDependencyOfExclusiveComponent() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -2060,7 +2037,7 @@ public void testEvaluateProjectWithFuncComponentIsDirectDependencyOfExclusiveCom } @Test - public void testEvaluateProjectWithFuncComponentIsDirectDependencyOfComponentWithInMemoryFilter() { + void testEvaluateProjectWithFuncComponentIsDirectDependencyOfComponentWithInMemoryFilter() { final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0"); diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/AnalysisResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/AnalysisResourceTest.java index 8a89d5df9b..4c712329b4 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/AnalysisResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/AnalysisResourceTest.java @@ -29,7 +29,6 @@ import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import net.jcip.annotations.NotThreadSafe; import org.apache.http.HttpStatus; import org.dependencytrack.JerseyTestExtension; import org.dependencytrack.ResourceTest; @@ -42,6 +41,7 @@ import org.dependencytrack.model.Project; import org.dependencytrack.model.Severity; import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.persistence.command.MakeAnalysisCommand; import org.dependencytrack.persistence.jdbi.VulnerabilityPolicyDao; import org.dependencytrack.policy.vulnerability.VulnerabilityPolicy; @@ -58,14 +58,14 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_PROJECT_AUDIT_CHANGE; import static org.dependencytrack.notification.proto.v1.Level.LEVEL_INFORMATIONAL; import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_PORTFOLIO; import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiHandle; import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; -@NotThreadSafe -public class AnalysisResourceTest extends ResourceTest { +class AnalysisResourceTest extends ResourceTest { @RegisterExtension static JerseyTestExtension jersey = new JerseyTestExtension( @@ -75,7 +75,7 @@ public class AnalysisResourceTest extends ResourceTest { .register(AuthorizationFeature.class)); @Test - public void retrieveAnalysisTest() { + void retrieveAnalysisTest() { initializeWithPermissions(Permissions.VIEW_VULNERABILITY); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -130,7 +130,7 @@ public void retrieveAnalysisTest() { } @Test - public void retrieveAnalysisWithoutExistingAnalysisTest() { + void retrieveAnalysisWithoutExistingAnalysisTest() { initializeWithPermissions(Permissions.VIEW_VULNERABILITY); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -161,7 +161,7 @@ public void retrieveAnalysisWithoutExistingAnalysisTest() { } @Test - public void noAnalysisExists() { + void noAnalysisExists() { initializeWithPermissions(Permissions.VIEW_VULNERABILITY); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -189,7 +189,7 @@ public void noAnalysisExists() { } @Test - public void retrieveAnalysisWithProjectNotFoundTest() { + void retrieveAnalysisWithProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_VULNERABILITY); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -220,7 +220,7 @@ public void retrieveAnalysisWithProjectNotFoundTest() { } @Test - public void retrieveAnalysisWithComponentNotFoundTest() { + void retrieveAnalysisWithComponentNotFoundTest() { initializeWithPermissions(Permissions.VIEW_VULNERABILITY); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -251,7 +251,7 @@ public void retrieveAnalysisWithComponentNotFoundTest() { } @Test - public void retrieveAnalysisWithVulnerabilityNotFoundTest() { + void retrieveAnalysisWithVulnerabilityNotFoundTest() { initializeWithPermissions(Permissions.VIEW_VULNERABILITY); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -282,7 +282,7 @@ public void retrieveAnalysisWithVulnerabilityNotFoundTest() { } @Test - public void retrieveAnalysisUnauthorizedTest() { + void retrieveAnalysisUnauthorizedTest() { final Response response = jersey.target(V1_ANALYSIS) .queryParam("project", UUID.randomUUID()) .queryParam("component", UUID.randomUUID()) @@ -295,7 +295,7 @@ public void retrieveAnalysisUnauthorizedTest() { } @Test - public void retrieveAnalysisWithAclTest() { + void retrieveAnalysisWithAclTest() { enablePortfolioAccessControl(); initializeWithPermissions(Permissions.VIEW_VULNERABILITY); @@ -349,7 +349,9 @@ public void retrieveAnalysisWithAclTest() { } @Test - public void updateAnalysisCreateNewTest() throws Exception { + void updateAnalysisCreateNewTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -415,7 +417,9 @@ public void updateAnalysisCreateNewTest() throws Exception { } @Test - public void updateAnalysisCreateNewWithUserTest() { + void updateAnalysisCreateNewWithUserTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); @@ -485,7 +489,7 @@ public void updateAnalysisCreateNewWithUserTest() { } @Test - public void updateAnalysisCreateNewWithEmptyRequestTest() { + void updateAnalysisCreateNewWithEmptyRequestTest() { initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -526,7 +530,9 @@ public void updateAnalysisCreateNewWithEmptyRequestTest() { } @Test - public void updateAnalysisUpdateExistingTest() throws Exception { + void updateAnalysisUpdateExistingTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -610,7 +616,7 @@ public void updateAnalysisUpdateExistingTest() throws Exception { } @Test - public void updateAnalysisWithNoChangesTest() { + void updateAnalysisWithNoChangesTest() { initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -669,7 +675,9 @@ public void updateAnalysisWithNoChangesTest() { } @Test - public void updateAnalysisUpdateExistingWithEmptyRequestTest() throws Exception { + void updateAnalysisUpdateExistingWithEmptyRequestTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -742,7 +750,7 @@ public void updateAnalysisUpdateExistingWithEmptyRequestTest() throws Exception } @Test - public void updateAnalysisWithComponentNotFoundTest() { + void updateAnalysisWithComponentNotFoundTest() { initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -774,7 +782,7 @@ public void updateAnalysisWithComponentNotFoundTest() { } @Test - public void updateAnalysisWithVulnerabilityNotFoundTest() { + void updateAnalysisWithVulnerabilityNotFoundTest() { initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -810,7 +818,9 @@ public void updateAnalysisWithVulnerabilityNotFoundTest() { // Performing an analysis with those request fields set in >= 4.4.0 then resulted in NPEs, // see https://github.com/DependencyTrack/dependency-track/issues/1409 @Test - public void updateAnalysisIssue1409Test() throws InterruptedException { + void updateAnalysisIssue1409Test() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -882,7 +892,7 @@ public void updateAnalysisIssue1409Test() throws InterruptedException { } @Test - public void updateAnalysisUnauthorizedTest() { + void updateAnalysisUnauthorizedTest() { final var analysisRequest = new AnalysisRequest(UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString(), AnalysisState.NOT_AFFECTED, AnalysisJustification.PROTECTED_BY_MITIGATING_CONTROL, AnalysisResponse.UPDATE, "Analysis details here", "Analysis comment here", false); @@ -896,7 +906,7 @@ public void updateAnalysisUnauthorizedTest() { } @Test - public void updateAnalysisWithAclTest() { + void updateAnalysisWithAclTest() { enablePortfolioAccessControl(); initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); @@ -947,7 +957,7 @@ public void updateAnalysisWithAclTest() { } @Test - public void updateAnalysisWithAssociatedVulnerabilityPolicyTest() { + void updateAnalysisWithAssociatedVulnerabilityPolicyTest() { initializeWithPermissions(Permissions.VULNERABILITY_ANALYSIS); final var project = new Project(); diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index ae0e9c300b..3397c7a686 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -61,6 +61,7 @@ import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.WorkflowStep; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.proto.v1.BomValidationFailedSubject; import org.dependencytrack.parser.cyclonedx.CycloneDxValidator; import org.dependencytrack.persistence.command.MakeAnalysisCommand; @@ -110,12 +111,13 @@ import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_MODE; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_EXCLUSIVE; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_INCLUSIVE; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_BOM_VALIDATION_FAILED; import static org.dependencytrack.notification.proto.v1.Level.LEVEL_ERROR; import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_PORTFOLIO; import static org.hamcrest.CoreMatchers.equalTo; -public class BomResourceTest extends ResourceTest { +class BomResourceTest extends ResourceTest { private static PluginManager pluginManager; @@ -150,7 +152,7 @@ static void afterAll() { } @Test - public void exportProjectAsCycloneDxTest() { + void exportProjectAsCycloneDxTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -169,7 +171,7 @@ public void exportProjectAsCycloneDxTest() { } @Test - public void exportProjectAsCycloneDxInvalidTest() { + void exportProjectAsCycloneDxInvalidTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); Response response = jersey.target(V1_BOM + "/cyclonedx/project/" + UUID.randomUUID()).request() @@ -182,7 +184,7 @@ public void exportProjectAsCycloneDxInvalidTest() { } @Test - public void exportProjectAsCycloneDxAclTest() { + void exportProjectAsCycloneDxAclTest() { enablePortfolioAccessControl(); final var project = new Project(); @@ -213,7 +215,7 @@ public void exportProjectAsCycloneDxAclTest() { } @Test - public void exportProjectAsCycloneDxAclUserTest() { + void exportProjectAsCycloneDxAclUserTest() { enablePortfolioAccessControl(); final ManagedUser testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); @@ -249,7 +251,7 @@ public void exportProjectAsCycloneDxAclUserTest() { } @Test - public void exportProjectAsCycloneDxInventoryTest() { + void exportProjectAsCycloneDxInventoryTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); var vulnerability = new Vulnerability(); @@ -487,7 +489,7 @@ public void exportProjectAsCycloneDxInventoryTest() { } @Test - public void exportProjectAsCycloneDxLicenseTest() { + void exportProjectAsCycloneDxLicenseTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -564,7 +566,7 @@ public void exportProjectAsCycloneDxLicenseTest() { } @Test - public void exportProjectAsCycloneDxInventoryWithVulnerabilitiesTest() { + void exportProjectAsCycloneDxInventoryWithVulnerabilitiesTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO, Permissions.VIEW_VULNERABILITY); var vulnerability = new Vulnerability(); @@ -765,7 +767,7 @@ public void exportProjectAsCycloneDxInventoryWithVulnerabilitiesTest() { } @Test - public void exportProjectAsCycloneDxInventoryWithVulnerabilitiesWithInsufficientPermissionsTest() { + void exportProjectAsCycloneDxInventoryWithVulnerabilitiesWithInsufficientPermissionsTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); var project = new Project(); @@ -782,7 +784,7 @@ public void exportProjectAsCycloneDxInventoryWithVulnerabilitiesWithInsufficient } @Test - public void exportProjectAsCycloneDxVdrTest() { + void exportProjectAsCycloneDxVdrTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO, Permissions.VIEW_VULNERABILITY); var vulnerability = new Vulnerability(); @@ -976,7 +978,7 @@ public void exportProjectAsCycloneDxVdrTest() { } @Test - public void exportProjectAsCycloneDxVdrWithInsufficientPermissionsTest() { + void exportProjectAsCycloneDxVdrWithInsufficientPermissionsTest() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); var project = new Project(); @@ -993,7 +995,7 @@ public void exportProjectAsCycloneDxVdrWithInsufficientPermissionsTest() { } @Test - public void exportComponentAsCycloneDx() { + void exportComponentAsCycloneDx() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); @@ -1012,7 +1014,7 @@ public void exportComponentAsCycloneDx() { } @Test - public void exportComponentAsCycloneDxInvalid() { + void exportComponentAsCycloneDxInvalid() { initializeWithPermissions(Permissions.VIEW_PORTFOLIO); Response response = jersey.target(V1_BOM + "/cyclonedx/component/" + UUID.randomUUID()).request() @@ -1025,7 +1027,7 @@ public void exportComponentAsCycloneDxInvalid() { } @Test - public void exportComponentAsCycloneDxAclTest() { + void exportComponentAsCycloneDxAclTest() { enablePortfolioAccessControl(); final var project = new Project(); @@ -1061,7 +1063,7 @@ public void exportComponentAsCycloneDxAclTest() { } @Test - public void uploadBomTest() throws Exception { + void uploadBomTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); @@ -1121,7 +1123,7 @@ public void uploadBomTest() throws Exception { } @Test - public void uploadNonCycloneDxBomTest() { + void uploadNonCycloneDxBomTest() { initializeWithPermissions(Permissions.BOM_UPLOAD); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); String bomString = Base64.getEncoder().encodeToString(""" @@ -1143,7 +1145,7 @@ public void uploadNonCycloneDxBomTest() { } @Test - public void uploadInvalidCycloneDxBomTest() { + void uploadInvalidCycloneDxBomTest() { initializeWithPermissions(Permissions.BOM_UPLOAD); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); String bomString = Base64.getEncoder().encodeToString(""" @@ -1179,7 +1181,7 @@ public void uploadInvalidCycloneDxBomTest() { } @Test - public void uploadInvalidFormatBomTest() throws Exception { + void uploadInvalidFormatBomTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); File file = new File(IOUtils.resourceToURL("/unit/bom-invalid.json").toURI()); @@ -1199,7 +1201,7 @@ public void uploadInvalidFormatBomTest() throws Exception { } @Test - public void uploadBomInvalidProjectTest() throws Exception { + void uploadBomInvalidProjectTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); @@ -1214,7 +1216,7 @@ public void uploadBomInvalidProjectTest() throws Exception { } @Test - public void uploadBomAutoCreateTest() throws Exception { + void uploadBomAutoCreateTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); @@ -1232,7 +1234,7 @@ public void uploadBomAutoCreateTest() throws Exception { } @Test - public void uploadBomUnauthorizedTest() throws Exception { + void uploadBomUnauthorizedTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); @@ -1247,7 +1249,7 @@ public void uploadBomUnauthorizedTest() throws Exception { } @Test - public void uploadBomAutoCreateTestWithParentTest() throws Exception { + void uploadBomAutoCreateTestWithParentTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); @@ -1310,7 +1312,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { } @Test - public void uploadBomInvalidParentTest() throws Exception { + void uploadBomInvalidParentTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); @@ -1352,7 +1354,7 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr @ParameterizedTest @MethodSource("uploadBomSchemaValidationTestParameters") - public void uploadBomSchemaValidationTest(final Path filePath) throws Exception { + void uploadBomSchemaValidationTest(final Path filePath) throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); File file = filePath.toFile(); @@ -1365,7 +1367,9 @@ public void uploadBomSchemaValidationTest(final Path filePath) throws Exception } @Test - public void uploadBomInvalidJsonTest() throws Exception { + void uploadBomInvalidJsonTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.BOM_UPLOAD); final var project = new Project(); @@ -1431,7 +1435,9 @@ public void uploadBomInvalidJsonTest() throws Exception { } @Test - public void uploadBomInvalidXmlTest() throws Exception { + void uploadBomInvalidXmlTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.BOM_UPLOAD); final var project = new Project(); @@ -1493,7 +1499,7 @@ public void uploadBomInvalidXmlTest() throws Exception { } @Test - public void uploadBomTooLargeViaPutTest() { + void uploadBomTooLargeViaPutTest() { initializeWithPermissions(Permissions.BOM_UPLOAD); final var project = new Project(); @@ -1524,7 +1530,7 @@ public void uploadBomTooLargeViaPutTest() { } @Test - public void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { + void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); final var multiPart = new FormDataMultiPart() .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) @@ -1558,7 +1564,7 @@ public void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { } @Test - public void uploadBomProtobufFormatTest() { + void uploadBomProtobufFormatTest() { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); final var project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); final var bomProto = Bom.newBuilder().setSpecVersion("1.6").build(); @@ -1592,7 +1598,7 @@ public void uploadBomProtobufFormatTest() { } @Test - public void uploadBomAutoCreateWithTagsTest() throws Exception { + void uploadBomAutoCreateWithTagsTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); @@ -1618,13 +1624,13 @@ public void uploadBomAutoCreateWithTagsTest() throws Exception { } @Test - public void validateCycloneDxBomWithMultipleNamespacesTest() throws Exception { + void validateCycloneDxBomWithMultipleNamespacesTest() throws Exception { byte[] bom = resourceToByteArray("/unit/bom-issue4008.xml"); assertThatNoException().isThrownBy(() -> CycloneDxValidator.getInstance().validate(bom)); } @Test - public void uploadBomWithValidationModeDisabledTest() { + void uploadBomWithValidationModeDisabledTest() { initializeWithPermissions(Permissions.BOM_UPLOAD); qm.createConfigProperty( @@ -1669,7 +1675,7 @@ public void uploadBomWithValidationModeDisabledTest() { } @Test - public void uploadBomWithValidationModeEnabledForTagsTest() { + void uploadBomWithValidationModeEnabledForTagsTest() { initializeWithPermissions(Permissions.BOM_UPLOAD); qm.createConfigProperty( @@ -1736,7 +1742,7 @@ public void uploadBomWithValidationModeEnabledForTagsTest() { } @Test - public void uploadBomWithValidationModeDisabledForTagsTest() { + void uploadBomWithValidationModeDisabledForTagsTest() { initializeWithPermissions(Permissions.BOM_UPLOAD); qm.createConfigProperty( @@ -1803,7 +1809,7 @@ public void uploadBomWithValidationModeDisabledForTagsTest() { } @Test - public void uploadBomWithValidationTagsInvalidTest() { + void uploadBomWithValidationTagsInvalidTest() { initializeWithPermissions(Permissions.BOM_UPLOAD); qm.createConfigProperty( @@ -1873,7 +1879,7 @@ public void uploadBomWithValidationTagsInvalidTest() { } @Test - public void uploadBomAutoCreateLatestWithAclTest() throws Exception { + void uploadBomAutoCreateLatestWithAclTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); enablePortfolioAccessControl(); @@ -1897,7 +1903,7 @@ public void uploadBomAutoCreateLatestWithAclTest() throws Exception { } @Test - public void uploadBomAutoCreateLatestWithAclNoAccessTest() throws Exception { + void uploadBomAutoCreateLatestWithAclNoAccessTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); enablePortfolioAccessControl(); diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java index 32ac46b057..53aa58368e 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java @@ -18,150 +18,219 @@ */ package org.dependencytrack.resources.v1; -import alpine.common.util.UuidUtil; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFeature; -import jakarta.json.JsonArray; +import io.smallrye.config.SmallRyeConfigBuilder; import jakarta.json.JsonObject; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import net.javacrumbs.jsonunit.core.Option; import org.dependencytrack.JerseyTestExtension; import org.dependencytrack.ResourceTest; import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; +import org.dependencytrack.notification.DefaultNotificationPublisherInitializer; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationLevel; import org.dependencytrack.notification.NotificationScope; -import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; -import org.dependencytrack.persistence.DatabaseSeedingInitTask; +import org.dependencytrack.notification.publishing.DefaultNotificationPublishersPlugin; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.resources.v1.vo.UpdateNotificationPublisherRequest; +import org.glassfish.jersey.inject.hk2.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.UUID; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; -import static org.dependencytrack.notification.publisher.PublisherClass.SendMailPublisher; -import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiTransaction; -public class NotificationPublisherResourceTest extends ResourceTest { +class NotificationPublisherResourceTest extends ResourceTest { + + private static PluginManager pluginManager; @RegisterExtension static JerseyTestExtension jersey = new JerseyTestExtension( new ResourceConfig(NotificationPublisherResource.class) .register(ApiFilter.class) - .register(AuthenticationFeature.class)); - - @BeforeEach - public void before() throws Exception { - super.before(); + .register(AuthenticationFeature.class) + .register(new AbstractBinder() { + @Override + protected void configure() { + bindFactory(() -> pluginManager).to(PluginManager.class); + } + })); - useJdbiTransaction(DatabaseSeedingInitTask::seedDefaultNotificationPublishers); + @BeforeAll + static void beforeAll() { + pluginManager = new PluginManager( + new SmallRyeConfigBuilder().build(), + secretName -> null, + List.of(org.dependencytrack.notification.api.publishing.NotificationPublisher.class)); + pluginManager.loadPlugins(List.of(new DefaultNotificationPublishersPlugin())); } - @Test - public void getAllNotificationPublishersTest() { - Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() - .header(X_API_KEY, apiKey) - .get(Response.class); - Assertions.assertEquals(200, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - JsonArray json = parseJsonArray(response); - Assertions.assertNotNull(json); - Assertions.assertEquals(8, json.size()); - Assertions.assertEquals("Console", json.getJsonObject(1).getString("name")); - Assertions.assertEquals("Displays notifications on the system console", json.getJsonObject(1).getString("description")); - Assertions.assertEquals("text/plain", json.getJsonObject(1).getString("templateMimeType")); - Assertions.assertNotNull("template"); - Assertions.assertTrue(json.getJsonObject(1).getBoolean("defaultPublisher")); - Assertions.assertTrue(UuidUtil.isValidUUID(json.getJsonObject(1).getString("uuid"))); + @AfterAll + static void afterAll() { + if (pluginManager != null) { + pluginManager.close(); + } } @Test - public void createNotificationPublisherTest() { - NotificationPublisher publisher = new NotificationPublisher(); - publisher.setName("Example Publisher"); - publisher.setDescription("Publisher description"); - publisher.setTemplate("template"); - publisher.setTemplateMimeType("application/json"); - publisher.setPublisherClass(SendMailPublisher.name()); - publisher.setDefaultPublisher(false); - Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() - .header(X_API_KEY, apiKey) - .put(Entity.entity(publisher, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(201, response.getStatus(), 0); - JsonObject json = parseJsonObject(response); - Assertions.assertNotNull(json); - Assertions.assertEquals("Example Publisher", json.getString("name")); - Assertions.assertFalse(json.getBoolean("defaultPublisher")); - Assertions.assertEquals("Publisher description", json.getString("description")); - Assertions.assertEquals("template", json.getString("template")); - Assertions.assertEquals("application/json", json.getString("templateMimeType")); - Assertions.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); - Assertions.assertEquals(SendMailPublisher.name(), json.getString("publisherClass")); - } + void getAllNotificationPublishersTest() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); - @Test - public void createNotificationPublisherWithDefaultFlagTest() { - NotificationPublisher publisher = new NotificationPublisher(); - publisher.setName("Example Publisher"); - publisher.setDescription("Publisher description"); - publisher.setTemplate("template"); - publisher.setTemplateMimeType("application/json"); - publisher.setPublisherClass(SendMailPublisher.name()); - publisher.setDefaultPublisher(true); - Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() + final Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() .header(X_API_KEY, apiKey) - .put(Entity.entity(publisher, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(400, response.getStatus(), 0); - String body = getPlainTextBody(response); - Assertions.assertEquals("The creation of a new default publisher is forbidden", body); + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)) + .withOptions(Option.IGNORING_ARRAY_ORDER) + .isEqualTo(/* language=JSON */ """ + [ + { + "name": "Console", + "description": "Default Console publisher", + "extensionName": "console", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Email", + "description": "Default Email publisher", + "extensionName": "email", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Jira", + "description": "Default Jira publisher", + "extensionName": "jira", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Kafka", + "description": "Default Kafka publisher", + "extensionName": "kafka", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Mattermost", + "description": "Default Mattermost publisher", + "extensionName": "mattermost", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Msteams", + "description": "Default Msteams publisher", + "extensionName": "msteams", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Slack", + "description": "Default Slack publisher", + "extensionName": "slack", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Webex", + "description": "Default Webex publisher", + "extensionName": "webex", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + { + "name": "Webhook", + "description": "Default Webhook publisher", + "extensionName": "webhook", + "template": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + } + ] + """); } @Test - public void createNotificationPublisherWithExistingNameTest() { - NotificationPublisher publisher = new NotificationPublisher(); - publisher.setName(DefaultNotificationPublishers.SLACK.getPublisherName()); - publisher.setDescription("Publisher description"); - publisher.setTemplate("template"); - publisher.setTemplateMimeType("application/json"); - publisher.setPublisherClass(SendMailPublisher.name()); - publisher.setDefaultPublisher(true); - Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() + void createNotificationPublisherTest() { + final Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() .header(X_API_KEY, apiKey) - .put(Entity.entity(publisher, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(409, response.getStatus(), 0); - String body = getPlainTextBody(response); - Assertions.assertEquals("The notification with the name "+DefaultNotificationPublishers.SLACK.getPublisherName()+" already exist", body); + .put(Entity.json(/* language=JSON */ """ + { + "name": "Example Publisher", + "description": "Publisher description", + "extensionName": "slack", + "template": "template", + "templateMimeType": "application/json" + } + """)); + assertThat(response.getStatus()).isEqualTo(201); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "name": "Example Publisher", + "description": "Publisher description", + "extensionName": "slack", + "template": "template", + "templateMimeType": "application/json", + "defaultPublisher": false, + "uuid": "${json-unit.any-string}" + } + """); } @Test - public void createNotificationPublisherWithClassNotImplementingPublisherInterfaceTest() { - NotificationPublisher publisher = new NotificationPublisher(); - publisher.setName("Example Publisher"); - publisher.setDescription("Publisher description"); - publisher.setTemplate("template"); - publisher.setTemplateMimeType("application/json"); - publisher.setPublisherClass(NotificationPublisherResource.class.getName()); - publisher.setDefaultPublisher(false); - Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() + void createNotificationPublisherWithExistingNameTest() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); + + final Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() .header(X_API_KEY, apiKey) - .put(Entity.entity(publisher, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(400, response.getStatus(), 0); - String body = getPlainTextBody(response); - Assertions.assertEquals("The publisher class "+NotificationPublisherResource.class.getName()+" is not valid.", body); + .put(Entity.json(/* language=JSON */ """ + { + "name": "Slack", + "extensionName": "slack", + "template": "template", + "templateMimeType": "application/json" + } + """)); + assertThat(response.getStatus()).isEqualTo(409); + assertThat(getPlainTextBody(response)).isEqualTo( + "The notification with the name Slack already exist"); } @Test - public void updateNotificationPublisherTest() { + void updateNotificationPublisherTest() { NotificationPublisher notificationPublisher = qm.createNotificationPublisher( "Example Publisher", "Publisher description", - SendMailPublisher.name(), "template", "text/html", + "slack", "template", "text/html", false ); notificationPublisher.setName("Updated Publisher name"); @@ -177,14 +246,14 @@ public void updateNotificationPublisherTest() { Assertions.assertEquals("template", json.getString("template")); Assertions.assertEquals("text/html", json.getString("templateMimeType")); Assertions.assertEquals(notificationPublisher.getUuid().toString(), json.getString("uuid")); - Assertions.assertEquals(SendMailPublisher.name(), json.getString("publisherClass")); + Assertions.assertEquals("slack", json.getString("extensionName")); } @Test - public void updateUnknownNotificationPublisherTest() { + void updateUnknownNotificationPublisherTest() { NotificationPublisher notificationPublisher = qm.createNotificationPublisher( "Example Publisher", "Publisher description", - SendMailPublisher.name(), "template", "text/html", + "slack", "template", "text/html", false ); notificationPublisher = qm.detach(NotificationPublisher.class, notificationPublisher.getId()); @@ -199,58 +268,86 @@ public void updateUnknownNotificationPublisherTest() { } @Test - public void updateExistingDefaultNotificationPublisherTest() { - NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisherByName(DefaultNotificationPublishers.MS_TEAMS.getPublisherName()); - notificationPublisher.setName(notificationPublisher.getName() + " Updated"); + void updateExistingDefaultNotificationPublisherTest() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); + + final NotificationPublisher slackPublisher = qm.getDefaultNotificationPublisherByName("Slack"); + assertThat(slackPublisher).isNotNull(); + + final var updateRequest = new UpdateNotificationPublisherRequest( + "foo", + slackPublisher.getExtensionName(), + slackPublisher.getDescription(), + slackPublisher.getTemplate(), + slackPublisher.getTemplateMimeType(), + slackPublisher.getUuid()); + Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() .header(X_API_KEY, apiKey) - .post(Entity.entity(notificationPublisher, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(400, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - String body = getPlainTextBody(response); - Assertions.assertEquals("The modification of a default publisher is forbidden", body); + .post(Entity.json(updateRequest)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).isEqualTo( + "The modification of a default publisher is forbidden"); } @Test - public void updateNotificationPublisherWithNameOfAnotherNotificationPublisherTest() { - NotificationPublisher notificationPublisher = qm.createNotificationPublisher( - "Example Publisher", "Publisher description", - SendMailPublisher.name(), "template", "text/html", - false - ); - notificationPublisher = qm.detach(NotificationPublisher.class, notificationPublisher.getId()); - notificationPublisher.setName(DefaultNotificationPublishers.MS_TEAMS.getPublisherName()); + void updateNotificationPublisherWithNameOfAnotherNotificationPublisherTest() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); + + final NotificationPublisher publisher = qm.createNotificationPublisher( + "Example Publisher", + "description", + "slack", + "template", + "text/html", + false); + Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() .header(X_API_KEY, apiKey) - .post(Entity.entity(notificationPublisher, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(409, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - String body = getPlainTextBody(response); - Assertions.assertEquals("An existing publisher with the name '"+DefaultNotificationPublishers.MS_TEAMS.getPublisherName()+"' already exist", body); + .post(Entity.json(/* language=JSON */ """ + { + "name": "Slack", + "description": "description", + "extensionName": "slack", + "template": "template", + "templateMimeType": "templateMimeType", + "uuid": "%s" + } + """.formatted(publisher.getUuid()))); + assertThat(response.getStatus()).isEqualTo(409); + assertThat(getPlainTextBody(response)).isEqualTo( + "An existing publisher with the name 'Slack' already exist"); } @Test - public void updateNotificationPublisherWithInvalidClassTest() { - NotificationPublisher notificationPublisher = qm.createNotificationPublisher( - "Example Publisher", "Publisher description", - SendMailPublisher.name(), "template", "text/html", - false - ); - notificationPublisher.setPublisherClass("unknownClass"); - Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() + void updateNotificationPublisherWithInvalidExtensionNameTest() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); + + final NotificationPublisher slackPublisher = qm.getNotificationPublisher("Slack"); + assertThat(slackPublisher).isNotNull(); + + final var updateRequest = new UpdateNotificationPublisherRequest( + slackPublisher.getName(), + "unknown", + slackPublisher.getDescription(), + slackPublisher.getTemplate(), + slackPublisher.getTemplateMimeType(), + slackPublisher.getUuid()); + + final Response response = jersey.target(V1_NOTIFICATION_PUBLISHER) + .request() .header(X_API_KEY, apiKey) - .post(Entity.entity(notificationPublisher, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(400, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - String body = getPlainTextBody(response); - Assertions.assertEquals("The publisher class unknownClass is not valid.", body); + .post(Entity.json(updateRequest)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).isEqualTo( + "No extension with name 'unknown' exists"); } @Test - public void deleteNotificationPublisherWithNoRulesTest() { + void deleteNotificationPublisherWithNoRulesTest() { NotificationPublisher publisher = qm.createNotificationPublisher( "Example Publisher", "Publisher description", - SendMailPublisher.name(), "template", "text/html", + "slack", "template", "text/html", false ); Response response = jersey.target(V1_NOTIFICATION_PUBLISHER + "/" + publisher.getUuid()).request() @@ -261,10 +358,10 @@ public void deleteNotificationPublisherWithNoRulesTest() { } @Test - public void deleteNotificationPublisherWithLinkedNotificationRulesTest() { + void deleteNotificationPublisherWithLinkedNotificationRulesTest() { NotificationPublisher publisher = qm.createNotificationPublisher( "Example Publisher", "Publisher description", - SendMailPublisher.name(), "template", "text/html", + "slack", "template", "text/html", false ); NotificationRule firstRule = qm.createNotificationRule("Example Rule 1", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); @@ -279,7 +376,7 @@ public void deleteNotificationPublisherWithLinkedNotificationRulesTest() { } @Test - public void deleteUnknownNotificationPublisherTest() { + void deleteUnknownNotificationPublisherTest() { Response response = jersey.target(V1_NOTIFICATION_PUBLISHER + "/" + UUID.randomUUID()).request() .header(X_API_KEY, apiKey) .delete(); @@ -287,21 +384,47 @@ public void deleteUnknownNotificationPublisherTest() { } @Test - public void deleteDefaultNotificationPublisherTest() { - NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisherByName(DefaultNotificationPublishers.MS_TEAMS.getPublisherName());; - Response response = jersey.target(V1_NOTIFICATION_PUBLISHER + "/" + notificationPublisher.getUuid()).request() + void deleteDefaultNotificationPublisherTest() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); + + final NotificationPublisher slackPublisher = qm.getNotificationPublisher("Slack"); + assertThat(slackPublisher).isNotNull(); + + final Response response = jersey.target(V1_NOTIFICATION_PUBLISHER + "/" + slackPublisher.getUuid()).request() .header(X_API_KEY, apiKey) .delete(); - Assertions.assertEquals(400, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - String body = getPlainTextBody(response); - Assertions.assertEquals("Deleting a default notification publisher is forbidden.", body); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).isEqualTo( + "Deleting a default notification publisher is forbidden."); } @Test - public void testNotificationRuleTest() { - NotificationPublisher slackPublisher = qm.getDefaultNotificationPublisherByName(DefaultNotificationPublishers.SLACK.getPublisherName()); - slackPublisher.setName(slackPublisher.getName()+" Test Rule"); + void getNotificationPublisherConfigShouldReturnJsonSchema() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); + + final NotificationPublisher slackPublisher = qm.getDefaultNotificationPublisherByName("Slack"); + + final Response response = jersey.target( + "%s/%s/configSchema".formatted(V1_NOTIFICATION_PUBLISHER, slackPublisher.getUuid())) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)) + .withOptions(Option.IGNORING_EXTRA_FIELDS) + .isEqualTo(/* language=JSON */ """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema" + } + """); + } + + @Test + void testNotificationRuleTest() { + new DefaultNotificationPublisherInitializer().seedDefaultPublishers(pluginManager); + + NotificationPublisher slackPublisher = qm.getDefaultNotificationPublisherByName("Slack"); + slackPublisher.setName(slackPublisher.getName() + " Test Rule"); qm.persist(slackPublisher); qm.detach(NotificationPublisher.class, slackPublisher.getId()); @@ -323,7 +446,7 @@ public void testNotificationRuleTest() { } @Test - public void testNotificationRuleNotFoundTest() { + void testNotificationRuleNotFoundTest() { final Response response = jersey.target(V1_NOTIFICATION_PUBLISHER + "/test/" + UUID.randomUUID()).request() .header(X_API_KEY, apiKey) .post(null); diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java index 63bccdf460..d8ad37acfe 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java @@ -22,8 +22,11 @@ import alpine.model.Team; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFeature; +import io.smallrye.config.SmallRyeConfigBuilder; +import jakarta.json.Json; import jakarta.json.JsonArray; import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -36,17 +39,19 @@ import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationLevel; import org.dependencytrack.notification.NotificationScope; -import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; -import org.dependencytrack.persistence.DatabaseSeedingInitTask; +import org.dependencytrack.notification.publishing.DefaultNotificationPublishersPlugin; +import org.dependencytrack.plugin.PluginManager; import org.glassfish.jersey.client.ClientProperties; +import org.glassfish.jersey.inject.hk2.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import java.util.ArrayList; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -55,28 +60,55 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.dependencytrack.notification.publisher.PublisherClass.SendMailPublisher; -import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiTransaction; import static org.hamcrest.Matchers.equalTo; -public class NotificationRuleResourceTest extends ResourceTest { +class NotificationRuleResourceTest extends ResourceTest { + + private static PluginManager pluginManager; @RegisterExtension static JerseyTestExtension jersey = new JerseyTestExtension( new ResourceConfig(NotificationRuleResource.class) .register(ApiFilter.class) - .register(AuthenticationFeature.class)); + .register(AuthenticationFeature.class) + .register(new AbstractBinder() { + @Override + protected void configure() { + bindFactory(() -> pluginManager).to(PluginManager.class); + } + })); + + private NotificationPublisher publisher; + + @BeforeAll + static void beforeAll() { + pluginManager = new PluginManager( + new SmallRyeConfigBuilder().build(), + secretName -> null, + List.of(org.dependencytrack.notification.api.publishing.NotificationPublisher.class)); + pluginManager.loadPlugins(List.of(new DefaultNotificationPublishersPlugin())); + } @BeforeEach - public void before() throws Exception { - super.before(); + void beforeEach() { + publisher = qm.createNotificationPublisher( + "Slack", + "description", + "slack", + "templateContent", + "templateMimeType", + true); + } - useJdbiTransaction(DatabaseSeedingInitTask::seedDefaultNotificationPublishers); + @AfterAll + static void afterAll() { + if (pluginManager != null) { + pluginManager.close(); + } } @Test - public void getAllNotificationRulesTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + void getAllNotificationRulesTest() { qm.createNotificationRule("Rule 1", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); qm.createNotificationRule("Rule 2", NotificationScope.PORTFOLIO, NotificationLevel.WARNING, publisher); qm.createNotificationRule("Rule 3", NotificationScope.SYSTEM, NotificationLevel.ERROR, publisher); @@ -98,76 +130,119 @@ public void getAllNotificationRulesTest() { } @Test - public void createNotificationRuleTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - NotificationRule rule = new NotificationRule(); - rule.setName("Example Rule"); - rule.setEnabled(true); - rule.setPublisherConfig("{ \"foo\": \"bar\" }"); - rule.setMessage("A message"); - rule.setNotificationLevel(NotificationLevel.WARNING); - rule.setScope(NotificationScope.SYSTEM); - rule.setPublisher(publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE).request() + void createNotificationRuleTest() { + final Response response = jersey.target(V1_NOTIFICATION_RULE).request() .header(X_API_KEY, apiKey) - .put(Entity.entity(rule, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(201, response.getStatus(), 0); - JsonObject json = parseJsonObject(response); - Assertions.assertNotNull(json); - Assertions.assertEquals("Example Rule", json.getString("name")); - Assertions.assertTrue(json.getBoolean("enabled")); - Assertions.assertEquals("SYSTEM", json.getString("scope")); - Assertions.assertEquals("WARNING", json.getString("notificationLevel")); - Assertions.assertEquals(0, json.getJsonArray("notifyOn").size()); - Assertions.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); - Assertions.assertEquals("Slack", json.getJsonObject("publisher").getString("name")); + .put(Entity.json(/* language=JSON */ """ + { + "name": "Example Rule", + "notificationLevel": "WARNING", + "scope": "SYSTEM", + "publisher": { + "uuid": "%s" + } + } + """.formatted(publisher.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "name": "Example Rule", + "enabled": true, + "notifyChildren": true, + "logSuccessfulPublish": false, + "scope": "SYSTEM", + "notificationLevel": "WARNING", + "projects": [], + "tags": [], + "teams": [], + "notifyOn": [], + "publisher": { + "name": "Slack", + "description": "description", + "extensionName": "slack", + "templateMimeType": "templateMimeType", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + "publisherConfig": "{\\"destinationUrl\\":\\"https://slack.example.com\\"}", + "uuid": "${json-unit.any-string}" + } + """); } @Test - public void createNotificationRuleInvalidPublisherTest() { - NotificationPublisher publisher = new NotificationPublisher(); - publisher.setUuid(UUID.randomUUID()); - NotificationRule rule = new NotificationRule(); - rule.setName("Example Rule"); - rule.setEnabled(true); - rule.setPublisherConfig("{ \"foo\": \"bar\" }"); - rule.setMessage("A message"); - rule.setNotificationLevel(NotificationLevel.WARNING); - rule.setScope(NotificationScope.SYSTEM); - rule.setPublisher(publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE).request() + void createNotificationRuleInvalidPublisherTest() { + final Response response = jersey.target(V1_NOTIFICATION_RULE).request() .header(X_API_KEY, apiKey) - .put(Entity.entity(rule, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(404, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - String body = getPlainTextBody(response); - Assertions.assertEquals("The UUID of the notification publisher could not be found.", body); + .put(Entity.json(/* language=JSON */ """ + { + "name": "Example Rule", + "notificationLevel": "WARNING", + "scope": "SYSTEM", + "publisher": { + "uuid": "da3222e6-6041-4423-9452-141fc9c2ea77" + } + } + """)); + assertThat(response.getStatus()).isEqualTo(404); + assertThat(getPlainTextBody(response)).isEqualTo( + "The UUID of the notification publisher could not be found."); } @Test - public void updateNotificationRuleTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - NotificationRule rule = qm.createNotificationRule("Rule 1", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - rule.setName("Example Rule"); - rule.setNotifyOn(Collections.singleton(NotificationGroup.NEW_VULNERABILITY)); + void updateNotificationRuleTest() { Response response = jersey.target(V1_NOTIFICATION_RULE).request() .header(X_API_KEY, apiKey) - .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); - Assertions.assertEquals(200, response.getStatus(), 0); - JsonObject json = parseJsonObject(response); - Assertions.assertNotNull(json); - Assertions.assertEquals("Example Rule", json.getString("name")); - Assertions.assertTrue(json.getBoolean("enabled")); - Assertions.assertEquals("PORTFOLIO", json.getString("scope")); - Assertions.assertEquals("INFORMATIONAL", json.getString("notificationLevel")); - Assertions.assertEquals("NEW_VULNERABILITY", json.getJsonArray("notifyOn").getString(0)); - Assertions.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); - Assertions.assertEquals("Slack", json.getJsonObject("publisher").getString("name")); + .put(Entity.json(/* language=JSON */ """ + { + "name": "Rule 1", + "notificationLevel": "INFORMATIONAL", + "scope": "PORTFOLIO", + "publisher": { + "uuid": "%s" + } + } + """.formatted(publisher.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + + final JsonObjectBuilder ruleJson = Json.createObjectBuilder(parseJsonObject(response)); + ruleJson.add("name", "Example Rule"); + ruleJson.add("notifyOn", Json.createArrayBuilder().add(NotificationGroup.NEW_VULNERABILITY.name())); + + response = jersey.target(V1_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.json(ruleJson.build().toString())); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "name": "Example Rule", + "enabled": true, + "notifyChildren": true, + "logSuccessfulPublish": false, + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "projects": [], + "tags": [], + "teams": [], + "notifyOn": [ + "NEW_VULNERABILITY" + ], + "publisher": { + "name": "Slack", + "description": "description", + "extensionName": "slack", + "templateMimeType": "templateMimeType", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + "publisherConfig": "{\\"destinationUrl\\":\\"https://slack.example.com\\"}", + "uuid": "${json-unit.any-string}" + } + """); } @Test - public void updateNotificationRuleInvalidTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + void updateNotificationRuleInvalidTest() { NotificationRule rule = qm.createNotificationRule("Rule 1", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); rule = qm.detach(NotificationRule.class, rule.getId()); rule.setUuid(UUID.randomUUID()); @@ -181,8 +256,7 @@ public void updateNotificationRuleInvalidTest() { } @Test - public void deleteNotificationRuleTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + void deleteNotificationRuleTest() { NotificationRule rule = qm.createNotificationRule("Rule 1", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); rule.setName("Example Rule"); Response response = jersey.target(V1_NOTIFICATION_RULE).request() @@ -194,11 +268,10 @@ public void deleteNotificationRuleTest() { } @Test - public void addProjectToRuleTest() { + void addProjectToRuleTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid().toString()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(200, response.getStatus(), 0); @@ -211,10 +284,9 @@ public void addProjectToRuleTest() { } @Test - public void addProjectToRuleInvalidRuleTest() { + void addProjectToRuleInvalidRuleTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + UUID.randomUUID() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(404, response.getStatus(), 0); @@ -224,11 +296,10 @@ public void addProjectToRuleInvalidRuleTest() { } @Test - public void addProjectToRuleInvalidScopeTest() { + void addProjectToRuleInvalidScopeTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.SYSTEM, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(406, response.getStatus(), 0); @@ -238,10 +309,9 @@ public void addProjectToRuleInvalidScopeTest() { } @Test - public void addProjectToRuleInvalidProjectTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + void addProjectToRuleInvalidProjectTest() { NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + UUID.randomUUID().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + UUID.randomUUID()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(404, response.getStatus(), 0); @@ -251,15 +321,14 @@ public void addProjectToRuleInvalidProjectTest() { } @Test - public void addProjectToRuleDuplicateProjectTest() { + void addProjectToRuleDuplicateProjectTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); List projects = new ArrayList<>(); projects.add(project); rule.setProjects(projects); qm.persist(rule); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(304, response.getStatus(), 0); @@ -267,20 +336,18 @@ public void addProjectToRuleDuplicateProjectTest() { } @Test - public void addProjectToRuleAclTest() { + void addProjectToRuleAclTest() { enablePortfolioAccessControl(); final var project = new Project(); project.setName("acme-app"); qm.persist(project); - final NotificationPublisher publisher = qm.getNotificationPublisher( - DefaultNotificationPublishers.SLACK.getPublisherName()); final NotificationRule rule = qm.createNotificationRule( "rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); final Supplier responseSupplier = () -> jersey - .target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); @@ -301,15 +368,14 @@ public void addProjectToRuleAclTest() { } @Test - public void removeProjectFromRuleTest() { + void removeProjectFromRuleTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); List projects = new ArrayList<>(); projects.add(project); rule.setProjects(projects); qm.persist(rule); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .delete(); Assertions.assertEquals(200, response.getStatus(), 0); @@ -317,10 +383,9 @@ public void removeProjectFromRuleTest() { } @Test - public void removeProjectFromRuleInvalidRuleTest() { + void removeProjectFromRuleInvalidRuleTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + UUID.randomUUID() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .delete(); Assertions.assertEquals(404, response.getStatus(), 0); @@ -330,11 +395,10 @@ public void removeProjectFromRuleInvalidRuleTest() { } @Test - public void removeProjectFromRuleInvalidScopeTest() { + void removeProjectFromRuleInvalidScopeTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.SYSTEM, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .delete(); Assertions.assertEquals(406, response.getStatus(), 0); @@ -344,10 +408,9 @@ public void removeProjectFromRuleInvalidScopeTest() { } @Test - public void removeProjectFromRuleInvalidProjectTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + void removeProjectFromRuleInvalidProjectTest() { NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + UUID.randomUUID().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + UUID.randomUUID()).request() .header(X_API_KEY, apiKey) .delete(); Assertions.assertEquals(404, response.getStatus(), 0); @@ -357,11 +420,10 @@ public void removeProjectFromRuleInvalidProjectTest() { } @Test - public void removeProjectFromRuleDuplicateProjectTest() { + void removeProjectFromRuleDuplicateProjectTest() { Project project = qm.createProject("Acme Example", null, null, null, null, null, null, false); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .delete(); Assertions.assertEquals(304, response.getStatus(), 0); @@ -369,21 +431,19 @@ public void removeProjectFromRuleDuplicateProjectTest() { } @Test - public void removeProjectFromRuleAclTest() { + void removeProjectFromRuleAclTest() { enablePortfolioAccessControl(); final var project = new Project(); project.setName("acme-app"); qm.persist(project); - final NotificationPublisher publisher = qm.getNotificationPublisher( - DefaultNotificationPublishers.SLACK.getPublisherName()); final NotificationRule rule = qm.createNotificationRule( "rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); rule.setProjects(List.of(project)); final Supplier responseSupplier = () -> jersey - .target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .delete(); @@ -404,11 +464,10 @@ public void removeProjectFromRuleAclTest() { } @Test - public void addTeamToRuleTest(){ + void addTeamToRuleTest() { Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/team/" + team.getUuid()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(200, response.getStatus(), 0); @@ -421,10 +480,9 @@ public void addTeamToRuleTest(){ } @Test - public void addTeamToRuleInvalidRuleTest(){ + void addTeamToRuleInvalidRuleTest() { Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/team/" + team.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + UUID.randomUUID() + "/team/" + team.getUuid()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(404, response.getStatus(), 0); @@ -434,10 +492,9 @@ public void addTeamToRuleInvalidRuleTest(){ } @Test - public void addTeamToRuleInvalidTeamTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); + void addTeamToRuleInvalidTeamTest() { NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + UUID.randomUUID().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/team/" + UUID.randomUUID()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(404, response.getStatus(), 0); @@ -447,15 +504,14 @@ public void addTeamToRuleInvalidTeamTest() { } @Test - public void addTeamToRuleDuplicateTeamTest() { + void addTeamToRuleDuplicateTeamTest() { Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); Set teams = new HashSet<>(); teams.add(team); rule.setTeams(teams); qm.persist(rule); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/team/" + team.getUuid()).request() .header(X_API_KEY, apiKey) .post(Entity.json("")); Assertions.assertEquals(304, response.getStatus(), 0); @@ -463,23 +519,8 @@ public void addTeamToRuleDuplicateTeamTest() { } @Test - public void addTeamToRuleInvalidPublisherTest(){ - Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() - .header(X_API_KEY, apiKey) - .post(Entity.json("")); - Assertions.assertEquals(406, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - String body = getPlainTextBody(response); - Assertions.assertEquals("Team subscriptions are only possible on notification rules with EMAIL publisher.", body); - } - - @Test - public void addTeamToRuleWithCustomEmailPublisherTest() { + void addTeamToRuleWithCustomEmailPublisherTest() { final Team team = qm.createTeam("Team Example"); - final NotificationPublisher publisher = qm.createNotificationPublisher("foo", "description", SendMailPublisher.name(), "template", "templateMimeType", false); final NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); final Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid() + "/team/" + team.getUuid()).request() .header(X_API_KEY, apiKey) @@ -508,11 +549,11 @@ public void addTeamToRuleWithCustomEmailPublisherTest() { ], "notifyOn": [], "publisher": { - "name": "foo", + "name": "Slack", "description": "description", - "publisherClass": "SendMailPublisher", + "extensionName": "slack", "templateMimeType": "templateMimeType", - "defaultPublisher": false, + "defaultPublisher": true, "uuid": "${json-unit.matches:publisherUuid}" }, "uuid": "${json-unit.matches:ruleUuid}" @@ -521,9 +562,8 @@ public void addTeamToRuleWithCustomEmailPublisherTest() { } @Test - public void removeTeamFromRuleTest() { + void removeTeamFromRuleTest() { Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); Set teams = new HashSet<>(); teams.add(team); @@ -537,9 +577,8 @@ public void removeTeamFromRuleTest() { } @Test - public void removeTeamFromRuleInvalidRuleTest() { + void removeTeamFromRuleInvalidRuleTest() { Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/team/" + team.getUuid().toString()).request() .header(X_API_KEY, apiKey) .delete(); @@ -550,8 +589,7 @@ public void removeTeamFromRuleInvalidRuleTest() { } @Test - public void removeTeamFromRuleInvalidTeamTest() { - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); + void removeTeamFromRuleInvalidTeamTest() { NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + UUID.randomUUID().toString()).request() .header(X_API_KEY, apiKey) @@ -563,9 +601,8 @@ public void removeTeamFromRuleInvalidTeamTest() { } @Test - public void removeTeamFromRuleDuplicateTeamTest() { + void removeTeamFromRuleDuplicateTeamTest() { Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() .header(X_API_KEY, apiKey) @@ -575,52 +612,39 @@ public void removeTeamFromRuleDuplicateTeamTest() { } @Test - public void removeTeamToRuleInvalidPublisherTest(){ - Team team = qm.createTeam("Team Example"); - NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - NotificationRule rule = qm.createNotificationRule("Example Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - Response response = jersey.target(V1_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() - .header(X_API_KEY, apiKey) - .delete(); - Assertions.assertEquals(406, response.getStatus(), 0); - Assertions.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - String body = getPlainTextBody(response); - Assertions.assertEquals("Team subscriptions are only possible on notification rules with EMAIL publisher.", body); - } - - @Test - public void updateNotificationRuleWithTagsTest() { - final NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); - final NotificationRule rule = qm.createNotificationRule("Rule 1", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); - - // Tag the rule with "foo" and "bar". + void updateNotificationRuleWithTagsTest() { Response response = jersey.target(V1_NOTIFICATION_RULE).request() .header(X_API_KEY, apiKey) - .post(Entity.entity(/* language=JSON */ """ + .put(Entity.json(/* language=JSON */ """ { - "uuid": "%s", "name": "Rule 1", - "scope": "PORTFOLIO", "notificationLevel": "INFORMATIONAL", - "tags": [ - { - "name": "foo" - }, - { - "name": "bar" - } - ] + "scope": "PORTFOLIO", + "publisher": { + "uuid": "%s" + } } - """.formatted(rule.getUuid()), MediaType.APPLICATION_JSON)); + """.formatted(publisher.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + + // Tag the rule with "foo" and "bar". + JsonObjectBuilder ruleJson = Json.createObjectBuilder(parseJsonObject(response)); + ruleJson.add("tags", Json.createArrayBuilder() + .add(Json.createObjectBuilder().add("name", "foo")) + .add(Json.createObjectBuilder().add("name", "bar"))); + + response = jersey.target(V1_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.json(ruleJson.build().toString())); assertThat(response.getStatus()).isEqualTo(200); - assertThatJson(getPlainTextBody(response)) + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()) .withOptions(Option.IGNORING_ARRAY_ORDER) - .withMatcher("ruleUuid", equalTo(rule.getUuid().toString())) .isEqualTo(/* language=JSON */ """ { "name": "Rule 1", - "enabled": false, - "notifyChildren": false, + "enabled": true, + "notifyChildren": true, "logSuccessfulPublish": false, "scope": "PORTFOLIO", "notificationLevel": "INFORMATIONAL", @@ -638,60 +662,52 @@ public void updateNotificationRuleWithTagsTest() { "publisher": { "name": "${json-unit.any-string}", "description": "${json-unit.any-string}", - "publisherClass": "${json-unit.any-string}", + "extensionName": "${json-unit.any-string}", "templateMimeType": "${json-unit.any-string}", "defaultPublisher": true, "uuid": "${json-unit.any-string}" }, - "uuid": "${json-unit.matches:ruleUuid}" + "publisherConfig": "${json-unit.any-string}", + "uuid": "${json-unit.any-string}" } """); // Replace the previous tags with only "baz". + ruleJson = Json.createObjectBuilder(responseJson); + ruleJson.add("tags", Json.createArrayBuilder() + .add(Json.createObjectBuilder().add("name", "baz"))); + response = jersey.target(V1_NOTIFICATION_RULE).request() .header(X_API_KEY, apiKey) - .post(Entity.entity(/* language=JSON */ """ - { - "uuid": "%s", - "name": "Rule 1", - "scope": "PORTFOLIO", - "notificationLevel": "INFORMATIONAL", - "tags": [ - { - "name": "baz" - } - ] - } - """.formatted(rule.getUuid()), MediaType.APPLICATION_JSON)); + .post(Entity.json(ruleJson.build().toString())); assertThat(response.getStatus()).isEqualTo(200); - assertThatJson(getPlainTextBody(response)) - .withMatcher("ruleUuid", equalTo(rule.getUuid().toString())) - .isEqualTo(/* language=JSON */ """ - { - "name": "Rule 1", - "enabled": false, - "notifyChildren": false, - "logSuccessfulPublish": false, - "scope": "PORTFOLIO", - "notificationLevel": "INFORMATIONAL", - "projects": [], - "tags": [ - { - "name": "baz" - } - ], - "teams": [], - "notifyOn": [], - "publisher": { - "name": "${json-unit.any-string}", - "description": "${json-unit.any-string}", - "publisherClass": "${json-unit.any-string}", - "templateMimeType": "${json-unit.any-string}", - "defaultPublisher": true, - "uuid": "${json-unit.any-string}" - }, - "uuid": "${json-unit.matches:ruleUuid}" - } - """); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "name": "Rule 1", + "enabled": true, + "notifyChildren": true, + "logSuccessfulPublish": false, + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "projects": [], + "tags": [ + { + "name": "baz" + } + ], + "teams": [], + "notifyOn": [], + "publisher": { + "name": "${json-unit.any-string}", + "description": "${json-unit.any-string}", + "extensionName": "${json-unit.any-string}", + "templateMimeType": "${json-unit.any-string}", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + "publisherConfig": "${json-unit.any-string}", + "uuid": "${json-unit.any-string}" + } + """); } } diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 0a675bb288..9a6e11fc14 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -69,6 +69,7 @@ import org.dependencytrack.model.WorkflowState; import org.dependencytrack.model.WorkflowStatus; import org.dependencytrack.model.WorkflowStep; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.persistence.command.MakeAnalysisCommand; import org.dependencytrack.persistence.jdbi.MetricsTestDao; import org.dependencytrack.persistence.jdbi.VulnerabilityPolicyDao; @@ -101,6 +102,7 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_PROJECT_CREATED; import static org.dependencytrack.notification.proto.v1.Level.LEVEL_INFORMATIONAL; import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_PORTFOLIO; @@ -109,7 +111,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.not; -public class ProjectResourceTest extends ResourceTest { +class ProjectResourceTest extends ResourceTest { @RegisterExtension static JerseyTestExtension jersey = new JerseyTestExtension( @@ -118,14 +120,12 @@ public class ProjectResourceTest extends ResourceTest { .register(AuthenticationFeature.class)); @AfterEach - @Override - public void after() { + void afterEach() { EventService.getInstance().unsubscribe(CloneProjectTask.class); - super.after(); } @Test - public void getProjectsDefaultRequestTest() { + void getProjectsDefaultRequestTest() { for (int i = 0; i < 1000; i++) { qm.createProject("Acme Example", null, String.valueOf(i), null, null, null, null, false); } @@ -143,7 +143,7 @@ public void getProjectsDefaultRequestTest() { } @Test - public void getProjectsWithDataTest() throws Exception { + void getProjectsWithDataTest() throws Exception { var project = qm.createProject("Acme Example", null, "1.0", null, null, new PackageURL(RepositoryType.MAVEN.toString(), "foo", "acme", "1.0", null, null), null, false); var component = new Component(); component.setProject(project); @@ -219,7 +219,7 @@ public void getProjectsWithDataTest() throws Exception { } @Test // https://github.com/DependencyTrack/dependency-track/issues/2583 - public void getProjectsWithAclEnabledTest() { + void getProjectsWithAclEnabledTest() { enablePortfolioAccessControl(); // Create project and give access to current principal's team. @@ -243,7 +243,7 @@ public void getProjectsWithAclEnabledTest() { } @Test - public void getProjectsPaginationTest() { + void getProjectsPaginationTest() { for (int i = 0; i < 3; i++) { final var project = new Project(); project.setName("acme-app-" + (i + 1)); @@ -298,7 +298,7 @@ public void getProjectsPaginationTest() { } @Test - public void getProjectsByTagTest() { + void getProjectsByTagTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); qm.persist(projectA); @@ -342,7 +342,7 @@ public void getProjectsByTagTest() { } @Test - public void getProjectsNotAssignedToTeamWithUuidTest() { + void getProjectsNotAssignedToTeamWithUuidTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); qm.persist(projectA); @@ -372,7 +372,7 @@ public void getProjectsNotAssignedToTeamWithUuidTest() { } @Test - public void getSingleProjectByNameTest() { + void getSingleProjectByNameTest() { for (int i = 0; i < 10; i++) { qm.createProject("Acme Example " + i, null, String.valueOf(i), null, null, null, null, false); } @@ -391,7 +391,7 @@ public void getSingleProjectByNameTest() { } @Test - public void getProjectsByNameRequestTest() { + void getProjectsByNameRequestTest() { for (int i = 0; i < 1000; i++) { qm.createProject("Acme Example", null, String.valueOf(i), null, null, null, null, false); } @@ -410,7 +410,7 @@ public void getProjectsByNameRequestTest() { } @Test - public void getProjectsByClassifierRequestTest() { + void getProjectsByClassifierRequestTest() { qm.createProject("Acme Example A", null, "1.0", null, null, null, null, false); var p2 = qm.createProject("Acme Example B", null, "1.0", null, null, null, null, false); p2.setClassifier(Classifier.LIBRARY); @@ -428,7 +428,7 @@ public void getProjectsByClassifierRequestTest() { } @Test - public void getProjectsWithMetricsTest() { + void getProjectsWithMetricsTest() { var project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); var projectMetrics = new ProjectMetrics(); projectMetrics.setProjectId(project.getId()); @@ -452,7 +452,7 @@ public void getProjectsWithMetricsTest() { } @Test - public void getProjectsByInvalidNameRequestTest() { + void getProjectsByInvalidNameRequestTest() { for (int i = 0; i < 1000; i++) { qm.createProject("Acme Example", null, String.valueOf(i), null, null, null, null, false); } @@ -469,7 +469,7 @@ public void getProjectsByInvalidNameRequestTest() { } @Test - public void getProjectsByNameActiveOnlyRequestTest() { + void getProjectsByNameActiveOnlyRequestTest() { for (int i = 0; i < 500; i++) { qm.createProject("Acme Example", null, String.valueOf(i), null, null, null, null, false); } @@ -490,7 +490,7 @@ public void getProjectsByNameActiveOnlyRequestTest() { } @Test - public void getProjectsOnlyRootTest() { + void getProjectsOnlyRootTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); qm.persist(projectA); @@ -546,7 +546,7 @@ public void getProjectsOnlyRootTest() { } @Test - public void getProjectLookupTest() { + void getProjectLookupTest() { for (int i = 0; i < 500; i++) { qm.createProject("Acme Example", null, String.valueOf(i), null, null, null, null, false); } @@ -569,7 +569,7 @@ public void getProjectLookupTest() { } @Test - public void getProjectLookupNotFoundTest() { + void getProjectLookupNotFoundTest() { final var project = new Project(); project.setName("acme-app"); project.setVersion("1.2.3"); @@ -586,7 +586,7 @@ public void getProjectLookupNotFoundTest() { } @Test - public void getProjectLookupNotPermittedTest() { + void getProjectLookupNotPermittedTest() { enablePortfolioAccessControl(); final var project = new Project(); @@ -611,7 +611,7 @@ public void getProjectLookupNotPermittedTest() { } @Test - public void getProjectsAscOrderedRequestTest() { + void getProjectsAscOrderedRequestTest() { qm.createProject("ABC", null, "1.0", null, null, null, null, false); qm.createProject("DEF", null, "1.0", null, null, null, null, false); Response response = jersey.target(V1_PROJECT) @@ -628,7 +628,7 @@ public void getProjectsAscOrderedRequestTest() { } @Test - public void getProjectsDescOrderedRequestTest() { + void getProjectsDescOrderedRequestTest() { qm.createProject("ABC", null, "1.0", null, null, null, null, false); qm.createProject("DEF", null, "1.0", null, null, null, null, false); Response response = jersey.target(V1_PROJECT) @@ -645,7 +645,7 @@ public void getProjectsDescOrderedRequestTest() { } @Test - public void getProjectsConciseTest() { + void getProjectsConciseTest() { final var project = new Project(); project.setGroup("com.acme"); project.setName("acme-app"); @@ -687,7 +687,7 @@ public void getProjectsConciseTest() { } @Test - public void getProjectsConciseWithAclTest() { + void getProjectsConciseWithAclTest() { enablePortfolioAccessControl(); final var projectA = new Project(); @@ -727,7 +727,7 @@ public void getProjectsConciseWithAclTest() { } @Test - public void getProjectsConciseEmptyTest() { + void getProjectsConciseEmptyTest() { final Response response = jersey.target(V1_PROJECT + "/concise") .request() .header(X_API_KEY, apiKey) @@ -738,7 +738,7 @@ public void getProjectsConciseEmptyTest() { } @Test - public void getProjectsConcisePaginationTest() { + void getProjectsConcisePaginationTest() { for (int i = 0; i < 3; i++) { final var project = new Project(); project.setName("acme-app-" + (i + 1)); @@ -794,7 +794,7 @@ public void getProjectsConcisePaginationTest() { } @Test - public void getProjectsConciseFilterByNameTest() { + void getProjectsConciseFilterByNameTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); qm.persist(projectA); @@ -835,7 +835,7 @@ public void getProjectsConciseFilterByNameTest() { } @Test - public void getProjectsConciseFilterByVersionTest() { + void getProjectsConciseFilterByVersionTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); projectA.setVersion("1.0"); @@ -879,7 +879,7 @@ public void getProjectsConciseFilterByVersionTest() { } @Test - public void getProjectsConciseFilterByTagTest() { + void getProjectsConciseFilterByTagTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); qm.persist(projectA); @@ -927,7 +927,7 @@ public void getProjectsConciseFilterByTagTest() { } @Test - public void getProjectsConciseFilterByTeamTest() { + void getProjectsConciseFilterByTeamTest() { enablePortfolioAccessControl(); // Create project and give access to current principal's team. final var projectB = new Project(); @@ -973,7 +973,7 @@ public void getProjectsConciseFilterByTeamTest() { } @Test - public void getProjectsConciseOnlyRootTest() { + void getProjectsConciseOnlyRootTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); qm.persist(projectA); @@ -1059,7 +1059,7 @@ public void getProjectsConciseOnlyRootTest() { } @Test - public void getProjectsConciseWithFilterByActiveTest() { + void getProjectsConciseWithFilterByActiveTest() { final var projectA = new Project(); projectA.setName("acme-app-a"); projectA.setInactiveSince(new Date()); @@ -1117,7 +1117,7 @@ public void getProjectsConciseWithFilterByActiveTest() { } @Test - public void getProjectsConciseWithLatestMetricsTest() { + void getProjectsConciseWithLatestMetricsTest() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -1215,7 +1215,7 @@ public void getProjectsConciseWithLatestMetricsTest() { } @Test - public void getProjectChildrenConciseTest() { + void getProjectChildrenConciseTest() { final var parentProject = new Project(); parentProject.setGroup("com.acme"); parentProject.setName("acme-app"); @@ -1263,7 +1263,7 @@ public void getProjectChildrenConciseTest() { } @Test - public void getProjectChildrenConciseWithAclTest() { + void getProjectChildrenConciseWithAclTest() { enablePortfolioAccessControl(); final var parentProject = new Project(); @@ -1354,7 +1354,7 @@ public void getProjectChildrenConciseWithAclTest() { } @Test - public void getProjectChildrenConciseEmptyTest() { + void getProjectChildrenConciseEmptyTest() { final var parentProject = new Project(); parentProject.setName("acme-app"); qm.persist(parentProject); @@ -1369,7 +1369,7 @@ public void getProjectChildrenConciseEmptyTest() { } @Test - public void getProjectChildrenConciseWithParentNotExistsTest() { + void getProjectChildrenConciseWithParentNotExistsTest() { final Response response = jersey.target(V1_PROJECT + "/concise/6ce40fad-0cff-427a-86ce-acb248872b5b/children") .request() .header(X_API_KEY, apiKey) @@ -1380,7 +1380,7 @@ public void getProjectChildrenConciseWithParentNotExistsTest() { } @Test - public void getProjectChildrenConcisePaginationTest() { + void getProjectChildrenConcisePaginationTest() { final var parentProject = new Project(); parentProject.setName("acme-app"); qm.persist(parentProject); @@ -1441,7 +1441,7 @@ public void getProjectChildrenConcisePaginationTest() { } @Test - public void getProjectChildrenConciseFilterByNameTest() { + void getProjectChildrenConciseFilterByNameTest() { final var parentProject = new Project(); parentProject.setName("acme-app"); qm.persist(parentProject); @@ -1489,7 +1489,7 @@ public void getProjectChildrenConciseFilterByNameTest() { } @Test - public void getProjectChildrenConciseFilterByVersionTest() { + void getProjectChildrenConciseFilterByVersionTest() { final var parentProject = new Project(); parentProject.setName("acme-app"); qm.persist(parentProject); @@ -1539,7 +1539,7 @@ public void getProjectChildrenConciseFilterByVersionTest() { } @Test - public void getProjectChildrenConciseFilterByTagTest() { + void getProjectChildrenConciseFilterByTagTest() { final var parentProject = new Project(); parentProject.setName("acme-app"); qm.persist(parentProject); @@ -1593,7 +1593,7 @@ public void getProjectChildrenConciseFilterByTagTest() { } @Test - public void getProjectChildrenConciseFilterByTeamTest() { + void getProjectChildrenConciseFilterByTeamTest() { final var parentProject = new Project(); parentProject.setName("acme-app"); qm.persist(parentProject); @@ -1647,7 +1647,7 @@ public void getProjectChildrenConciseFilterByTeamTest() { } @Test - public void getProjectChildrenConciseWithLatestMetricsTest() { + void getProjectChildrenConciseWithLatestMetricsTest() { final var parentProject = new Project(); parentProject.setName("acme-app"); qm.persist(parentProject); @@ -1750,7 +1750,7 @@ public void getProjectChildrenConciseWithLatestMetricsTest() { } @Test - public void getProjectByUuidTest() { + void getProjectByUuidTest() { final var parentProject = new Project(); parentProject.setName("acme-app-parent"); parentProject.setVersion("1.0.0"); @@ -1814,7 +1814,7 @@ public void getProjectByUuidTest() { } @Test - public void getProjectByUuidNotPermittedTest() { + void getProjectByUuidNotPermittedTest() { enablePortfolioAccessControl(); final var project = new Project(); @@ -1836,7 +1836,7 @@ public void getProjectByUuidNotPermittedTest() { } @Test - public void getProjectByInvalidUuidTest() { + void getProjectByInvalidUuidTest() { Response response = jersey.target(V1_PROJECT + "/" + UUID.randomUUID()) .request() .header(X_API_KEY, apiKey) @@ -1848,7 +1848,7 @@ public void getProjectByInvalidUuidTest() { } @Test - public void getProjectByTagTest() { + void getProjectByTagTest() { List tags = new ArrayList<>(); Tag tag = qm.createTag("production"); tags.add(tag); @@ -1866,7 +1866,7 @@ public void getProjectByTagTest() { } @Test - public void getProjectByCaseInsensitiveTagTest() { + void getProjectByCaseInsensitiveTagTest() { List tags = new ArrayList<>(); Tag tag = qm.createTag("PRODUCTION"); tags.add(tag); @@ -1884,7 +1884,7 @@ public void getProjectByCaseInsensitiveTagTest() { } @Test - public void getProjectByUnknownTagTest() { + void getProjectByUnknownTagTest() { List tags = new ArrayList<>(); Tag tag = qm.createTag("production"); tags.add(tag); @@ -1902,7 +1902,7 @@ public void getProjectByUnknownTagTest() { } @Test - public void getProjectsByTagAclTest() { + void getProjectsByTagAclTest() { enablePortfolioAccessControl(); final var accessibleProject = new Project(); @@ -1932,7 +1932,9 @@ public void getProjectsByTagAclTest() { } @Test - public void createProjectTest() throws Exception { + void createProjectTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + Project project = new Project(); project.setName("Acme Example"); project.setVersion("1.0"); @@ -1959,7 +1961,7 @@ public void createProjectTest() throws Exception { } @Test - public void createProjectDuplicateTest() { + void createProjectDuplicateTest() { Project project = new Project(); project.setName("Acme Example"); project.setVersion("1.0"); @@ -1978,7 +1980,7 @@ public void createProjectDuplicateTest() { } @Test - public void createProjectInactiveParentTest() { + void createProjectInactiveParentTest() { final var parentProject = new Project(); parentProject.setName("acme-app-parent"); parentProject.setVersion("1.0.0"); @@ -2002,7 +2004,7 @@ public void createProjectInactiveParentTest() { } @Test - public void createProjectEmptyTest() { + void createProjectEmptyTest() { Project project = new Project(); project.setName(" "); Response response = jersey.target(V1_PROJECT) @@ -2013,7 +2015,7 @@ public void createProjectEmptyTest() { } @Test - public void createProjectNonExistentParentTest() { + void createProjectNonExistentParentTest() { final Response response = jersey.target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) @@ -2036,7 +2038,7 @@ public void createProjectNonExistentParentTest() { } @Test - public void createProjectInaccessibleParentTest() { + void createProjectInaccessibleParentTest() { enablePortfolioAccessControl(); final var parentProject = new Project(); @@ -2073,7 +2075,7 @@ public void createProjectInaccessibleParentTest() { } @Test - public void updateProjectTest() { + void updateProjectTest() { Project project = qm.createProject("ABC", null, "1.0", null, null, null, null, false); project.setDescription("Test project"); Response response = jersey.target(V1_PROJECT) @@ -2089,7 +2091,7 @@ public void updateProjectTest() { } @Test - public void updateProjectNotFoundTest() { + void updateProjectNotFoundTest() { final Response response = jersey.target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) @@ -2105,7 +2107,7 @@ public void updateProjectNotFoundTest() { } @Test - public void updateProjectNotPermittedTest() { + void updateProjectNotPermittedTest() { enablePortfolioAccessControl(); final var project = new Project(); @@ -2132,7 +2134,7 @@ public void updateProjectNotPermittedTest() { } @Test - public void updateProjectTagsTest() { + void updateProjectTagsTest() { final var tags = Stream.of("tag1", "tag2").map(qm::createTag).collect(Collectors.toUnmodifiableList()); final var p1 = qm.createProject("ABC", "Test project", "1.0", tags, null, null, null, false); @@ -2189,7 +2191,7 @@ public void updateProjectTagsTest() { } @Test - public void updateProjectEmptyNameTest() { + void updateProjectEmptyNameTest() { Project project = qm.createProject("ABC", null, "1.0", null, null, null, null, false); project.setName(" "); Response response = jersey.target(V1_PROJECT) @@ -2200,7 +2202,7 @@ public void updateProjectEmptyNameTest() { } @Test - public void updateProjectDuplicateTest() { + void updateProjectDuplicateTest() { qm.createProject("ABC", null, "1.0", null, null, null, null, false); Project project = qm.createProject("DEF", null, "1.0", null, null, null, null, false); project = qm.detach(Project.class, project.getId()); @@ -2215,7 +2217,7 @@ public void updateProjectDuplicateTest() { } @Test - public void updateProjectInaccessibleParentTest() { + void updateProjectInaccessibleParentTest() { enablePortfolioAccessControl(); final var parentProject = new Project(); @@ -2258,7 +2260,7 @@ public void updateProjectInaccessibleParentTest() { } @Test - public void updateProjectNonExistentParentTest() { + void updateProjectNonExistentParentTest() { final var project = new Project(); project.setName("acme-app"); qm.persist(project); @@ -2286,7 +2288,7 @@ public void updateProjectNonExistentParentTest() { } @Test - public void deleteProjectTest() { + void deleteProjectTest() { Project project = qm.createProject("ABC", null, "1.0", null, null, null, null, false); Response response = jersey.target(V1_PROJECT + "/" + project.getUuid().toString()) .request() @@ -2296,7 +2298,7 @@ public void deleteProjectTest() { } @Test - public void deleteProjectInvalidUuidTest() { + void deleteProjectInvalidUuidTest() { qm.createProject("ABC", null, "1.0", null, null, null, null, false); Response response = jersey.target(V1_PROJECT + "/" + UUID.randomUUID().toString()) .request() @@ -2306,7 +2308,7 @@ public void deleteProjectInvalidUuidTest() { } @Test - public void deleteProjectAclTest() { + void deleteProjectAclTest() { enablePortfolioAccessControl(); final var project = new Project(); @@ -2336,7 +2338,7 @@ public void deleteProjectAclTest() { } @Test - public void patchProjectNotModifiedTest() { + void patchProjectNotModifiedTest() { final var tags = Stream.of("tag1", "tag2").map(qm::createTag).collect(Collectors.toUnmodifiableList()); final var p1 = qm.createProject("ABC", "Test project", "1.0", tags, null, null, null, false); @@ -2352,7 +2354,7 @@ public void patchProjectNotModifiedTest() { } @Test - public void patchProjectNameVersionConflictTest() { + void patchProjectNameVersionConflictTest() { final var tags = Stream.of("tag1", "tag2").map(qm::createTag).collect(Collectors.toUnmodifiableList()); final var p1 = qm.createProject("ABC", "Test project", "1.0", tags, null, null, null, false); qm.createProject("ABC", "Test project", "0.9", null, null, null, null, false); @@ -2368,7 +2370,7 @@ public void patchProjectNameVersionConflictTest() { } @Test - public void patchProjectNotFoundTest() { + void patchProjectNotFoundTest() { final var response = jersey.target(V1_PROJECT + "/" + UUID.randomUUID()) .request() .header(X_API_KEY, apiKey) @@ -2378,7 +2380,7 @@ public void patchProjectNotFoundTest() { } @Test - public void patchProjectNotPermittedTest() { + void patchProjectNotPermittedTest() { enablePortfolioAccessControl(); final var project = new Project(); @@ -2405,7 +2407,7 @@ public void patchProjectNotPermittedTest() { } @Test - public void patchProjectParentTest() { + void patchProjectParentTest() { final Project parent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); final Project project = qm.createProject("DEF", null, "2.0", null, parent, null, null, false); final Project newParent = qm.createProject("GHI", null, "3.0", null, null, null, null, false); @@ -2449,7 +2451,7 @@ public void patchProjectParentTest() { } @Test - public void patchProjectExternalReferencesTest() { + void patchProjectExternalReferencesTest() { final var project = qm.createProject("referred-project", "ExtRef test project", "1.0", null, null, null, null, false); final var ref1 = new ExternalReference(); ref1.setType(org.cyclonedx.model.ExternalReference.Type.VCS); @@ -2482,7 +2484,7 @@ public void patchProjectExternalReferencesTest() { } @Test - public void patchProjectParentNotFoundTest() { + void patchProjectParentNotFoundTest() { final Project parent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); final Project project = qm.createProject("DEF", null, "2.0", null, parent, null, null, false); @@ -2507,7 +2509,7 @@ public void patchProjectParentNotFoundTest() { } @Test - public void patchProjectParentInaccessibleTest() { + void patchProjectParentInaccessibleTest() { enablePortfolioAccessControl(); final var parentProject = new Project(); @@ -2544,7 +2546,7 @@ public void patchProjectParentInaccessibleTest() { } @Test - public void patchProjectSuccessfullyPatchedTest() { + void patchProjectSuccessfullyPatchedTest() { final var tags = Stream.of("tag1", "tag2").map(qm::createTag).collect(Collectors.toUnmodifiableList()); final var p1 = qm.createProject("ABC", "Test project", "1.0", tags, null, null, null, false); final var projectManufacturerContact = new OrganizationalContact(); @@ -2635,7 +2637,7 @@ public void patchProjectSuccessfullyPatchedTest() { } @Test - public void getRootProjectsTest() { + void getRootProjectsTest() { Project parent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); Project child = qm.createProject("DEF", null, "1.0", null, parent, null, null, false); qm.createProject("GHI", null, "1.0", null, child, null, null, false); @@ -2653,7 +2655,7 @@ public void getRootProjectsTest() { } @Test - public void getChildrenProjectsTest() { + void getChildrenProjectsTest() { Project parent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); Project child = qm.createProject("DEF", null, "1.0", null, parent, null, null, false); qm.createProject("GHI", null, "1.0", null, parent, null, null, false); @@ -2671,7 +2673,7 @@ public void getChildrenProjectsTest() { } @Test - public void updateChildAsParentOfChild() { + void updateChildAsParentOfChild() { Project parent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); Project child = qm.createProject("DEF", null, "1.0", null, parent, null, null, false); @@ -2686,7 +2688,7 @@ public void updateChildAsParentOfChild() { } @Test - public void updateParentToInactiveWithActiveChild() { + void updateParentToInactiveWithActiveChild() { Project parent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); qm.createProject("DEF", null, "1.0", null, parent, null, null, false); @@ -2700,7 +2702,7 @@ public void updateParentToInactiveWithActiveChild() { } @Test - public void createProjectWithoutVersionDuplicateTest() { + void createProjectWithoutVersionDuplicateTest() { Project project = new Project(); project.setName("Acme Example"); Response response = jersey.target(V1_PROJECT) @@ -2718,7 +2720,7 @@ public void createProjectWithoutVersionDuplicateTest() { } @Test - public void updateProjectParentToSelf() { + void updateProjectParentToSelf() { Project parent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); Project tmpProject = new Project(); @@ -2732,7 +2734,7 @@ public void updateProjectParentToSelf() { } @Test - public void getProjectsWithoutDescendantsOfTest() { + void getProjectsWithoutDescendantsOfTest() { Project grandParent = qm.createProject("ABC", null, "1.0", null, null, null, null, false); Project parent = qm.createProject("DEF", null, "1.0", null, grandParent, null, null, false); Project child = qm.createProject("GHI", null, "1.0", null, parent, null, null, false); @@ -2751,7 +2753,7 @@ public void getProjectsWithoutDescendantsOfTest() { } @Test - public void cloneProjectTest() { + void cloneProjectTest() { EventService.getInstance().subscribe(CloneProjectEvent.class, new CloneProjectTask()); final var projectManufacturer = new OrganizationalEntity(); @@ -3063,7 +3065,7 @@ public void cloneProjectTest() { } @Test - public void cloneProjectConflictTest() { + void cloneProjectConflictTest() { final var project = new Project(); project.setName("acme-app"); project.setVersion("1.0.0"); @@ -3083,7 +3085,7 @@ public void cloneProjectConflictTest() { } @Test - public void cloneProjectWithAclTest() { + void cloneProjectWithAclTest() { enablePortfolioAccessControl(); final var accessProject = new Project(); @@ -3130,7 +3132,7 @@ public void cloneProjectWithAclTest() { } @Test - public void validateProjectVersionsActiveInactiveTest() { + void validateProjectVersionsActiveInactiveTest() { Project project = qm.createProject("ABC", null, "1.0", null, null, null, null, false); qm.createProject("ABC", null, "2.0", null, null, null, new Date(), false); qm.createProject("ABC", null, "3.0", null, null, null, null, false); @@ -3160,7 +3162,7 @@ public void validateProjectVersionsActiveInactiveTest() { } @Test // https://github.com/DependencyTrack/dependency-track/issues/4048 - public void issue4048RegressionTest() { + void issue4048RegressionTest() { final int projectsPerLevel = 10; final int maxDepth = 5; @@ -3232,7 +3234,7 @@ public void issue4048RegressionTest() { } @Test // https://github.com/DependencyTrack/dependency-track/issues/4413 - public void cloneProjectWithBrokenDependencyGraphTest() { + void cloneProjectWithBrokenDependencyGraphTest() { EventService.getInstance().subscribe(CloneProjectEvent.class, new CloneProjectTask()); final var project = new Project(); @@ -3277,7 +3279,7 @@ public void cloneProjectWithBrokenDependencyGraphTest() { } @Test // https://github.com/DependencyTrack/dependency-track/issues/3883 - public void issue3883RegressionTest() { + void issue3883RegressionTest() { Response response = jersey.target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) @@ -3371,7 +3373,7 @@ public void issue3883RegressionTest() { } @Test - public void createProjectAsLatestTest() { + void createProjectAsLatestTest() { Project project = new Project(); project.setName("Acme Example"); project.setVersion("1.0"); @@ -3410,7 +3412,7 @@ public void createProjectAsLatestTest() { } @Test - public void createProjectAsLatestWithACLTest() { + void createProjectAsLatestWithACLTest() { enablePortfolioAccessControl(); final var accessProject = new Project(); @@ -3448,7 +3450,7 @@ public void createProjectAsLatestWithACLTest() { } @Test - public void createProjectAsInactiveTest() { + void createProjectAsInactiveTest() { Project project = new Project(); project.setName("Acme Example"); project.setVersion("1.0"); @@ -3474,7 +3476,7 @@ public void createProjectAsInactiveTest() { } @Test - public void updateProjectAsLatestTest() { + void updateProjectAsLatestTest() { // create project not as latest Project project = qm.createProject("ABC", null, "1.0", null, null, null, null, false, false); @@ -3509,7 +3511,7 @@ public void updateProjectAsLatestTest() { } @Test - public void updateProjectAsLatestWithACLAndAccessTest() { + void updateProjectAsLatestWithACLAndAccessTest() { enablePortfolioAccessControl(); final var accessLatestProject = new Project(); @@ -3543,7 +3545,7 @@ public void updateProjectAsLatestWithACLAndAccessTest() { } @Test - public void updateProjectAsLatestWithACLAndNoAccessTest() { + void updateProjectAsLatestWithACLAndNoAccessTest() { enablePortfolioAccessControl(); final var noAccessLatestProject = new Project(); @@ -3572,7 +3574,7 @@ public void updateProjectAsLatestWithACLAndNoAccessTest() { } @Test - public void patchProjectAsLatestTest() { + void patchProjectAsLatestTest() { // create project not as latest Project project = qm.createProject("ABC", null, "1.0", null, null, null, null, false, false); @@ -3609,7 +3611,7 @@ public void patchProjectAsLatestTest() { } @Test - public void patchProjectAsLatestWithACLAndAccessTest() { + void patchProjectAsLatestWithACLAndAccessTest() { enablePortfolioAccessControl(); final var accessLatestProject = new Project(); @@ -3644,7 +3646,7 @@ public void patchProjectAsLatestWithACLAndAccessTest() { } @Test - public void patchProjectAsLatestWithACLAndNoAccessTest() { + void patchProjectAsLatestWithACLAndNoAccessTest() { enablePortfolioAccessControl(); final var noAccessLatestProject = new Project(); @@ -3675,7 +3677,7 @@ public void patchProjectAsLatestWithACLAndNoAccessTest() { } @Test - public void cloneProjectAsLatestTest() { + void cloneProjectAsLatestTest() { EventService.getInstance().subscribe(CloneProjectEvent.class, new CloneProjectTask()); final var project = new Project(); @@ -3714,7 +3716,7 @@ public void cloneProjectAsLatestTest() { } @Test - public void getLatestProjectTest() { + void getLatestProjectTest() { qm.createProject("Acme Example", null, "1.0.0", null, null, null, null, false); qm.createProject("Acme Example", null, "1.0.2", null, null, null, null, true, false); qm.createProject("Different project", null, "1.0.3", null, null, null, null, true, false); @@ -3731,7 +3733,7 @@ public void getLatestProjectTest() { } @Test - public void getLatestProjectWithAclEnabledTest() { + void getLatestProjectWithAclEnabledTest() { enablePortfolioAccessControl(); // Create project and give access to current principal's team. @@ -3755,7 +3757,7 @@ public void getLatestProjectWithAclEnabledTest() { } @Test - public void getLatestProjectWithAclEnabledNoAccessTest() { + void getLatestProjectWithAclEnabledNoAccessTest() { enablePortfolioAccessControl(); // Create projects and give NO access @@ -3770,7 +3772,7 @@ public void getLatestProjectWithAclEnabledNoAccessTest() { } @Test - public void createProjectAsUserWithAclEnabledAndExistingTeamByUuidTest() { + void createProjectAsUserWithAclEnabledAndExistingTeamByUuidTest() { qm.createConfigProperty( ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), @@ -3815,7 +3817,7 @@ public void createProjectAsUserWithAclEnabledAndExistingTeamByUuidTest() { } @Test - public void createProjectAsUserWithAclEnabledAndExistingTeamByNameTest() { + void createProjectAsUserWithAclEnabledAndExistingTeamByNameTest() { qm.createConfigProperty( ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), @@ -3860,7 +3862,7 @@ public void createProjectAsUserWithAclEnabledAndExistingTeamByNameTest() { } @Test - public void createProjectAsUserWithAclEnabledAndWithoutTeamTest() { + void createProjectAsUserWithAclEnabledAndWithoutTeamTest() { qm.createConfigProperty( ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), @@ -3900,7 +3902,7 @@ public void createProjectAsUserWithAclEnabledAndWithoutTeamTest() { } @Test - public void createProjectAsUserWithNotAllowedExistingTeamTest() { + void createProjectAsUserWithNotAllowedExistingTeamTest() { qm.createConfigProperty( ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), @@ -3932,7 +3934,7 @@ public void createProjectAsUserWithNotAllowedExistingTeamTest() { } @Test - public void createProjectAsUserWithAclEnabledAndNotMemberOfTeamAdminTest() { + void createProjectAsUserWithAclEnabledAndNotMemberOfTeamAdminTest() { qm.createConfigProperty( ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), @@ -3981,7 +3983,7 @@ public void createProjectAsUserWithAclEnabledAndNotMemberOfTeamAdminTest() { } @Test - public void createProjectAsUserWithAclEnabledAndTeamNotExistingNoAdminTest() { + void createProjectAsUserWithAclEnabledAndTeamNotExistingNoAdminTest() { qm.createConfigProperty( ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), @@ -4014,7 +4016,7 @@ public void createProjectAsUserWithAclEnabledAndTeamNotExistingNoAdminTest() { } @Test - public void createProjectAsUserWithAclEnabledAndTeamNotExistingAdminTest() { + void createProjectAsUserWithAclEnabledAndTeamNotExistingAdminTest() { qm.createConfigProperty( ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), @@ -4050,7 +4052,7 @@ public void createProjectAsUserWithAclEnabledAndTeamNotExistingAdminTest() { } @Test - public void createProjectAsApiKeyWithAclEnabledAndWithExistentTeamTest() { + void createProjectAsApiKeyWithAclEnabledAndWithExistentTeamTest() { enablePortfolioAccessControl(); final Response response = jersey.target(V1_PROJECT) @@ -4085,7 +4087,7 @@ public void createProjectAsApiKeyWithAclEnabledAndWithExistentTeamTest() { } @Test - public void patchActiveProjectToInactiveTest() { + void patchActiveProjectToInactiveTest() { // create project as active Project project = qm.createProject("ABC", null, null, null, null, null, null, false, false); @@ -4116,7 +4118,7 @@ public void patchActiveProjectToInactiveTest() { } @Test - public void patchInactiveProjectToActiveTest() { + void patchInactiveProjectToActiveTest() { // create project as inactive Project project = qm.createProject("ABC", null, null, null, null, null, new Date(), false, false); @@ -4146,7 +4148,7 @@ public void patchInactiveProjectToActiveTest() { } @Test - public void updateActiveProjectToInactiveTest() { + void updateActiveProjectToInactiveTest() { // create project as active Project project = qm.createProject("ABC", null, null, null, null, null, null, false, false); @@ -4178,7 +4180,7 @@ public void updateActiveProjectToInactiveTest() { } @Test - public void updateInactiveProjectToActiveTest() { + void updateInactiveProjectToActiveTest() { // create project as inactive Project project = qm.createProject("ABC", null, null, null, null, null, new Date(), false, false); diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java index a97a9bffb3..8d66e700fc 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java @@ -37,6 +37,7 @@ import org.dependencytrack.model.IdentifiableObject; import org.dependencytrack.model.Project; import org.dependencytrack.model.Role; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.resources.v1.vo.ModifyUserProjectRoleRequest; import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.server.ResourceConfig; @@ -52,12 +53,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_USER_CREATED; import static org.dependencytrack.notification.proto.v1.Group.GROUP_USER_DELETED; import static org.dependencytrack.notification.proto.v1.Level.LEVEL_INFORMATIONAL; import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_SYSTEM; -public class UserResourceAuthenticatedTest extends ResourceTest { +class UserResourceAuthenticatedTest extends ResourceTest { @RegisterExtension static JerseyTestExtension jersey = new JerseyTestExtension( @@ -69,16 +71,14 @@ public class UserResourceAuthenticatedTest extends ResourceTest { private String jwt; @BeforeEach - @Override - public void before() throws Exception { - super.before(); + void beforeEach() { testUser = qm.createManagedUser("testuser", TEST_USER_PASSWORD_HASH); this.jwt = new JsonWebToken().createToken(testUser); qm.addUserToTeam(testUser, team); } @Test - public void getManagedUsersTest() { + void getManagedUsersTest() { for (int i=0; i<1000; i++) { qm.createManagedUser("managed-user-" + i, TEST_USER_PASSWORD_HASH); } @@ -94,7 +94,7 @@ public void getManagedUsersTest() { } @Test - public void getLdapUsersTest() { + void getLdapUsersTest() { for (int i=0; i<1000; i++) { qm.createLdapUser("ldap-user-" + i); } @@ -110,7 +110,7 @@ public void getLdapUsersTest() { } @Test - public void getSelfTest() { + void getSelfTest() { Response response = jersey.target(V1_USER + "/self").request() .header("Authorization", "Bearer " + jwt) .get(Response.class); @@ -122,7 +122,7 @@ public void getSelfTest() { } @Test - public void getSelfNonUserTest() { + void getSelfNonUserTest() { Response response = jersey.target(V1_USER + "/self").request() .header(X_API_KEY, apiKey) .get(Response.class); @@ -130,7 +130,7 @@ public void getSelfNonUserTest() { } @Test - public void updateSelfTest() { + void updateSelfTest() { ManagedUser user = new ManagedUser(); user.setUsername(testUser.getUsername()); user.setFullname("Captain BlackBeard"); @@ -147,7 +147,7 @@ public void updateSelfTest() { } @Test - public void updateSelfInvalidFullnameTest() { + void updateSelfInvalidFullnameTest() { ManagedUser user = new ManagedUser(); user.setUsername(testUser.getUsername()); user.setFullname(""); @@ -161,7 +161,7 @@ public void updateSelfInvalidFullnameTest() { } @Test - public void updateSelfInvalidEmailTest() { + void updateSelfInvalidEmailTest() { ManagedUser user = new ManagedUser(); user.setUsername(testUser.getUsername()); user.setFullname("Captain BlackBeard"); @@ -175,7 +175,7 @@ public void updateSelfInvalidEmailTest() { } @Test - public void updateSelfUnauthorizedTest() { + void updateSelfUnauthorizedTest() { ManagedUser user = new ManagedUser(); user.setUsername(testUser.getUsername()); Response response = jersey.target(V1_USER + "/self").request() @@ -185,7 +185,7 @@ public void updateSelfUnauthorizedTest() { } @Test - public void updateSelfPasswordsTest() { + void updateSelfPasswordsTest() { ManagedUser user = new ManagedUser(); user.setUsername(testUser.getUsername()); user.setFullname("Captain BlackBeard"); @@ -204,7 +204,7 @@ public void updateSelfPasswordsTest() { } @Test - public void updateSelfPasswordMismatchTest() { + void updateSelfPasswordMismatchTest() { ManagedUser user = new ManagedUser(); user.setUsername(testUser.getUsername()); user.setFullname("Captain BlackBeard"); @@ -220,7 +220,9 @@ public void updateSelfPasswordMismatchTest() { } @Test - public void createLdapUserTest() throws InterruptedException { + void createLdapUserTest() { + createCatchAllNotificationRule(qm, NotificationScope.SYSTEM); + LdapUser user = new LdapUser(); user.setUsername("blackbeard"); Response response = jersey.target(V1_USER + "/ldap").request() @@ -242,7 +244,7 @@ public void createLdapUserTest() throws InterruptedException { } @Test - public void createLdapUserInvalidUsernameTest() throws InterruptedException { + void createLdapUserInvalidUsernameTest() { LdapUser user = new LdapUser(); user.setUsername(""); Response response = jersey.target(V1_USER + "/ldap").request() @@ -257,7 +259,7 @@ public void createLdapUserInvalidUsernameTest() throws InterruptedException { } @Test - public void createLdapUserDuplicateUsernameTest() { + void createLdapUserDuplicateUsernameTest() { qm.createLdapUser("blackbeard"); LdapUser user = new LdapUser(); user.setUsername("blackbeard"); @@ -270,7 +272,9 @@ public void createLdapUserDuplicateUsernameTest() { } @Test - public void deleteLdapUserTest() { + void deleteLdapUserTest() { + createCatchAllNotificationRule(qm, NotificationScope.SYSTEM); + qm.createLdapUser("blackbeard"); LdapUser user = new LdapUser(); user.setUsername("blackbeard"); @@ -291,7 +295,9 @@ public void deleteLdapUserTest() { } @Test - public void createManagedUserTest() throws InterruptedException { + void createManagedUserTest() { + createCatchAllNotificationRule(qm, NotificationScope.SYSTEM); + ManagedUser user = new ManagedUser(); user.setFullname("Captain BlackBeard"); user.setEmail("blackbeard@example.com"); @@ -319,7 +325,7 @@ public void createManagedUserTest() throws InterruptedException { } @Test - public void createManagedUserInvalidUsernameTest() { + void createManagedUserInvalidUsernameTest() { ManagedUser user = new ManagedUser(); user.setFullname("Captain BlackBeard"); user.setEmail("blackbeard@example.com"); @@ -336,7 +342,7 @@ public void createManagedUserInvalidUsernameTest() { } @Test - public void createManagedUserInvalidFullnameTest() { + void createManagedUserInvalidFullnameTest() { ManagedUser user = new ManagedUser(); user.setFullname(""); user.setEmail("blackbeard@example.com"); @@ -353,7 +359,7 @@ public void createManagedUserInvalidFullnameTest() { } @Test - public void createManagedUserInvalidEmailTest() { + void createManagedUserInvalidEmailTest() { ManagedUser user = new ManagedUser(); user.setFullname("Captain BlackBeard"); user.setEmail(""); @@ -370,7 +376,7 @@ public void createManagedUserInvalidEmailTest() { } @Test - public void createManagedUserInvalidPasswordTest() { + void createManagedUserInvalidPasswordTest() { ManagedUser user = new ManagedUser(); user.setFullname("Captain BlackBeard"); user.setEmail("blackbeard@example.com"); @@ -387,7 +393,7 @@ public void createManagedUserInvalidPasswordTest() { } @Test - public void createManagedUserPasswordMismatchTest() { + void createManagedUserPasswordMismatchTest() { ManagedUser user = new ManagedUser(); user.setFullname("Captain BlackBeard"); user.setEmail("blackbeard@example.com"); @@ -404,7 +410,7 @@ public void createManagedUserPasswordMismatchTest() { } @Test - public void createManagedUserDuplicateUsernameTest() { + void createManagedUserDuplicateUsernameTest() { qm.createManagedUser("blackbeard", TEST_USER_PASSWORD_HASH); ManagedUser user = new ManagedUser(); user.setFullname("Captain BlackBeard"); @@ -422,7 +428,7 @@ public void createManagedUserDuplicateUsernameTest() { } @Test - public void updateManagedUserTest() { + void updateManagedUserTest() { qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); ManagedUser user = new ManagedUser(); user.setUsername("blackbeard"); @@ -446,7 +452,7 @@ public void updateManagedUserTest() { } @Test - public void updateManagedUserInvalidFullnameTest() { + void updateManagedUserInvalidFullnameTest() { qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); ManagedUser user = new ManagedUser(); user.setUsername("blackbeard"); @@ -465,7 +471,7 @@ public void updateManagedUserInvalidFullnameTest() { } @Test - public void updateManagedUserInvalidEmailTest() { + void updateManagedUserInvalidEmailTest() { qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); ManagedUser user = new ManagedUser(); user.setUsername("blackbeard"); @@ -484,7 +490,7 @@ public void updateManagedUserInvalidEmailTest() { } @Test - public void updateManagedUserInvalidUsernameTest() { + void updateManagedUserInvalidUsernameTest() { qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); ManagedUser user = new ManagedUser(); user.setUsername(""); @@ -503,7 +509,9 @@ public void updateManagedUserInvalidUsernameTest() { } @Test - public void deleteManagedUserTest() throws InterruptedException { + void deleteManagedUserTest() { + createCatchAllNotificationRule(qm, NotificationScope.SYSTEM); + qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); ManagedUser user = new ManagedUser(); user.setUsername("blackbeard"); @@ -524,7 +532,9 @@ public void deleteManagedUserTest() throws InterruptedException { } @Test - public void createOidcUserTest() throws InterruptedException { + void createOidcUserTest() { + createCatchAllNotificationRule(qm, NotificationScope.SYSTEM); + final OidcUser user = new OidcUser(); user.setUsername("blackbeard"); Response response = jersey.target(V1_USER + "/oidc").request() @@ -546,7 +556,7 @@ public void createOidcUserTest() throws InterruptedException { } @Test - public void createOidcUserDuplicateUsernameTest() { + void createOidcUserDuplicateUsernameTest() { qm.createOidcUser("blackbeard"); final OidcUser user = new OidcUser(); user.setUsername("blackbeard"); @@ -559,7 +569,7 @@ public void createOidcUserDuplicateUsernameTest() { } @Test - public void deleteOidcUserTest() { + void deleteOidcUserTest() { qm.createOidcUser("blackbeard"); OidcUser user = new OidcUser(); user.setUsername("blackbeard"); @@ -572,7 +582,7 @@ public void deleteOidcUserTest() { } @Test - public void addTeamToUserTest() { + void addTeamToUserTest() { qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); Team team = qm.createTeam("Pirates"); IdentifiableObject ido = new IdentifiableObject(); @@ -594,7 +604,7 @@ public void addTeamToUserTest() { } @Test - public void addTeamToUserInvalidTeamTest() { + void addTeamToUserInvalidTeamTest() { qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); IdentifiableObject ido = new IdentifiableObject(); ido.setUuid(UUID.randomUUID().toString()); @@ -610,7 +620,7 @@ public void addTeamToUserInvalidTeamTest() { } @Test - public void addTeamToUserInvalidUserTest() { + void addTeamToUserInvalidUserTest() { Team team = qm.createTeam("Pirates"); IdentifiableObject ido = new IdentifiableObject(); ido.setUuid(team.getUuid().toString()); @@ -626,7 +636,7 @@ public void addTeamToUserInvalidUserTest() { } @Test - public void addTeamToUserDuplicateMembershipTest() { + void addTeamToUserDuplicateMembershipTest() { Team team = qm.createTeam("Pirates"); ManagedUser user = qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); qm.addUserToTeam(user, team); @@ -643,7 +653,7 @@ public void addTeamToUserDuplicateMembershipTest() { } @Test - public void removeTeamFromUserTest() { + void removeTeamFromUserTest() { Team team = qm.createTeam("Pirates"); ManagedUser user = qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); qm.addUserToTeam(user, team); @@ -658,7 +668,7 @@ public void removeTeamFromUserTest() { } @Test - public void setUserTeamsTest() { + void setUserTeamsTest() { String username = qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false).getUsername(); String endpoint = V1_USER + "/membership"; @@ -716,7 +726,7 @@ public void setUserTeamsTest() { } @Test - public void setUserTeamsInvalidTest() { + void setUserTeamsInvalidTest() { String endpoint = V1_USER + "/membership"; qm.createManagedUser("blackbeard", "Captain BlackBeard", "blackbeard@example.com", TEST_USER_PASSWORD_HASH, false, false, false); @@ -748,7 +758,7 @@ public void setUserTeamsInvalidTest() { } @Test - public void assignProjectRoleToUserTest() { + void assignProjectRoleToUserTest() { // Arrange ManagedUser user = qm.createManagedUser("roleuser", TEST_USER_PASSWORD_HASH); Project project = qm.createProject( @@ -777,7 +787,7 @@ public void assignProjectRoleToUserTest() { } @Test - public void assignProjectRoleToUserAlreadyAssignedTest() { + void assignProjectRoleToUserAlreadyAssignedTest() { ManagedUser user = qm.createManagedUser("roleuser2", TEST_USER_PASSWORD_HASH); Project project = qm.createProject( "Test Project 2","null", @@ -800,7 +810,7 @@ public void assignProjectRoleToUserAlreadyAssignedTest() { } @Test - public void removeProjectRoleFromUserTest() { + void removeProjectRoleFromUserTest() { ManagedUser user = qm.createManagedUser("roleuser3", TEST_USER_PASSWORD_HASH); Project project = qm.createProject( "Test Project 3","null", @@ -824,7 +834,7 @@ public void removeProjectRoleFromUserTest() { } @Test - public void removeProjectRoleFromUserNotAssignedTest() { + void removeProjectRoleFromUserNotAssignedTest() { ManagedUser user = qm.createManagedUser("roleuser4", TEST_USER_PASSWORD_HASH); Project project = qm.createProject( "Test Project 4","null", diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/ViolationAnalysisResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/ViolationAnalysisResourceTest.java index 4effc01088..2419047d1e 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/ViolationAnalysisResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/ViolationAnalysisResourceTest.java @@ -27,7 +27,6 @@ import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import net.jcip.annotations.NotThreadSafe; import org.dependencytrack.JerseyTestExtension; import org.dependencytrack.ResourceTest; import org.dependencytrack.auth.Permissions; @@ -42,6 +41,7 @@ import org.dependencytrack.model.ViolationAnalysis; import org.dependencytrack.model.ViolationAnalysisComment; import org.dependencytrack.model.ViolationAnalysisState; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.resources.v1.vo.ViolationAnalysisRequest; import org.glassfish.jersey.server.ResourceConfig; import org.junit.jupiter.api.Test; @@ -53,12 +53,12 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_PROJECT_AUDIT_CHANGE; import static org.dependencytrack.notification.proto.v1.Level.LEVEL_INFORMATIONAL; import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_PORTFOLIO; -@NotThreadSafe -public class ViolationAnalysisResourceTest extends ResourceTest { +class ViolationAnalysisResourceTest extends ResourceTest { @RegisterExtension static JerseyTestExtension jersey = new JerseyTestExtension( @@ -68,7 +68,7 @@ public class ViolationAnalysisResourceTest extends ResourceTest { .register(AuthorizationFeature.class)); @Test - public void retrieveAnalysisTest() { + void retrieveAnalysisTest() { initializeWithPermissions(Permissions.VIEW_POLICY_VIOLATION); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -122,7 +122,7 @@ public void retrieveAnalysisTest() { } @Test - public void retrieveAnalysisUnauthorizedTest() { + void retrieveAnalysisUnauthorizedTest() { final Response response = jersey.target(V1_VIOLATION_ANALYSIS) .queryParam("component", UUID.randomUUID()) .queryParam("policyViolation", UUID.randomUUID()) @@ -134,7 +134,7 @@ public void retrieveAnalysisUnauthorizedTest() { } @Test - public void retrieveAnalysisComponentNotFoundTest() { + void retrieveAnalysisComponentNotFoundTest() { initializeWithPermissions(Permissions.VIEW_POLICY_VIOLATION); final Response response = jersey.target(V1_VIOLATION_ANALYSIS) @@ -149,7 +149,7 @@ public void retrieveAnalysisComponentNotFoundTest() { } @Test - public void retrieveAnalysisViolationNotFoundTest() { + void retrieveAnalysisViolationNotFoundTest() { initializeWithPermissions(Permissions.VIEW_POLICY_VIOLATION); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -172,7 +172,7 @@ public void retrieveAnalysisViolationNotFoundTest() { } @Test - public void retrieveAnalysisAclTest() { + void retrieveAnalysisAclTest() { initializeWithPermissions(Permissions.VIEW_POLICY_VIOLATION); enablePortfolioAccessControl(); @@ -210,7 +210,9 @@ public void retrieveAnalysisAclTest() { } @Test - public void updateAnalysisCreateNewTest() throws Exception { + void updateAnalysisCreateNewTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -265,7 +267,9 @@ public void updateAnalysisCreateNewTest() throws Exception { } @Test - public void updateAnalysisCreateNewWithEmptyRequestTest() { + void updateAnalysisCreateNewWithEmptyRequestTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -306,7 +310,9 @@ public void updateAnalysisCreateNewWithEmptyRequestTest() { } @Test - public void updateAnalysisUpdateExistingTest() { + void updateAnalysisUpdateExistingTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -371,7 +377,7 @@ public void updateAnalysisUpdateExistingTest() { } @Test - public void updateAnalysisUpdateExistingNoChangesTest() { + void updateAnalysisUpdateExistingNoChangesTest() { initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -419,7 +425,9 @@ public void updateAnalysisUpdateExistingNoChangesTest() { } @Test - public void updateAnalysisUpdateExistingWithEmptyRequestTest() throws Exception { + void updateAnalysisUpdateExistingWithEmptyRequestTest() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -476,7 +484,7 @@ public void updateAnalysisUpdateExistingWithEmptyRequestTest() throws Exception } @Test - public void updateAnalysisUnauthorizedTest() { + void updateAnalysisUnauthorizedTest() { final var request = new ViolationAnalysisRequest(UUID.randomUUID().toString(), UUID.randomUUID().toString(), ViolationAnalysisState.REJECTED, "Some comment", false); @@ -489,7 +497,7 @@ public void updateAnalysisUnauthorizedTest() { } @Test - public void updateAnalysisComponentNotFoundTest() { + void updateAnalysisComponentNotFoundTest() { initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); final var request = new ViolationAnalysisRequest(UUID.randomUUID().toString(), @@ -505,7 +513,7 @@ public void updateAnalysisComponentNotFoundTest() { } @Test - public void updateAnalysisViolationNotFoundTest() { + void updateAnalysisViolationNotFoundTest() { initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, null, false); @@ -529,7 +537,7 @@ public void updateAnalysisViolationNotFoundTest() { } @Test - public void updateAnalysisAclTest() { + void updateAnalysisAclTest() { initializeWithPermissions(Permissions.POLICY_VIOLATION_ANALYSIS); enablePortfolioAccessControl(); diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java index ecd2cdde99..85564fc255 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java @@ -300,7 +300,7 @@ void updateExtensionConfigShouldReturnBadRequestWhenInvalid() { "errors": [ { "evaluation_path": "$.properties.requiredString.type", - "schema_location": "#/properties/requiredString/type", + "schema_location": "https://example.com/schema/test#/properties/requiredString/type", "instance_location": "$.requiredString", "keyword": "type", "message": "$.requiredString: null found, string expected" @@ -355,6 +355,7 @@ void getExtensionConfigSchemaShouldReturnConfigSchema() { assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema/test", "type": "object", "properties": { "requiredString": { @@ -512,7 +513,7 @@ void testExtensionShouldReturnBadRequestWhenConfigSchemaValidationFails() { "instance_location": "$.outcome", "keyword": "enum", "message": "$.outcome: does not have a value in the enumeration [\\"PASSED\\", \\"FAILED\\"]", - "schema_location": "#/properties/outcome/enum" + "schema_location": "https://example.com/schema/test#/properties/outcome/enum" } ] } @@ -585,6 +586,7 @@ public RuntimeConfigSpec runtimeConfigSpec() { new RuntimeConfigSchemaSource.Literal(/* language=JSON */ """ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema/test", "type": "object", "properties": { "requiredString": { @@ -690,6 +692,7 @@ public ExtensionTestResult test(@Nullable RuntimeConfig runtimeConfig) { new RuntimeConfigSchemaSource.Literal(/* language=JSON */ """ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema/test", "type": "object", "properties": { "outcome": { @@ -736,6 +739,7 @@ public void init(ExtensionContext ctx) { new RuntimeConfigSchemaSource.Literal(/* language=JSON */ """ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema/test", "type": "object", "properties": { "outcome": { diff --git a/apiserver/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java b/apiserver/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java index f7397bee31..2569c29d4b 100644 --- a/apiserver/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java +++ b/apiserver/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java @@ -53,6 +53,7 @@ import org.dependencytrack.model.Project; import org.dependencytrack.model.VulnerabilityScan; import org.dependencytrack.model.WorkflowStep; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.proto.v1.BomProcessingFailedSubject; import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.persistence.DatabaseSeedingInitTask; @@ -101,6 +102,7 @@ import static org.dependencytrack.model.WorkflowStep.METRICS_UPDATE; import static org.dependencytrack.model.WorkflowStep.POLICY_EVALUATION; import static org.dependencytrack.model.WorkflowStep.VULN_ANALYSIS; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_BOM_CONSUMED; import static org.dependencytrack.notification.proto.v1.Group.GROUP_BOM_PROCESSED; import static org.dependencytrack.notification.proto.v1.Group.GROUP_BOM_PROCESSING_FAILED; @@ -131,6 +133,9 @@ void beforeEach() { "true", ACCEPT_ARTIFACT_CYCLONEDX.getPropertyType(), ACCEPT_ARTIFACT_CYCLONEDX.getDescription()); + + // Required for notifications to be emitted. + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); } @Test diff --git a/apiserver/src/test/java/org/dependencytrack/tasks/maintenance/WorkflowMaintenanceTaskTest.java b/apiserver/src/test/java/org/dependencytrack/tasks/maintenance/WorkflowMaintenanceTaskTest.java index 5b500d35d9..d369cc4374 100644 --- a/apiserver/src/test/java/org/dependencytrack/tasks/maintenance/WorkflowMaintenanceTaskTest.java +++ b/apiserver/src/test/java/org/dependencytrack/tasks/maintenance/WorkflowMaintenanceTaskTest.java @@ -26,6 +26,7 @@ import org.dependencytrack.model.WorkflowState; import org.dependencytrack.model.WorkflowStatus; import org.dependencytrack.model.WorkflowStep; +import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.proto.v1.BomProcessingFailedSubject; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -41,19 +42,20 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import static org.dependencytrack.model.ConfigPropertyConstants.MAINTENANCE_WORKFLOW_RETENTION_HOURS; import static org.dependencytrack.model.ConfigPropertyConstants.MAINTENANCE_WORKFLOW_STEP_TIMEOUT_MINUTES; +import static org.dependencytrack.notification.NotificationTestUtil.createCatchAllNotificationRule; import static org.dependencytrack.notification.proto.v1.Group.GROUP_BOM_PROCESSING_FAILED; import static org.dependencytrack.notification.proto.v1.Level.LEVEL_ERROR; import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_PORTFOLIO; -public class WorkflowMaintenanceTaskTest extends PersistenceCapableTest { +class WorkflowMaintenanceTaskTest extends PersistenceCapableTest { @RegisterExtension - private static final ConfigPropertyExtension configProperties = + static ConfigPropertyExtension configProperties = new ConfigPropertyExtension() .withProperty("tmp.delay.bom.processed.notification", "true"); @Test - public void testWithTransitionToTimedOut() { + void testWithTransitionToTimedOut() { qm.createConfigProperty( MAINTENANCE_WORKFLOW_RETENTION_HOURS.getGroupName(), MAINTENANCE_WORKFLOW_RETENTION_HOURS.getPropertyName(), @@ -100,7 +102,7 @@ public void testWithTransitionToTimedOut() { } @Test - public void testWithTransitionTimedOutToFailed() { + void testWithTransitionTimedOutToFailed() { qm.createConfigProperty( MAINTENANCE_WORKFLOW_RETENTION_HOURS.getGroupName(), MAINTENANCE_WORKFLOW_RETENTION_HOURS.getPropertyName(), @@ -166,7 +168,9 @@ public void testWithTransitionTimedOutToFailed() { } @Test - public void testWithTransitionTimedOutToFailedForVulnAnalysis() { + void testWithTransitionTimedOutToFailedForVulnAnalysis() { + createCatchAllNotificationRule(qm, NotificationScope.PORTFOLIO); + qm.createConfigProperty( MAINTENANCE_WORKFLOW_RETENTION_HOURS.getGroupName(), MAINTENANCE_WORKFLOW_RETENTION_HOURS.getPropertyName(), @@ -231,7 +235,7 @@ public void testWithTransitionTimedOutToFailedForVulnAnalysis() { } @Test - public void testWithDeleteExpired() { + void testWithDeleteExpired() { qm.createConfigProperty( MAINTENANCE_WORKFLOW_RETENTION_HOURS.getGroupName(), MAINTENANCE_WORKFLOW_RETENTION_HOURS.getPropertyName(), diff --git a/dev/compose.yaml b/dev/compose.yaml new file mode 100644 index 0000000000..414fc30192 --- /dev/null +++ b/dev/compose.yaml @@ -0,0 +1,115 @@ +# 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) OWASP Foundation. All Rights Reserved. +name: dtrack-dev + +services: + traefik: + image: traefik:v3 + command: + - "--providers.docker=true" + - "--entrypoints.web.address=:8080" + ports: + - "127.0.0.1:8080:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + restart: unless-stopped + + frontend: + image: "ghcr.io/dependencytrack/hyades-frontend:${DT_VERSION:-snapshot}" + environment: + API_BASE_URL: "http://localhost:8080" + ports: + - "127.0.0.1:8081:8080" + restart: unless-stopped + + apiserver: + image: "ghcr.io/dependencytrack/hyades-apiserver:${DT_VERSION:-snapshot}" + labels: + traefik.enable: "true" + traefik.http.routers.myapp.rule: "PathPrefix(`/api`)" + depends_on: + kafka: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + endpoint_mode: dnsrr + mode: replicated + replicas: 3 + environment: + EXTRA_JAVA_OPTIONS: "-Xmx1g" + DT_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/dtrack" + DT_DATASOURCE_USERNAME: "dtrack" + DT_DATASOURCE_PASSWORD: "dtrack" + KAFKA_BOOTSTRAP_SERVERS: "kafka:9092" + volumes: + - "apiserver-data:/data" + restart: unless-stopped + + kafka: + image: apache/kafka:4.1.1 + environment: + KAFKA_NODE_ID: "1" + KAFKA_PROCESS_ROLES: "broker,controller" + KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093" + KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092" + KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER" + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT" + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@localhost:9093" + healthcheck: + test: [ "CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1" ] + interval: 10s + timeout: 10s + retries: 5 + volumes: + - "kafka-data:/var/lib/kafka/data" + restart: unless-stopped + + kafka-init: + image: apache/kafka:4.1.1 + depends_on: + kafka: + condition: service_healthy + entrypoint: >- + sh -c " + /opt/kafka/bin/kafka-topics.sh --create --if-not-exists --topic dtrack.repo-meta-analysis.result --partitions 3 --replication-factor 1 --bootstrap-server kafka:9092 && + /opt/kafka/bin/kafka-topics.sh --create --if-not-exists --topic dtrack.vuln-analysis.result --partitions 3 --replication-factor 1 --bootstrap-server kafka:9092 && + /opt/kafka/bin/kafka-topics.sh --create --if-not-exists --topic dtrack.vuln-analysis.result.processed --partitions 3 --replication-factor 1 --bootstrap-server kafka:9092 + " + restart: on-failure + + postgres: + image: postgres:18-alpine + environment: + POSTGRES_DB: "dtrack" + POSTGRES_USER: "dtrack" + POSTGRES_PASSWORD: "dtrack" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] + interval: 5s + timeout: 3s + retries: 3 + ports: + - "127.0.0.1:5432:5432" + volumes: + - "postgres-data:/var/lib/postgresql/data" + restart: unless-stopped + +volumes: + apiserver-data: {} + kafka-data: {} + postgres-data: {} \ No newline at end of file diff --git a/dev/kafka-mtls/.gitignore b/dev/kafka-mtls/.gitignore new file mode 100644 index 0000000000..a2661ad0d1 --- /dev/null +++ b/dev/kafka-mtls/.gitignore @@ -0,0 +1 @@ +certs/ \ No newline at end of file diff --git a/dev/kafka-mtls/Makefile b/dev/kafka-mtls/Makefile new file mode 100644 index 0000000000..c6df394102 --- /dev/null +++ b/dev/kafka-mtls/Makefile @@ -0,0 +1,33 @@ +# 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) OWASP Foundation. All Rights Reserved. + +certs: + mkdir -p ./certs + openssl genrsa -out ./certs/ca-key.pem 4096 + openssl req -new -x509 -key ./certs/ca-key.pem -sha256 -subj '/CN=Kafka-CA/O=AcmeInc/C=DE' -days 3650 -out ./certs/ca-cert.pem + openssl genrsa -out ./certs/broker-key.pem 4096 + openssl req -new -key ./certs/broker-key.pem -subj '/CN=Kafka-Broker/O=AcmeInc/C=DE' -out ./certs/broker.csr + echo 'subjectAltName=DNS:localhost,DNS:kafka,IP:127.0.0.1' > ./certs/broker-cert-ext.cnf + openssl x509 -req -in ./certs/broker.csr -CA certs/ca-cert.pem -CAkey ./certs/ca-key.pem -CAcreateserial -extfile ./certs/broker-cert-ext.cnf -days 365 -out ./certs/broker-cert.pem + openssl genrsa -out ./certs/client-key.pem 4096 + openssl req -new -key ./certs/client-key.pem -subj '/CN=Kafka-Client/O=AcmeInc/C=DE' -out ./certs/client.csr + openssl x509 -req -in ./certs/client.csr -CA certs/ca-cert.pem -CAkey ./certs/ca-key.pem -CAcreateserial -days 365 -out ./certs/client-cert.pem + openssl pkcs12 -export -in ./certs/broker-cert.pem -inkey ./certs/broker-key.pem -name broker -out ./certs/broker-keystore.p12 -password pass:changeit + keytool -import -trustcacerts -noprompt -alias ca -file ./certs/ca-cert.pem -keystore ./certs/truststore.p12 -storetype PKCS12 -storepass changeit + echo 'changeit' > ./certs/broker-keystore-credentials + echo 'changeit' > ./certs/truststore-credentials +.PHONY: certs \ No newline at end of file diff --git a/dev/kafka-mtls/README.md b/dev/kafka-mtls/README.md new file mode 100644 index 0000000000..d9ce68b912 --- /dev/null +++ b/dev/kafka-mtls/README.md @@ -0,0 +1,15 @@ +# Kafka mTLS + +Minimal setup to test Kafka mTLS connections with. + +## Usage + +```shell +make +docker compose up -d +``` + +The broker's SSL listener is exposed to `localhost:9093`. + +Use `certs/ca-cert.pem`, `certs/client-cert.pem`, and `certs/client-key.pem` +in your client configuration. \ No newline at end of file diff --git a/dev/kafka-mtls/compose.yml b/dev/kafka-mtls/compose.yml new file mode 100644 index 0000000000..0c179036dc --- /dev/null +++ b/dev/kafka-mtls/compose.yml @@ -0,0 +1,50 @@ +# 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) OWASP Foundation. All Rights Reserved. +services: + kafka: + image: apache/kafka:4.1.1 + environment: + KAFKA_NODE_ID: "1" + CLUSTER_ID: "MkU3OEVBNTcwNTJENDM2Qk" + KAFKA_PROCESS_ROLES: "broker,controller" + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9094" + KAFKA_LISTENERS: SSL://:9093,CONTROLLER://:9094 + KAFKA_ADVERTISED_LISTENERS: SSL://localhost:9093 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: SSL:SSL,CONTROLLER:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: SSL + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_SSL_KEYSTORE_TYPE: "PKCS12" + KAFKA_SSL_KEYSTORE_FILENAME: "broker-keystore.p12" + KAFKA_SSL_KEYSTORE_CREDENTIALS: "broker-keystore-credentials" + KAFKA_SSL_KEY_CREDENTIALS: "broker-keystore-credentials" + KAFKA_SSL_TRUSTSTORE_TYPE: "PKCS12" + KAFKA_SSL_TRUSTSTORE_FILENAME: "truststore.p12" + KAFKA_SSL_TRUSTSTORE_CREDENTIALS: "truststore-credentials" + KAFKA_SSL_CLIENT_AUTH: "required" + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1" + KAFKA_LOG4J_ROOT_LOGLEVEL: WARN + ports: + - "127.0.0.1:9093:9093" + volumes: + - "./certs:/etc/kafka/secrets:ro" + - "kafka-data:/var/lib/kafka/data" + restart: unless-stopped + +volumes: + kafka-data: {} \ No newline at end of file diff --git a/dex/api/src/main/java/org/dependencytrack/dex/api/ActivityHandle.java b/dex/api/src/main/java/org/dependencytrack/dex/api/ActivityHandle.java index 53fdf06fd7..45e84b32ac 100644 --- a/dex/api/src/main/java/org/dependencytrack/dex/api/ActivityHandle.java +++ b/dex/api/src/main/java/org/dependencytrack/dex/api/ActivityHandle.java @@ -33,6 +33,13 @@ public interface ActivityHandle call(ActivityCallOptions<@Nullable A> options); + /** + * @see #call(ActivityCallOptions) + */ + default Awaitable<@Nullable R> call(@Nullable A argument) { + return call(new ActivityCallOptions().withArgument(argument)); + } + /** * @see #call(ActivityCallOptions) */ diff --git a/dex/api/src/main/java/org/dependencytrack/dex/api/failure/TerminalApplicationFailureException.java b/dex/api/src/main/java/org/dependencytrack/dex/api/failure/TerminalApplicationFailureException.java index 48e2df7a2e..ce99733952 100644 --- a/dex/api/src/main/java/org/dependencytrack/dex/api/failure/TerminalApplicationFailureException.java +++ b/dex/api/src/main/java/org/dependencytrack/dex/api/failure/TerminalApplicationFailureException.java @@ -31,4 +31,12 @@ public TerminalApplicationFailureException( super(message, cause, /* isTerminal */ true); } + public TerminalApplicationFailureException(@Nullable String message) { + this(message, null); + } + + public TerminalApplicationFailureException(@Nullable Throwable cause) { + this(null, cause); + } + } diff --git a/dex/benchmark/src/main/java/org/dependencytrack/dex/benchmark/Application.java b/dex/benchmark/src/main/java/org/dependencytrack/dex/benchmark/Application.java index e587a2b679..4164f8f937 100644 --- a/dex/benchmark/src/main/java/org/dependencytrack/dex/benchmark/Application.java +++ b/dex/benchmark/src/main/java/org/dependencytrack/dex/benchmark/Application.java @@ -30,12 +30,11 @@ import io.micrometer.prometheusmetrics.PrometheusConfig; import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; import io.prometheus.metrics.exporter.httpserver.HTTPServer; -import org.dependencytrack.dex.engine.api.ActivityTaskWorkerOptions; import org.dependencytrack.dex.engine.api.DexEngine; import org.dependencytrack.dex.engine.api.DexEngineConfig; import org.dependencytrack.dex.engine.api.DexEngineFactory; -import org.dependencytrack.dex.engine.api.TaskQueueType; -import org.dependencytrack.dex.engine.api.WorkflowTaskWorkerOptions; +import org.dependencytrack.dex.engine.api.TaskType; +import org.dependencytrack.dex.engine.api.TaskWorkerOptions; import org.dependencytrack.dex.engine.api.request.CreateTaskQueueRequest; import org.dependencytrack.dex.engine.api.request.CreateWorkflowRunRequest; import org.dependencytrack.dex.engine.migration.MigrationExecutor; @@ -86,10 +85,10 @@ private static void executeInitCommand() throws Exception { try (final DexEngine dexEngine = createDexEngine(dataSource, null)) { LOGGER.info("Creating task queues"); - dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "default", 1000)); - dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo", 1000)); - dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "bar", 1000)); - dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "baz", 1000)); + dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "default", 1000)); + dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "foo", 1000)); + dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "bar", 1000)); + dexEngine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "baz", 1000)); } } @@ -120,10 +119,10 @@ private static void executeStartEngineCommand() throws Exception { final DexEngine dexEngine = createDexEngine(dataSource, meterRegistry); - dexEngine.registerWorkflowWorker(new WorkflowTaskWorkerOptions("default", "default", 150)); - dexEngine.registerActivityWorker(new ActivityTaskWorkerOptions("foo-worker", "foo", 50)); - dexEngine.registerActivityWorker(new ActivityTaskWorkerOptions("bar-worker", "bar", 50)); - dexEngine.registerActivityWorker(new ActivityTaskWorkerOptions("baz-worker", "baz", 50)); + dexEngine.registerTaskWorker(new TaskWorkerOptions(TaskType.WORKFLOW, "default", "default", 150)); + dexEngine.registerTaskWorker(new TaskWorkerOptions(TaskType.ACTIVITY, "foo-worker", "foo", 50)); + dexEngine.registerTaskWorker(new TaskWorkerOptions(TaskType.ACTIVITY, "bar-worker", "bar", 50)); + dexEngine.registerTaskWorker(new TaskWorkerOptions(TaskType.ACTIVITY, "baz-worker", "baz", 50)); Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/DexEngine.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/DexEngine.java index abda6dbbeb..149be17af7 100644 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/DexEngine.java +++ b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/DexEngine.java @@ -90,20 +90,12 @@ void registerActivity( Duration lockTimeout); /** - * Register a worker for activity tasks. + * Register a task worker. * * @param options Options of the worker. * @throws IllegalStateException When the engine was already started. */ - void registerActivityWorker(ActivityTaskWorkerOptions options); - - /** - * Register a worker for workflow tasks. - * - * @param options Options of the worker. - * @throws IllegalStateException When the engine was already started. - */ - void registerWorkflowWorker(WorkflowTaskWorkerOptions options); + void registerTaskWorker(TaskWorkerOptions options); /** * Add a listener for {@link DexEngineEvent}s. diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskQueue.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskQueue.java index a4b1af77cd..94cb605acd 100644 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskQueue.java +++ b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskQueue.java @@ -23,7 +23,7 @@ import java.time.Instant; public record TaskQueue( - TaskQueueType type, + TaskType type, String name, TaskQueueStatus status, int capacity, diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskQueueType.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskType.java similarity index 96% rename from dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskQueueType.java rename to dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskType.java index 78e83afcc7..e2e95ff612 100644 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskQueueType.java +++ b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskType.java @@ -18,7 +18,7 @@ */ package org.dependencytrack.dex.engine.api; -public enum TaskQueueType { +public enum TaskType { ACTIVITY, diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/ActivityTaskWorkerOptions.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskWorkerOptions.java similarity index 73% rename from dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/ActivityTaskWorkerOptions.java rename to dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskWorkerOptions.java index afc45268e0..f541ace84f 100644 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/ActivityTaskWorkerOptions.java +++ b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/TaskWorkerOptions.java @@ -24,14 +24,16 @@ import static java.util.Objects.requireNonNull; -public record ActivityTaskWorkerOptions( +public record TaskWorkerOptions( + TaskType type, String name, String queueName, int maxConcurrency, Duration minPollInterval, IntervalFunction pollBackoffFunction) { - public ActivityTaskWorkerOptions { + public TaskWorkerOptions { + requireNonNull(type, "type must not be null"); requireNonNull(name, "name must not be null"); requireNonNull(queueName, "queueName must not be null"); if (maxConcurrency <= 0) { @@ -41,12 +43,13 @@ public record ActivityTaskWorkerOptions( requireNonNull(pollBackoffFunction, "pollBackoffFunction must not be null"); } - public ActivityTaskWorkerOptions(String name, String queueName, int maxConcurrency) { - this(name, queueName, maxConcurrency, Duration.ofMillis(100), IntervalFunction.of(Duration.ofMillis(500))); + public TaskWorkerOptions(TaskType taskType, String name, String queueName, int maxConcurrency) { + this(taskType, name, queueName, maxConcurrency, Duration.ofMillis(100), IntervalFunction.of(Duration.ofMillis(500))); } - public ActivityTaskWorkerOptions withMinPollInterval(Duration minPollInterval) { - return new ActivityTaskWorkerOptions( + public TaskWorkerOptions withMinPollInterval(Duration minPollInterval) { + return new TaskWorkerOptions( + this.type, this.name, this.queueName, this.maxConcurrency, @@ -54,8 +57,9 @@ public ActivityTaskWorkerOptions withMinPollInterval(Duration minPollInterval) { this.pollBackoffFunction); } - public ActivityTaskWorkerOptions withPollBackoffFunction(IntervalFunction pollBackoffFunction) { - return new ActivityTaskWorkerOptions( + public TaskWorkerOptions withPollBackoffFunction(IntervalFunction pollBackoffFunction) { + return new TaskWorkerOptions( + this.type, this.name, this.queueName, this.maxConcurrency, diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/WorkflowTaskWorkerOptions.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/WorkflowTaskWorkerOptions.java deleted file mode 100644 index dc14d2c284..0000000000 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/WorkflowTaskWorkerOptions.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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) OWASP Foundation. All Rights Reserved. - */ -package org.dependencytrack.dex.engine.api; - -import io.github.resilience4j.core.IntervalFunction; - -import java.time.Duration; - -import static java.util.Objects.requireNonNull; - -public record WorkflowTaskWorkerOptions( - String name, - String queueName, - int maxConcurrency, - Duration minPollInterval, - IntervalFunction pollBackoffFunction) { - - public WorkflowTaskWorkerOptions { - requireNonNull(name, "name must not be null"); - requireNonNull(queueName, "queueName must not be null"); - if (maxConcurrency <= 0) { - throw new IllegalArgumentException("maxConcurrency must not be negative or zero"); - } - requireNonNull(minPollInterval, "minPollInterval must not be null"); - requireNonNull(pollBackoffFunction, "pollBackoffFunction must not be null"); - } - - public WorkflowTaskWorkerOptions(String name, String queueName, int maxConcurrency) { - this(name, queueName, maxConcurrency, Duration.ofMillis(100), IntervalFunction.of(Duration.ofMillis(500))); - } - - public WorkflowTaskWorkerOptions withMinPollInterval(Duration minPollInterval) { - return new WorkflowTaskWorkerOptions( - this.name, - this.queueName, - this.maxConcurrency, - minPollInterval, - this.pollBackoffFunction); - } - - public WorkflowTaskWorkerOptions withPollBackoffFunction(IntervalFunction pollBackoffFunction) { - return new WorkflowTaskWorkerOptions( - this.name, - this.queueName, - this.maxConcurrency, - this.minPollInterval, - pollBackoffFunction); - } - -} diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/CreateTaskQueueRequest.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/CreateTaskQueueRequest.java index c3b67f4454..ee7aac8dd7 100644 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/CreateTaskQueueRequest.java +++ b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/CreateTaskQueueRequest.java @@ -18,11 +18,11 @@ */ package org.dependencytrack.dex.engine.api.request; -import org.dependencytrack.dex.engine.api.TaskQueueType; +import org.dependencytrack.dex.engine.api.TaskType; import static java.util.Objects.requireNonNull; -public record CreateTaskQueueRequest(TaskQueueType type, String name, int capacity) { +public record CreateTaskQueueRequest(TaskType type, String name, int capacity) { public CreateTaskQueueRequest { requireNonNull(type, "type must not be null"); diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/ListTaskQueuesRequest.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/ListTaskQueuesRequest.java index ab58985743..9672cae061 100644 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/ListTaskQueuesRequest.java +++ b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/ListTaskQueuesRequest.java @@ -18,12 +18,12 @@ */ package org.dependencytrack.dex.engine.api.request; -import org.dependencytrack.dex.engine.api.TaskQueueType; +import org.dependencytrack.dex.engine.api.TaskType; import org.jspecify.annotations.Nullable; import static java.util.Objects.requireNonNull; -public record ListTaskQueuesRequest(TaskQueueType type, @Nullable String pageToken, int limit) { +public record ListTaskQueuesRequest(TaskType type, @Nullable String pageToken, int limit) { public ListTaskQueuesRequest { requireNonNull(type, "type must not be null"); @@ -32,7 +32,7 @@ public record ListTaskQueuesRequest(TaskQueueType type, @Nullable String pageTok } } - public ListTaskQueuesRequest(final TaskQueueType type) { + public ListTaskQueuesRequest(final TaskType type) { this(type, null, 10); } diff --git a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/UpdateTaskQueueRequest.java b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/UpdateTaskQueueRequest.java index 6c7463ea09..e312b4f139 100644 --- a/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/UpdateTaskQueueRequest.java +++ b/dex/engine-api/src/main/java/org/dependencytrack/dex/engine/api/request/UpdateTaskQueueRequest.java @@ -19,13 +19,13 @@ package org.dependencytrack.dex.engine.api.request; import org.dependencytrack.dex.engine.api.TaskQueueStatus; -import org.dependencytrack.dex.engine.api.TaskQueueType; +import org.dependencytrack.dex.engine.api.TaskType; import org.jspecify.annotations.Nullable; import static java.util.Objects.requireNonNull; public record UpdateTaskQueueRequest( - TaskQueueType type, + TaskType type, String name, @Nullable TaskQueueStatus status, @Nullable Integer capacity) { diff --git a/dex/engine/src/main/java/org/dependencytrack/dex/engine/DexEngineImpl.java b/dex/engine/src/main/java/org/dependencytrack/dex/engine/DexEngineImpl.java index a4309d913c..57eae92ff9 100644 --- a/dex/engine/src/main/java/org/dependencytrack/dex/engine/DexEngineImpl.java +++ b/dex/engine/src/main/java/org/dependencytrack/dex/engine/DexEngineImpl.java @@ -40,15 +40,15 @@ import org.dependencytrack.dex.engine.TaskEvent.ActivityTaskFailedEvent; import org.dependencytrack.dex.engine.TaskEvent.WorkflowTaskAbandonedEvent; import org.dependencytrack.dex.engine.TaskEvent.WorkflowTaskCompletedEvent; -import org.dependencytrack.dex.engine.api.ActivityTaskWorkerOptions; import org.dependencytrack.dex.engine.api.DexEngine; import org.dependencytrack.dex.engine.api.DexEngineConfig; import org.dependencytrack.dex.engine.api.ExternalEvent; import org.dependencytrack.dex.engine.api.TaskQueue; +import org.dependencytrack.dex.engine.api.TaskType; +import org.dependencytrack.dex.engine.api.TaskWorkerOptions; import org.dependencytrack.dex.engine.api.WorkflowRun; import org.dependencytrack.dex.engine.api.WorkflowRunMetadata; import org.dependencytrack.dex.engine.api.WorkflowRunStatus; -import org.dependencytrack.dex.engine.api.WorkflowTaskWorkerOptions; import org.dependencytrack.dex.engine.api.event.DexEngineEvent; import org.dependencytrack.dex.engine.api.event.DexEngineEventListener; import org.dependencytrack.dex.engine.api.event.WorkflowRunsCompletedEvent; @@ -442,9 +442,19 @@ void registerActivityInternal( } @Override - public void registerActivityWorker(ActivityTaskWorkerOptions options) { + public void registerTaskWorker(TaskWorkerOptions options) { requireStatusAnyOf(Status.CREATED, Status.STOPPED); + if (options.type() == TaskType.ACTIVITY) { + registerActivityTaskWorker(options); + } else if (options.type() == TaskType.WORKFLOW) { + registerWorkflowTaskWorker(options); + } else { + throw new IllegalArgumentException("Unknown task type: " + options.type()); + } + } + + private void registerActivityTaskWorker(TaskWorkerOptions options) { final boolean queueExists = jdbi.withHandle( handle -> new ActivityDao(handle).doesActivityTaskQueueExists(options.queueName())); if (!queueExists) { @@ -463,14 +473,11 @@ public void registerActivityWorker(ActivityTaskWorkerOptions options) { if (taskWorkerByName.putIfAbsent("activity/" + options.name(), worker) != null) { throw new IllegalStateException( - "An activity task worker with name %s was already registered".formatted(options.name())); + "An task worker with name %s was already registered".formatted(options.name())); } } - @Override - public void registerWorkflowWorker(WorkflowTaskWorkerOptions options) { - requireStatusAnyOf(Status.CREATED, Status.STOPPED); - + private void registerWorkflowTaskWorker(TaskWorkerOptions options) { final boolean queueExists = jdbi.withHandle( handle -> new WorkflowDao(handle).doesWorkflowTaskQueueExists(options.queueName())); if (!queueExists) { @@ -489,7 +496,7 @@ public void registerWorkflowWorker(WorkflowTaskWorkerOptions options) { if (taskWorkerByName.putIfAbsent("workflow/" + options.name(), worker) != null) { throw new IllegalStateException( - "A workflow task worker with name %s was already registered".formatted(options.name())); + "A task worker with name %s was already registered".formatted(options.name())); } } diff --git a/dex/engine/src/main/java/org/dependencytrack/dex/engine/persistence/jdbi/TaskQueueRowMapper.java b/dex/engine/src/main/java/org/dependencytrack/dex/engine/persistence/jdbi/TaskQueueRowMapper.java index 62b5f3fd9a..4e8ed77b2d 100644 --- a/dex/engine/src/main/java/org/dependencytrack/dex/engine/persistence/jdbi/TaskQueueRowMapper.java +++ b/dex/engine/src/main/java/org/dependencytrack/dex/engine/persistence/jdbi/TaskQueueRowMapper.java @@ -20,7 +20,7 @@ import org.dependencytrack.dex.engine.api.TaskQueue; import org.dependencytrack.dex.engine.api.TaskQueueStatus; -import org.dependencytrack.dex.engine.api.TaskQueueType; +import org.dependencytrack.dex.engine.api.TaskType; import org.jdbi.v3.core.config.ConfigRegistry; import org.jdbi.v3.core.mapper.ColumnMapper; import org.jdbi.v3.core.mapper.ColumnMappers; @@ -48,7 +48,7 @@ public TaskQueue map(final ResultSet rs, final StatementContext ctx) throws SQLE requireNonNull(instantColumnMapper); return new TaskQueue( - TaskQueueType.valueOf(rs.getString("type")), + TaskType.valueOf(rs.getString("type")), rs.getString("name"), TaskQueueStatus.valueOf(rs.getString("status")), rs.getInt("capacity"), diff --git a/dex/engine/src/test/java/org/dependencytrack/dex/engine/DexEngineImplTest.java b/dex/engine/src/test/java/org/dependencytrack/dex/engine/DexEngineImplTest.java index 1374d4ae12..230fb0434c 100644 --- a/dex/engine/src/test/java/org/dependencytrack/dex/engine/DexEngineImplTest.java +++ b/dex/engine/src/test/java/org/dependencytrack/dex/engine/DexEngineImplTest.java @@ -34,15 +34,14 @@ import org.dependencytrack.dex.api.failure.FailureException; import org.dependencytrack.dex.api.failure.TerminalApplicationFailureException; import org.dependencytrack.dex.api.payload.PayloadConverter; -import org.dependencytrack.dex.engine.api.ActivityTaskWorkerOptions; import org.dependencytrack.dex.engine.api.DexEngineConfig; import org.dependencytrack.dex.engine.api.ExternalEvent; import org.dependencytrack.dex.engine.api.TaskQueue; import org.dependencytrack.dex.engine.api.TaskQueueStatus; -import org.dependencytrack.dex.engine.api.TaskQueueType; +import org.dependencytrack.dex.engine.api.TaskType; +import org.dependencytrack.dex.engine.api.TaskWorkerOptions; import org.dependencytrack.dex.engine.api.WorkflowRunMetadata; import org.dependencytrack.dex.engine.api.WorkflowRunStatus; -import org.dependencytrack.dex.engine.api.WorkflowTaskWorkerOptions; import org.dependencytrack.dex.engine.api.event.WorkflowRunsCompletedEventListener; import org.dependencytrack.dex.engine.api.request.CreateTaskQueueRequest; import org.dependencytrack.dex.engine.api.request.CreateWorkflowRunRequest; @@ -117,8 +116,8 @@ void beforeEach() { config.taskEventBuffer().setFlushInterval(Duration.ofMillis(10)); engine = new DexEngineImpl(config); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, WORKFLOW_TASK_QUEUE, 10)); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, ACTIVITY_TASK_QUEUE, 10)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, WORKFLOW_TASK_QUEUE, 10)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, ACTIVITY_TASK_QUEUE, 10)); } @AfterEach @@ -255,7 +254,7 @@ void shouldFailWorkflowRunOnNonDeterministicExecution() { registerActivity("abc", (ctx, arg) -> null); registerActivity("def", (ctx, arg) -> null); registerWorkflowWorker("workflow-worker", 1); - registerActivityWorker("activity-worker", 1); + registerTaskWorker("activity-worker", 1); engine.start(); @@ -848,7 +847,7 @@ void shouldCallActivity() { }); registerActivity("abc", voidConverter(), stringConverter(), (ctx, arg) -> "123"); registerWorkflowWorker("workflow-worker", 1); - registerActivityWorker("activity-worker", 1); + registerTaskWorker("activity-worker", 1); engine.start(); final UUID runId = engine.createRun(new CreateWorkflowRunRequest<>("test", 1)); @@ -880,7 +879,7 @@ void shouldCreateMultipleActivitiesConcurrently() { }); registerActivity("abc", stringConverter(), stringConverter(), (ctx, arg) -> arg); registerWorkflowWorker("workflow-worker", 1); - registerActivityWorker("activity-worker", 2); + registerTaskWorker("activity-worker", 2); engine.start(); final UUID runId = engine.createRun(new CreateWorkflowRunRequest<>("test", 1)); @@ -915,7 +914,7 @@ void shouldRetryFailingActivity() { throw new IllegalStateException(); }); registerWorkflowWorker("workflow-worker", 1); - registerActivityWorker("activity-worker", 1); + registerTaskWorker("activity-worker", 1); engine.start(); final UUID runId = engine.createRun(new CreateWorkflowRunRequest<>("test", 1)); @@ -947,7 +946,7 @@ void shouldNotRetryActivityFailingWithTerminalException() { throw new TerminalApplicationFailureException("Ouch!", null); }); registerWorkflowWorker("workflow-worker", 1); - registerActivityWorker("activity-worker", 1); + registerTaskWorker("activity-worker", 1); engine.start(); final UUID runId = engine.createRun(new CreateWorkflowRunRequest<>("test", 1)); @@ -987,7 +986,7 @@ void shouldHeartbeatActivity() { return null; }); registerWorkflowWorker("workflow-worker", 1); - registerActivityWorker("activity-worker", 1); + registerTaskWorker("activity-worker", 1); engine.start(); final UUID runId = engine.createRun(new CreateWorkflowRunRequest<>("test", 1)); @@ -1018,7 +1017,7 @@ void shouldCancelActivitiesDuringGracefulShutdown() throws Exception { } }); registerWorkflowWorker("workflow-worker", 1); - registerActivityWorker("activity-worker", 1); + registerTaskWorker("activity-worker", 1); engine.start(); engine.createRun(new CreateWorkflowRunRequest<>("test", 1)); @@ -1058,7 +1057,7 @@ void shouldPropagateExceptions() { throw new TerminalApplicationFailureException("Ouch!", null); }); registerWorkflowWorker("workflow-worker", 3); - registerActivityWorker("activity-worker", 1); + registerTaskWorker("activity-worker", 1); engine.start(); final UUID runId = engine.createRun(new CreateWorkflowRunRequest<>("foo", 1) @@ -1342,29 +1341,29 @@ class WorkflowTaskQueueTest { @Test void createShouldReturnTrueWhenCreatedAndFalseWhenNot() { boolean created = engine.createTaskQueue( - new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo", 1)); + new CreateTaskQueueRequest(TaskType.WORKFLOW, "foo", 1)); assertThat(created).isTrue(); created = engine.createTaskQueue( - new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo", 2)); + new CreateTaskQueueRequest(TaskType.WORKFLOW, "foo", 2)); assertThat(created).isFalse(); } @Test void updateShouldReturnTrueWhenUpdated() { - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "foo", 1)); final boolean updated = engine.updateTaskQueue( - new UpdateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo", TaskQueueStatus.PAUSED, null)); + new UpdateTaskQueueRequest(TaskType.WORKFLOW, "foo", TaskQueueStatus.PAUSED, null)); assertThat(updated).isTrue(); } @Test void updateShouldReturnFalseWhenUnchanged() { - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "foo", 1)); final boolean updated = engine.updateTaskQueue( - new UpdateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo", null, null)); + new UpdateTaskQueueRequest(TaskType.WORKFLOW, "foo", null, null)); assertThat(updated).isFalse(); } @@ -1372,16 +1371,16 @@ void updateShouldReturnFalseWhenUnchanged() { void updateShouldThrowWhenQueueDoesNotExist() { assertThatExceptionOfType(NoSuchElementException.class) .isThrownBy(() -> engine.updateTaskQueue( - new UpdateTaskQueueRequest(TaskQueueType.WORKFLOW, "does-not-exist", null, null))); + new UpdateTaskQueueRequest(TaskType.WORKFLOW, "does-not-exist", null, null))); } @Test void listShouldSupportPagination() { - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo-1", 1)); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "foo-2", 2)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "foo-1", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "foo-2", 2)); Page<@NonNull TaskQueue> queuesPage = engine.listTaskQueues( - new ListTaskQueuesRequest(TaskQueueType.WORKFLOW).withLimit(2)); + new ListTaskQueuesRequest(TaskType.WORKFLOW).withLimit(2)); assertThat(queuesPage.items()).satisfiesExactly( queue -> { assertThat(queue.name()).isEqualTo("default"); @@ -1402,7 +1401,7 @@ void listShouldSupportPagination() { assertThat(queuesPage.nextPageToken()).isNotNull(); queuesPage = engine.listTaskQueues( - new ListTaskQueuesRequest(TaskQueueType.WORKFLOW).withPageToken(queuesPage.nextPageToken())); + new ListTaskQueuesRequest(TaskType.WORKFLOW).withPageToken(queuesPage.nextPageToken())); assertThat(queuesPage.items()).satisfiesExactly(queue -> { assertThat(queue.name()).isEqualTo("foo-2"); assertThat(queue.status()).isEqualTo(TaskQueueStatus.ACTIVE); @@ -1422,29 +1421,29 @@ class TaskQueueTest { @Test void createShouldReturnTrueWhenCreatedAndFalseWhenNot() { boolean created = engine.createTaskQueue( - new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo", 1)); + new CreateTaskQueueRequest(TaskType.ACTIVITY, "foo", 1)); assertThat(created).isTrue(); created = engine.createTaskQueue( - new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo", 2)); + new CreateTaskQueueRequest(TaskType.ACTIVITY, "foo", 2)); assertThat(created).isFalse(); } @Test void updateShouldReturnTrueWhenUpdated() { - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "foo", 1)); final boolean updated = engine.updateTaskQueue( - new UpdateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo", TaskQueueStatus.PAUSED, null)); + new UpdateTaskQueueRequest(TaskType.ACTIVITY, "foo", TaskQueueStatus.PAUSED, null)); assertThat(updated).isTrue(); } @Test void updateShouldReturnFalseWhenUnchanged() { - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "foo", 1)); final boolean updated = engine.updateTaskQueue( - new UpdateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo", null, null)); + new UpdateTaskQueueRequest(TaskType.ACTIVITY, "foo", null, null)); assertThat(updated).isFalse(); } @@ -1452,16 +1451,16 @@ void updateShouldReturnFalseWhenUnchanged() { void updateShouldThrowWhenQueueDoesNotExist() { assertThatExceptionOfType(NoSuchElementException.class) .isThrownBy(() -> engine.updateTaskQueue( - new UpdateTaskQueueRequest(TaskQueueType.ACTIVITY, "does-not-exist", null, null))); + new UpdateTaskQueueRequest(TaskType.ACTIVITY, "does-not-exist", null, null))); } @Test void listShouldSupportPagination() { - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo-1", 1)); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "foo-2", 2)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "foo-1", 1)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "foo-2", 2)); Page<@NonNull TaskQueue> queuesPage = engine.listTaskQueues( - new ListTaskQueuesRequest(TaskQueueType.ACTIVITY).withLimit(2)); + new ListTaskQueuesRequest(TaskType.ACTIVITY).withLimit(2)); assertThat(queuesPage.items()).satisfiesExactly( queue -> { assertThat(queue.name()).isEqualTo("default"); @@ -1482,7 +1481,7 @@ void listShouldSupportPagination() { assertThat(queuesPage.nextPageToken()).isNotNull(); queuesPage = engine.listTaskQueues( - new ListTaskQueuesRequest(TaskQueueType.ACTIVITY).withPageToken(queuesPage.nextPageToken())); + new ListTaskQueuesRequest(TaskType.ACTIVITY).withPageToken(queuesPage.nextPageToken())); assertThat(queuesPage.items()).satisfiesExactly(queue -> { assertThat(queue.name()).isEqualTo("foo-2"); assertThat(queue.status()).isEqualTo(TaskQueueStatus.ACTIVE); @@ -1579,15 +1578,15 @@ private void registerActivity(final String name, final Activity exec } private void registerWorkflowWorker(final String name, final int maxConcurrency) { - engine.registerWorkflowWorker( - new WorkflowTaskWorkerOptions(name, WORKFLOW_TASK_QUEUE, maxConcurrency) + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.WORKFLOW, name, WORKFLOW_TASK_QUEUE, maxConcurrency) .withMinPollInterval(Duration.ofMillis(10)) .withPollBackoffFunction(IntervalFunction.of(10))); } - private void registerActivityWorker(final String name, final int maxConcurrency) { - engine.registerActivityWorker( - new ActivityTaskWorkerOptions(name, ACTIVITY_TASK_QUEUE, maxConcurrency) + private void registerTaskWorker(final String name, final int maxConcurrency) { + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.ACTIVITY, name, ACTIVITY_TASK_QUEUE, maxConcurrency) .withMinPollInterval(Duration.ofMillis(10)) .withPollBackoffFunction(IntervalFunction.of(10))); } diff --git a/dex/testing/src/test/java/org/dependencytrack/dex/testing/WorkflowTestRuleTest.java b/dex/testing/src/test/java/org/dependencytrack/dex/testing/WorkflowTestRuleTest.java index c4af4a3d09..9db90c5823 100644 --- a/dex/testing/src/test/java/org/dependencytrack/dex/testing/WorkflowTestRuleTest.java +++ b/dex/testing/src/test/java/org/dependencytrack/dex/testing/WorkflowTestRuleTest.java @@ -25,12 +25,11 @@ import org.dependencytrack.dex.api.Workflow; import org.dependencytrack.dex.api.WorkflowContext; import org.dependencytrack.dex.api.WorkflowSpec; -import org.dependencytrack.dex.engine.api.ActivityTaskWorkerOptions; import org.dependencytrack.dex.engine.api.DexEngine; -import org.dependencytrack.dex.engine.api.TaskQueueType; +import org.dependencytrack.dex.engine.api.TaskType; +import org.dependencytrack.dex.engine.api.TaskWorkerOptions; import org.dependencytrack.dex.engine.api.WorkflowRun; import org.dependencytrack.dex.engine.api.WorkflowRunStatus; -import org.dependencytrack.dex.engine.api.WorkflowTaskWorkerOptions; import org.dependencytrack.dex.engine.api.request.CreateTaskQueueRequest; import org.dependencytrack.dex.engine.api.request.CreateWorkflowRunRequest; import org.jspecify.annotations.Nullable; @@ -77,15 +76,15 @@ public void shouldExecuteWorkflow() { stringConverter(), Duration.ofSeconds(3)); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "default", 10)); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "default", 10)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "default", 10)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "default", 10)); - engine.registerWorkflowWorker( - new WorkflowTaskWorkerOptions("workflow-worker", "default", 1) + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.WORKFLOW, "workflow-worker", "default", 1) .withMinPollInterval(Duration.ofMillis(25)) .withPollBackoffFunction(IntervalFunction.of(25))); - engine.registerActivityWorker( - new ActivityTaskWorkerOptions("activity-worker", "default", 1) + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.ACTIVITY, "activity-worker", "default", 1) .withMinPollInterval(Duration.ofMillis(25)) .withPollBackoffFunction(IntervalFunction.of(25))); @@ -117,15 +116,15 @@ public void shouldSupportMockedActivities() { stringConverter(), Duration.ofSeconds(3)); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.WORKFLOW, "default", 10)); - engine.createTaskQueue(new CreateTaskQueueRequest(TaskQueueType.ACTIVITY, "default", 10)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.WORKFLOW, "default", 10)); + engine.createTaskQueue(new CreateTaskQueueRequest(TaskType.ACTIVITY, "default", 10)); - engine.registerWorkflowWorker( - new WorkflowTaskWorkerOptions("workflow-worker", "default", 1) + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.WORKFLOW, "workflow-worker", "default", 1) .withMinPollInterval(Duration.ofMillis(25)) .withPollBackoffFunction(IntervalFunction.of(25))); - engine.registerActivityWorker( - new ActivityTaskWorkerOptions("activity-worker", "default", 1) + engine.registerTaskWorker( + new TaskWorkerOptions(TaskType.ACTIVITY, "activity-worker", "default", 1) .withMinPollInterval(Duration.ofMillis(25)) .withPollBackoffFunction(IntervalFunction.of(25))); diff --git a/migration/src/main/resources/migration/changelog-v5.7.0.xml b/migration/src/main/resources/migration/changelog-v5.7.0.xml index 54638d3bb5..26b9e90065 100644 --- a/migration/src/main/resources/migration/changelog-v5.7.0.xml +++ b/migration/src/main/resources/migration/changelog-v5.7.0.xml @@ -642,4 +642,132 @@ AND "KEY" LIKE 'watermark/%'; + + + + + UPDATE "NOTIFICATIONPUBLISHER" + SET "PUBLISHER_CLASS" = t.extension_name + FROM ( + VALUES ('ConsolePublisher', 'console') + , ('CsWebexPublisher', 'webex') + , ('JiraPublisher', 'jira') + , ('MattermostPublisher', 'mattermost') + , ('MsTeamsPublisher', 'msteams') + , ('SendMailPublisher', 'email') + , ('SlackPublisher', 'slack') + , ('WebhookPublisher', 'webhook') + ) AS t(publisher_class, extension_name) + WHERE "PUBLISHER_CLASS" = t.publisher_class; + + ALTER TABLE "NOTIFICATIONPUBLISHER" RENAME COLUMN "PUBLISHER_CLASS" TO "EXTENSION_NAME"; + + ALTER TABLE "NOTIFICATIONPUBLISHER" ALTER COLUMN "TEMPLATE_MIME_TYPE" DROP NOT NULL; + + + + + + + ALTER TABLE "NOTIFICATIONRULE" ADD COLUMN "NOTIFY_ON_ARRAY" TEXT[]; + + UPDATE "NOTIFICATIONRULE" + SET "NOTIFY_ON_ARRAY" = STRING_TO_ARRAY("NOTIFY_ON", ',') + WHERE "NOTIFY_ON" IS NOT NULL; + + ALTER TABLE "NOTIFICATIONRULE" DROP COLUMN "NOTIFY_ON"; + ALTER TABLE "NOTIFICATIONRULE" RENAME COLUMN "NOTIFY_ON_ARRAY" TO "NOTIFY_ON"; + + + + + + + ALTER TABLE "NOTIFICATIONRULE" + ALTER COLUMN "PUBLISHER_CONFIG" + TYPE JSONB USING "PUBLISHER_CONFIG"::JSONB; + + + + + + + UPDATE "NOTIFICATIONRULE" AS r + SET "PUBLISHER_CONFIG" = NULL + , "ENABLED" = FALSE + FROM "NOTIFICATIONPUBLISHER" AS p + WHERE p."ID" = r."PUBLISHER" + AND p."EXTENSION_NAME" = 'console'; + + UPDATE "NOTIFICATIONRULE" AS r + SET "PUBLISHER_CONFIG" = JSONB_BUILD_OBJECT( + 'recipientAddresses', + CASE + WHEN NULLIF(r."PUBLISHER_CONFIG" ->> 'destination', '') IS NOT NULL + THEN JSONB_BUILD_ARRAY(r."PUBLISHER_CONFIG" ->> 'destination') + ELSE JSONB_BUILD_ARRAY() + END + ) + , "ENABLED" = FALSE + FROM "NOTIFICATIONPUBLISHER" AS p + WHERE p."ID" = r."PUBLISHER" + AND p."EXTENSION_NAME" = 'email'; + + UPDATE "NOTIFICATIONRULE" AS r + SET "PUBLISHER_CONFIG" = JSONB_BUILD_OBJECT( + 'projectKey', COALESCE("PUBLISHER_CONFIG" ->> 'destination', 'EXAMPLE') + , 'issueType', COALESCE("PUBLISHER_CONFIG" ->> 'jiraTicketType', 'TASK') + ) + , "ENABLED" = FALSE + FROM "NOTIFICATIONPUBLISHER" AS p + WHERE p."ID" = r."PUBLISHER" + AND p."EXTENSION_NAME" = 'jira'; + + UPDATE "NOTIFICATIONRULE" AS r + SET "PUBLISHER_CONFIG" = JSONB_BUILD_OBJECT( + 'destinationUrl', COALESCE("PUBLISHER_CONFIG" ->> 'destination', 'https://example.com') + ) + , "ENABLED" = FALSE + FROM "NOTIFICATIONPUBLISHER" AS p + WHERE p."ID" = r."PUBLISHER" + AND p."EXTENSION_NAME" IN ('mattermost', 'msteams', 'slack', 'webex', 'webhook'); + + + + + + + + + + + + + DELETE + FROM "CONFIGPROPERTY" + WHERE "GROUPNAME" = 'email'; + + DELETE + FROM "CONFIGPROPERTY" + WHERE "GROUPNAME" = 'integrations' + AND "PROPERTYNAME" LIKE 'jira.%'; + + diff --git a/notification/api/src/main/java/org/dependencytrack/notification/api/publishing/NotificationPublisherFactory.java b/notification/api/src/main/java/org/dependencytrack/notification/api/publishing/NotificationPublisherFactory.java index 3972ecc066..43ceebc2a7 100644 --- a/notification/api/src/main/java/org/dependencytrack/notification/api/publishing/NotificationPublisherFactory.java +++ b/notification/api/src/main/java/org/dependencytrack/notification/api/publishing/NotificationPublisherFactory.java @@ -36,21 +36,23 @@ public interface NotificationPublisherFactory extends ExtensionFactory publisherClass) { - final InputStream inputStream = publisherClass.getResourceAsStream("DefaultTemplate.peb"); + final InputStream inputStream = publisherClass.getResourceAsStream("default-template.peb"); if (inputStream == null) { throw new NoSuchElementException("No default template found for publisher: " + publisherClass.getName()); } diff --git a/notification/publishing/pom.xml b/notification/publishing/pom.xml index 6fdf98408c..ad410f2881 100644 --- a/notification/publishing/pom.xml +++ b/notification/publishing/pom.xml @@ -151,42 +151,50 @@ jsonschema2pojo-maven-plugin - notification-rule-config-email + config-email generate - ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/email/EmailNotificationRuleConfig.schema.json + + ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-global-config-v1.schema.json + ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-rule-config-v1.schema.json + org.dependencytrack.notification.publishing.email - notification-rule-config-http + config-http generate - ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/http/HttpNotificationRuleConfig.schema.json + ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/http org.dependencytrack.notification.publishing.http - notification-rule-config-jira + config-jira generate - ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/jira/JiraNotificationRuleConfig.schema.json + + ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-global-config-v1.schema.json + ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-rule-config-v1.schema.json + org.dependencytrack.notification.publishing.jira - notification-rule-config-kafka + config-kafka generate - ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/kafka/KafkaNotificationRuleConfig.schema.json + + ${basedir}/src/main/resources/org/dependencytrack/notification/publishing/kafka + org.dependencytrack.notification.publishing.kafka diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/DefaultNotificationPublisherPlugin.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/DefaultNotificationPublishersPlugin.java similarity index 97% rename from notification/publishing/src/main/java/org/dependencytrack/notification/publishing/DefaultNotificationPublisherPlugin.java rename to notification/publishing/src/main/java/org/dependencytrack/notification/publishing/DefaultNotificationPublishersPlugin.java index 9ced8f75d6..5369f68722 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/DefaultNotificationPublisherPlugin.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/DefaultNotificationPublishersPlugin.java @@ -37,7 +37,7 @@ /** * @since 5.7.0 */ -public final class DefaultNotificationPublisherPlugin implements Plugin { +public final class DefaultNotificationPublishersPlugin implements Plugin { @Override public Collection> extensionFactories() { diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherFactory.java index 44a721639f..e74f7e0be9 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherFactory.java @@ -22,6 +22,8 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; import org.dependencytrack.plugin.api.ExtensionContext; +import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; +import org.jspecify.annotations.Nullable; import java.io.OutputStream; @@ -52,11 +54,6 @@ public Class extensionClass() { return ConsoleNotificationPublisher.class; } - @Override - public int priority() { - return 0; - } - @Override public void init(ExtensionContext ctx) { } @@ -66,6 +63,11 @@ public NotificationPublisher create() { return new ConsoleNotificationPublisher(outputStream); } + @Override + public @Nullable RuntimeConfigSpec ruleConfigSpec() { + return null; + } + @Override public NotificationTemplate defaultTemplate() { return new NotificationTemplate(loadDefaultTemplate(extensionClass()), "text/plain"); diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisher.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisher.java index 6eae1c4463..83972748d3 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisher.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisher.java @@ -18,10 +18,8 @@ */ package org.dependencytrack.notification.publishing.email; -import jakarta.mail.Authenticator; import jakarta.mail.Message; import jakarta.mail.MessagingException; -import jakarta.mail.PasswordAuthentication; import jakarta.mail.Session; import jakarta.mail.Transport; import jakarta.mail.internet.InternetAddress; @@ -31,14 +29,17 @@ import org.dependencytrack.notification.api.publishing.NotificationPublishContext; import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationRuleContact; +import org.dependencytrack.notification.api.publishing.RetryablePublishException; import org.dependencytrack.notification.api.templating.RenderedNotificationTemplate; import org.dependencytrack.notification.proto.v1.Notification; +import org.eclipse.angus.mail.smtp.SMTPSendFailedException; +import org.eclipse.angus.mail.util.MailConnectException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; import java.util.Collection; import java.util.Objects; import java.util.Optional; -import java.util.Properties; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -47,11 +48,17 @@ */ final class EmailNotificationPublisher implements NotificationPublisher { - private static final long TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10); + private final Session session; + private final String senderAddress; + + EmailNotificationPublisher(Session session, String senderAddress) { + this.session = session; + this.senderAddress = senderAddress; + } @Override public void publish(NotificationPublishContext ctx, Notification notification) { - final var ruleConfig = ctx.ruleConfig(EmailNotificationRuleConfig.class); + final var ruleConfig = ctx.ruleConfig(EmailNotificationPublisherRuleConfigV1.class); final RenderedNotificationTemplate renderedTemplate = ctx.templateRenderer().render(notification); if (renderedTemplate == null) { @@ -76,11 +83,9 @@ public void publish(NotificationPublishContext ctx, Notification notification) { : "", notification.getTitle()).trim(); - final Session session = getSession(ruleConfig); - try { final var message = new MimeMessage(session); - message.setSender(new InternetAddress(ruleConfig.getSenderAddress())); + message.setSender(new InternetAddress(senderAddress)); message.setRecipients(Message.RecipientType.TO, recipients); message.setSubject(messageSubject); @@ -89,44 +94,30 @@ public void publish(NotificationPublishContext ctx, Notification notification) { final var multipart = new MimeMultipart(); multipart.addBodyPart(bodyPart); - message.setContent(multipart); + message.setContent(multipart, renderedTemplate.mimeType()); Transport.send(message); } catch (MessagingException e) { - throw new IllegalStateException(e); + if (isRetryable(e)) { + throw new RetryablePublishException("Failed to send email with retryable cause", e); + } + + throw new IllegalStateException("Failed to send email", e); } } - private Session getSession(EmailNotificationRuleConfig ruleConfig) { - final boolean authenticated = - ruleConfig.getSmtp().getUsername() != null - && ruleConfig.getSmtp().getPassword() != null; - - final Properties props = new Properties(); - props.put("mail.smtp.host", ruleConfig.getSmtp().getHost()); - props.put("mail.smtp.port", ruleConfig.getSmtp().getPort()); - props.put("mail.smtp.socketFactory.port", ruleConfig.getSmtp().getPort()); - if (authenticated) { - props.put("mail.smtp.auth", true); + private boolean isRetryable(MessagingException e) { + if (e instanceof MailConnectException + || e.getCause() instanceof ConnectException + || e.getCause() instanceof SocketTimeoutException) { + return true; } - // props.put("mail.smtp.starttls.enable", useStartTLS); - props.put("mail.smtp.connectiontimeout", TIMEOUT_MILLIS); - props.put("mail.smtp.timeout", TIMEOUT_MILLIS); - props.put("mail.smtp.writetimeout", TIMEOUT_MILLIS); - - Authenticator authenticator = null; - if (authenticated) { - authenticator = new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication( - ruleConfig.getSmtp().getUsername(), - ruleConfig.getSmtp().getPassword()); - } - }; + + if (e instanceof final SMTPSendFailedException ssfe) { + return ssfe.getReturnCode() >= 400 && ssfe.getReturnCode() < 500; } - return Session.getInstance(props, authenticator); + return false; } } diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherFactory.java index c665ef8004..e8377a8ea5 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherFactory.java @@ -18,12 +18,33 @@ */ package org.dependencytrack.notification.publishing.email; +import jakarta.mail.AuthenticationFailedException; +import jakarta.mail.Authenticator; +import jakarta.mail.MessagingException; +import jakarta.mail.PasswordAuthentication; +import jakarta.mail.Session; +import jakarta.mail.Transport; import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; import org.dependencytrack.plugin.api.ExtensionContext; +import org.dependencytrack.plugin.api.ExtensionTestResult; +import org.dependencytrack.plugin.api.config.ConfigRegistry; +import org.dependencytrack.plugin.api.config.InvalidRuntimeConfigException; +import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.net.ssl.SSLSocketFactory; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; + +import static java.util.Objects.requireNonNull; import static org.dependencytrack.notification.api.publishing.NotificationPublisherFactory.loadDefaultTemplate; /** @@ -31,6 +52,24 @@ */ public final class EmailNotificationPublisherFactory implements NotificationPublisherFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(EmailNotificationPublisherFactory.class); + + private final Map overrideMailProperties; + private final Class sslSocketFactoryClass; + private @Nullable ConfigRegistry configRegistry; + private boolean localConnectionsAllowed; + + EmailNotificationPublisherFactory( + Map overrideMailProperties, + Class sslSocketFactoryClass) { + this.overrideMailProperties = Map.copyOf(overrideMailProperties); + this.sslSocketFactoryClass = sslSocketFactoryClass; + } + + public EmailNotificationPublisherFactory() { + this(Collections.emptyMap(), SSLSocketFactory.class); + } + @Override public String extensionName() { return "email"; @@ -42,31 +81,93 @@ public Class extensionClass() { } @Override - public int priority() { - return 0; + public void init(ExtensionContext ctx) { + configRegistry = ctx.configRegistry(); + localConnectionsAllowed = configRegistry + .getDeploymentConfig() + .getOptionalValue("allow-local-connections", boolean.class) + .orElse(false); } @Override - public void init(ExtensionContext ctx) { + public NotificationPublisher create() { + requireNonNull(configRegistry, "configRegistry must not be null"); + + final var globalConfig = configRegistry.getRuntimeConfig(EmailNotificationPublisherGlobalConfigV1.class); + + if (!globalConfig.isEnabled()) { + throw new IllegalStateException("Publisher is disabled"); + } + + if (!localConnectionsAllowed && isLocalHost(globalConfig.getHost())) { + throw new IllegalStateException(""" + The configured host resolves to a local address, \ + but local connections are not allowed"""); + } + + return new EmailNotificationPublisher( + createSession(globalConfig), + globalConfig.getSenderAddress()); } @Override - public NotificationPublisher create() { - return new EmailNotificationPublisher(); + public RuntimeConfigSpec runtimeConfigSpec() { + return RuntimeConfigSpec.of( + new EmailNotificationPublisherGlobalConfigV1(), + config -> { + if (!config.isEnabled()) { + return; + } + if (config.getHost() == null) { + throw new InvalidRuntimeConfigException("No host provided"); + } + if (config.getPort() == null) { + throw new InvalidRuntimeConfigException("No port provided"); + } + if (config.getSenderAddress() == null) { + throw new InvalidRuntimeConfigException("No sender address provided"); + } + }); + } + + @Override + public ExtensionTestResult test(@Nullable RuntimeConfig runtimeConfig) { + requireNonNull(runtimeConfig, "runtimeConfig must not be null"); + + final var config = (EmailNotificationPublisherGlobalConfigV1) runtimeConfig; + + final var testResult = ExtensionTestResult.ofChecks("connection"); + if (!config.isEnabled()) { + return testResult; + } + + if (!localConnectionsAllowed && isLocalHost(config.getHost())) { + return testResult.fail("connection", """ + The configured host resolves to a local address, \ + but local connections are not allowed"""); + } + + final Session session = createSession(config); + + try (final Transport transport = session.getTransport()) { + transport.connect(); + testResult.pass("connection"); + } catch (AuthenticationFailedException e) { + LOGGER.warn("Failed to authenticate to email server", e); + testResult.fail("connection", "Authentication failed"); + } catch (MessagingException e) { + LOGGER.warn("Failed to connect to email server", e); + testResult.fail("connection", "Connection failed, check logs for details"); + } + + return testResult; } @Override public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new EmailNotificationRuleConfig() - .withSmtp(new Smtp() - .withHost("localhost") - .withPort(25) - .withSslEnabled(false) - .withStartTlsEnabled(false)) - .withSenderAddress("dependencytrack@localhost") - .withSubjectPrefix("[Dependency-Track]"); - - return RuntimeConfigSpec.of(defaultConfig); + return RuntimeConfigSpec.of( + new EmailNotificationPublisherRuleConfigV1() + .withSubjectPrefix("[Dependency-Track]")); } @Override @@ -74,4 +175,58 @@ public NotificationTemplate defaultTemplate() { return new NotificationTemplate(loadDefaultTemplate(extensionClass()), "text/plain"); } + private Session createSession(EmailNotificationPublisherGlobalConfigV1 config) { + final Properties props = new Properties(); + props.put("mail.smtp.host", config.getHost()); + props.put("mail.smtp.port", config.getPort()); + props.put("mail.smtp.socketFactory.port", config.getPort()); + props.put("mail.smtp.connectiontimeout", 10_000); + props.put("mail.smtp.timeout", 10_000); + props.put("mail.smtp.writetimeout", 10_000); + + if (config.isSslEnabled()) { + props.put("mail.smtp.ssl.enable", true); + props.put("mail.smtp.socketFactory.class", sslSocketFactoryClass.getName()); + props.put("mail.smtp.socketFactory.fallback", "false"); + } + + if (config.isStartTlsEnabled()) { + props.put("mail.smtp.starttls.enable", true); + } + + final boolean authenticated = + config.getUsername() != null + && config.getPassword() != null; + + Authenticator authenticator = null; + if (authenticated) { + props.put("mail.smtp.auth", true); + authenticator = new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication( + config.getUsername(), + config.getPassword()); + } + }; + } + + props.putAll(overrideMailProperties); + + return Session.getInstance(props, authenticator); + } + + private boolean isLocalHost(String hostname) { + try { + InetAddress hostAddress = InetAddress.getByName(hostname); + return hostAddress.isLoopbackAddress() + || hostAddress.isLinkLocalAddress() + || hostAddress.isSiteLocalAddress() + || hostAddress.isAnyLocalAddress(); + } catch (UnknownHostException e) { + // Let the actual connection logic handle this. + return false; + } + } + } diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/http/AbstractHttpNotificationPublisher.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/http/AbstractHttpNotificationPublisher.java index bf1b8b6d3d..07889720d4 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/http/AbstractHttpNotificationPublisher.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/http/AbstractHttpNotificationPublisher.java @@ -29,7 +29,9 @@ import java.io.IOException; import java.net.http.HttpClient; import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpTimeoutException; import java.time.Duration; import java.util.Set; @@ -53,7 +55,7 @@ protected AbstractHttpNotificationPublisher(HttpClient httpClient) { @Override public void publish(NotificationPublishContext ctx, Notification notification) throws IOException { - final var ruleConfig = ctx.ruleConfig(HttpNotificationRuleConfig.class); + final var ruleConfig = ctx.ruleConfig(HttpNotificationPublisherRuleConfigV1.class); final RenderedNotificationTemplate renderedTemplate = ctx.templateRenderer().render(notification); if (renderedTemplate == null) { @@ -63,13 +65,14 @@ public void publish(NotificationPublishContext ctx, Notification notification) t final var request = HttpRequest .newBuilder(ruleConfig.getDestinationUrl()) .header("Content-Type", renderedTemplate.mimeType()) - .POST(HttpRequest.BodyPublishers.ofString(renderedTemplate.content())) + .header("User-Agent", "Dependency-Track") + .POST(BodyPublishers.ofString(renderedTemplate.content())) .timeout(Duration.ofSeconds(10)) .build(); final HttpResponse response; try { - response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + response = httpClient.send(request, BodyHandlers.discarding()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RetryablePublishException("Interrupted while sending request", e); diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisher.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisher.java index f2c82644d9..44e24f2f53 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisher.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisher.java @@ -28,7 +28,9 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpTimeoutException; import java.time.Duration; import java.util.Base64; @@ -39,46 +41,50 @@ */ final class JiraNotificationPublisher implements NotificationPublisher { + private final JiraNotificationPublisherGlobalConfigV1 globalConfig; private final HttpClient httpClient; - JiraNotificationPublisher(HttpClient httpClient) { + JiraNotificationPublisher( + JiraNotificationPublisherGlobalConfigV1 globalConfig, + HttpClient httpClient) { + this.globalConfig = globalConfig; this.httpClient = httpClient; } @Override public void publish(NotificationPublishContext ctx, Notification notification) throws IOException { - final var ruleConfig = ctx.ruleConfig(JiraNotificationRuleConfig.class); + final var ruleConfig = ctx.ruleConfig(JiraNotificationPublisherRuleConfigV1.class); final RenderedNotificationTemplate renderedTemplate = ctx.templateRenderer().render( notification, Map.ofEntries( Map.entry("jiraProjectKey", ruleConfig.getProjectKey()), - Map.entry("jiraTicketType", ruleConfig.getTicketType()))); + Map.entry("jiraTicketType", ruleConfig.getIssueType()))); if (renderedTemplate == null) { throw new IllegalStateException("No template configured"); } final String authHeader; - if (ruleConfig.getUsername() != null) { + if (globalConfig.getUsername() != null) { final var credentials = Base64.getEncoder().encodeToString( - "%s:%s".formatted(ruleConfig.getUsername(), ruleConfig.getPasswordOrToken()).getBytes()); + "%s:%s".formatted(globalConfig.getUsername(), globalConfig.getPasswordOrToken()).getBytes()); authHeader = "Basic " + credentials; } else { - authHeader = "Bearer " + ruleConfig.getPasswordOrToken(); + authHeader = "Bearer " + globalConfig.getPasswordOrToken(); } final var request = HttpRequest.newBuilder() - .uri(URI.create("%s/rest/api/2/issue".formatted(ruleConfig.getApiUrl()))) + .uri(URI.create("%s/rest/api/2/issue".formatted(globalConfig.getApiUrl()))) .header("Authorization", authHeader) .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(renderedTemplate.content())) + .header("User-Agent", "Dependency-Track") + .POST(BodyPublishers.ofString(renderedTemplate.content())) .timeout(Duration.ofSeconds(10)) .build(); final HttpResponse response; try { - response = httpClient.send( - request, HttpResponse.BodyHandlers.discarding()); + response = httpClient.send(request, BodyHandlers.discarding()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RetryablePublishException("Interrupted while sending request", e); diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherFactory.java index 5ea80f0cab..2dcb1266d8 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherFactory.java @@ -22,10 +22,11 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; import org.dependencytrack.plugin.api.ExtensionContext; +import org.dependencytrack.plugin.api.config.ConfigRegistry; +import org.dependencytrack.plugin.api.config.InvalidRuntimeConfigException; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; -import java.net.URI; import java.net.http.HttpClient; import static java.util.Objects.requireNonNull; @@ -36,6 +37,7 @@ */ public final class JiraNotificationPublisherFactory implements NotificationPublisherFactory { + private @Nullable ConfigRegistry configRegistry; private @Nullable HttpClient httpClient; @Override @@ -48,13 +50,9 @@ public Class extensionClass() { return JiraNotificationPublisher.class; } - @Override - public int priority() { - return 0; - } - @Override public void init(ExtensionContext ctx) { + configRegistry = ctx.configRegistry(); httpClient = HttpClient.newBuilder() .proxy(ctx.proxySelector()) .build(); @@ -62,19 +60,49 @@ public void init(ExtensionContext ctx) { @Override public NotificationPublisher create() { + requireNonNull(configRegistry, "configRegistry must not be null"); requireNonNull(httpClient, "httpClient must not be null"); - return new JiraNotificationPublisher(httpClient); + + final var globalConfig = configRegistry.getRuntimeConfig(JiraNotificationPublisherGlobalConfigV1.class); + + if (!globalConfig.isEnabled()) { + throw new IllegalStateException("Publisher is disabled"); + } + + return new JiraNotificationPublisher(globalConfig, httpClient); } @Override - public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new JiraNotificationRuleConfig() - .withApiUrl(URI.create("https://jira.example.com")) - .withPasswordOrToken("{{ secret('JIRA_API_TOKEN') }}") - .withProjectKey("EXAMPLE") - .withTicketType("TASK"); + public RuntimeConfigSpec runtimeConfigSpec() { + return RuntimeConfigSpec.of( + new JiraNotificationPublisherGlobalConfigV1(), + config -> { + if (!config.isEnabled()) { + return; + } + if (config.getApiUrl() == null) { + throw new InvalidRuntimeConfigException("No API URL provided"); + } + if (config.getPasswordOrToken() == null) { + throw new InvalidRuntimeConfigException("No password or token provided"); + } + }); + } - return RuntimeConfigSpec.of(defaultConfig); + @Override + public RuntimeConfigSpec ruleConfigSpec() { + return RuntimeConfigSpec.of( + new JiraNotificationPublisherRuleConfigV1() + .withProjectKey("EXAMPLE") + .withIssueType("Bug"), + config -> { + if (config.getProjectKey() == null) { + throw new InvalidRuntimeConfigException("No project key provided"); + } + if (config.getIssueType() == null) { + throw new InvalidRuntimeConfigException("No issue type provided"); + } + }); } @Override diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisher.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisher.java index 43d3b5193a..715c48a0cf 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisher.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisher.java @@ -18,21 +18,35 @@ */ package org.dependencytrack.notification.publishing.kafka; -import com.github.benmanes.caffeine.cache.Cache; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; import org.apache.kafka.clients.producer.KafkaProducer; -import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.errors.RetriableException; import org.apache.kafka.common.header.internals.RecordHeaders; -import org.apache.kafka.common.serialization.ByteArraySerializer; -import org.apache.kafka.common.serialization.StringSerializer; import org.dependencytrack.notification.api.publishing.NotificationPublishContext; import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.RetryablePublishException; import org.dependencytrack.notification.api.templating.RenderedNotificationTemplate; +import org.dependencytrack.notification.proto.v1.BomConsumedOrProcessedSubject; +import org.dependencytrack.notification.proto.v1.BomProcessingFailedSubject; +import org.dependencytrack.notification.proto.v1.BomValidationFailedSubject; +import org.dependencytrack.notification.proto.v1.NewVulnerabilitySubject; +import org.dependencytrack.notification.proto.v1.NewVulnerableDependencySubject; import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.notification.proto.v1.PolicyViolationAnalysisDecisionChangeSubject; +import org.dependencytrack.notification.proto.v1.PolicyViolationSubject; +import org.dependencytrack.notification.proto.v1.Project; +import org.dependencytrack.notification.proto.v1.ProjectVulnAnalysisCompleteSubject; +import org.dependencytrack.notification.proto.v1.UserSubject; +import org.dependencytrack.notification.proto.v1.VexConsumedOrProcessedSubject; +import org.dependencytrack.notification.proto.v1.VulnerabilityAnalysisDecisionChangeSubject; +import org.jspecify.annotations.Nullable; -import java.util.Properties; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collection; +import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -42,23 +56,21 @@ */ final class KafkaNotificationPublisher implements NotificationPublisher { - private final Cache> producerCache; + private final KafkaProducer kafkaProducer; - KafkaNotificationPublisher(Cache> producerCache) { - this.producerCache = producerCache; + KafkaNotificationPublisher(KafkaProducer kafkaProducer) { + this.kafkaProducer = kafkaProducer; } @Override public void publish(NotificationPublishContext ctx, Notification notification) { - final var ruleConfig = ctx.ruleConfig(KafkaNotificationRuleConfig.class); - - final KafkaProducer producer = getProducer(ruleConfig); + final var ruleConfig = ctx.ruleConfig(KafkaNotificationPublisherRuleConfigV1.class); final RenderedNotificationTemplate renderedTemplate = ctx.templateRenderer().render(notification); final String mimeType; final byte[] notificationContent; - if (ruleConfig.getPublishProtobuf()) { + if (Boolean.TRUE.equals(ruleConfig.getPublishProtobuf())) { // https://protobuf.dev/reference/protobuf/mime-types/ mimeType = "application/protobuf"; notificationContent = notification.toByteArray(); @@ -69,20 +81,32 @@ public void publish(NotificationPublishContext ctx, Notification notification) { throw new IllegalStateException("No template configured"); } - // TODO: Extract key from notification. + final String recordKey; + try { + recordKey = extractKey(notification); + } catch (IOException e) { + throw new UncheckedIOException("Failed to extract record key from notification", e); + } + final var producerRecord = new ProducerRecord<>( ruleConfig.getTopicName(), /* partition */ null, - "TODO", + recordKey, notificationContent, new RecordHeaders() .add("content-type", mimeType.getBytes())); try { - producer.send(producerRecord).get(10, TimeUnit.SECONDS); + kafkaProducer.send(producerRecord).get(10, TimeUnit.SECONDS); + } catch (IllegalStateException e) { + if (e.getMessage() != null && e.getMessage().toLowerCase().contains("closed")) { + throw new RetryablePublishException("Kafka publisher is closed", e); + } + + throw e; } catch (ExecutionException e) { - if (e.getCause() instanceof final RetriableException retriableException) { - throw new RetryablePublishException("Failed to send record with retryable cause", e); + if (e.getCause() instanceof final RetriableException re) { + throw new RetryablePublishException("Failed to send record with retryable cause", re); } throw new IllegalStateException("Failed to send record", e); @@ -94,16 +118,94 @@ public void publish(NotificationPublishContext ctx, Notification notification) { } } - private KafkaProducer getProducer(KafkaNotificationRuleConfig ruleConfig) { - final var producerCfg = new Properties(); - producerCfg.setProperty( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, - String.join(",", ruleConfig.getBootstrapServers())); - producerCfg.setProperty(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true"); - producerCfg.setProperty(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); - producerCfg.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName()); + private static @Nullable String extractKey(Notification notification) throws InvalidProtocolBufferException { + return switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED, GROUP_BOM_PROCESSED -> { + requireSubjectOfTypeAnyOf(notification, List.of(BomConsumedOrProcessedSubject.class)); + final var subject = notification.getSubject().unpack(BomConsumedOrProcessedSubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_BOM_PROCESSING_FAILED -> { + requireSubjectOfTypeAnyOf(notification, List.of(BomProcessingFailedSubject.class)); + final var subject = notification.getSubject().unpack(BomProcessingFailedSubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_BOM_VALIDATION_FAILED -> { + requireSubjectOfTypeAnyOf(notification, List.of(BomValidationFailedSubject.class)); + final var subject = notification.getSubject().unpack(BomValidationFailedSubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_NEW_VULNERABILITY -> { + requireSubjectOfTypeAnyOf(notification, List.of(NewVulnerabilitySubject.class)); + final var subject = notification.getSubject().unpack(NewVulnerabilitySubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_NEW_VULNERABLE_DEPENDENCY -> { + requireSubjectOfTypeAnyOf(notification, List.of(NewVulnerableDependencySubject.class)); + final var subject = notification.getSubject().unpack(NewVulnerableDependencySubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_POLICY_VIOLATION -> { + requireSubjectOfTypeAnyOf(notification, List.of(PolicyViolationSubject.class)); + final var subject = notification.getSubject().unpack(PolicyViolationSubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_PROJECT_AUDIT_CHANGE -> { + final Class matchingSubject = requireSubjectOfTypeAnyOf(notification, List.of( + PolicyViolationAnalysisDecisionChangeSubject.class, + VulnerabilityAnalysisDecisionChangeSubject.class)); + + if (matchingSubject == PolicyViolationAnalysisDecisionChangeSubject.class) { + final var subject = notification.getSubject().unpack(PolicyViolationAnalysisDecisionChangeSubject.class); + yield subject.getProject().getUuid(); + } else { + final var subject = notification.getSubject().unpack(VulnerabilityAnalysisDecisionChangeSubject.class); + yield subject.getProject().getUuid(); + } + } + case GROUP_PROJECT_CREATED -> { + requireSubjectOfTypeAnyOf(notification, List.of(Project.class)); + final var subject = notification.getSubject().unpack(Project.class); + yield subject.getUuid(); + } + case GROUP_PROJECT_VULN_ANALYSIS_COMPLETE -> { + requireSubjectOfTypeAnyOf(notification, List.of(ProjectVulnAnalysisCompleteSubject.class)); + final var subject = notification.getSubject().unpack(ProjectVulnAnalysisCompleteSubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_VEX_CONSUMED, GROUP_VEX_PROCESSED -> { + requireSubjectOfTypeAnyOf(notification, List.of(VexConsumedOrProcessedSubject.class)); + final var subject = notification.getSubject().unpack(VexConsumedOrProcessedSubject.class); + yield subject.getProject().getUuid(); + } + case GROUP_USER_CREATED, GROUP_USER_DELETED -> { + requireSubjectOfTypeAnyOf(notification, List.of(UserSubject.class)); + final var subject = notification.getSubject().unpack(UserSubject.class); + yield subject.getUsername(); + } + case GROUP_ANALYZER, GROUP_CONFIGURATION, GROUP_DATASOURCE_MIRRORING, + GROUP_FILE_SYSTEM, GROUP_INTEGRATION, GROUP_REPOSITORY -> null; + case GROUP_UNSPECIFIED, UNRECOGNIZED -> throw new IllegalArgumentException(""" + Unable to determine record key because the notification does not \ + specify a notification group: %s""".formatted(notification.getGroup())); + // NB: The lack of a default case is intentional. This way, the compiler will fail + // the build when new groups are added, and we don't have a case for it :) + }; + } + + private static Class requireSubjectOfTypeAnyOf( + Notification notification, + Collection> subjectClasses) { + if (!notification.hasSubject()) { + throw new IllegalArgumentException( + "Expected subject of type matching any of %s, but notification has no subject".formatted(subjectClasses)); + } - return producerCache.get(producerCfg, KafkaProducer::new); + return subjectClasses.stream() + .filter(notification.getSubject()::is).findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Expected subject of type matching any of %s, but is %s".formatted( + subjectClasses, notification.getSubject().getTypeUrl()))); } } diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherFactory.java index c3a78e238e..bbe603fe5a 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherFactory.java @@ -18,31 +18,70 @@ */ package org.dependencytrack.notification.publishing.kafka; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.record.CompressionType; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.common.serialization.StringSerializer; import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; +import org.dependencytrack.notification.api.templating.NotificationTemplate; import org.dependencytrack.plugin.api.ExtensionContext; +import org.dependencytrack.plugin.api.ExtensionTestResult; +import org.dependencytrack.plugin.api.config.ConfigRegistry; +import org.dependencytrack.plugin.api.config.InvalidRuntimeConfigException; +import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Duration; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Objects; import java.util.Properties; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; import static java.util.Objects.requireNonNull; +import static org.apache.kafka.clients.CommonClientConfigs.REQUEST_TIMEOUT_MS_CONFIG; +import static org.apache.kafka.clients.CommonClientConfigs.SECURITY_PROTOCOL_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.BOOTSTRAP_SERVERS_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.COMPRESSION_TYPE_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.LINGER_MS_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.MAX_BLOCK_MS_CONFIG; +import static org.apache.kafka.clients.producer.ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG; +import static org.apache.kafka.common.config.SslConfigs.SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG; +import static org.apache.kafka.common.config.SslConfigs.SSL_KEYSTORE_KEY_CONFIG; +import static org.apache.kafka.common.config.SslConfigs.SSL_KEYSTORE_TYPE_CONFIG; +import static org.apache.kafka.common.config.SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG; +import static org.apache.kafka.common.config.SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG; /** * @since 5.7.0 */ public final class KafkaNotificationPublisherFactory implements NotificationPublisherFactory { + private record CachedProducer( + ProducerConfig config, + KafkaProducer producer) { + } + private static final Logger LOGGER = LoggerFactory.getLogger(KafkaNotificationPublisherFactory.class); - private @Nullable Cache> producerCache; + private final Lock producerCacheLock = new ReentrantLock(); + private @Nullable ConfigRegistry configRegistry; + private @Nullable CachedProducer cachedProducer; + private boolean localConnectionsAllowed; @Override public String extensionName() { @@ -55,44 +94,207 @@ public Class extensionClass() { } @Override - public int priority() { - return 0; + public void init(ExtensionContext ctx) { + configRegistry = ctx.configRegistry(); + localConnectionsAllowed = configRegistry + .getDeploymentConfig() + .getOptionalValue("allow-local-connections", boolean.class) + .orElse(false); } @Override - public void init(ExtensionContext ctx) { - producerCache = Caffeine.newBuilder() - .expireAfterAccess(Duration.ofMinutes(5)) - .>removalListener( - (props, producer, cause) -> { - if (producer != null) { - LOGGER.debug("Closing producer due to removal from cache with cause: {}", cause); - producer.close(Duration.ofSeconds(15)); - } - }) - .build(); + public NotificationPublisher create() { + requireNonNull(configRegistry, "configRegistry must not be null"); + + final var globalConfig = configRegistry.getRuntimeConfig(KafkaNotificationPublisherGlobalConfigV1.class); + + if (!globalConfig.isEnabled()) { + throw new IllegalStateException("Publisher is disabled"); + } + + if (!localConnectionsAllowed) { + final Set brokerHosts = extractBrokerHosts(globalConfig); + for (final var brokerHost : brokerHosts) { + if (isLocalHost(brokerHost)) { + throw new IllegalStateException(""" + Bootstrap server '%s' resolves to a local address, \ + but local connections are not allowed""".formatted(brokerHost)); + } + } + } + + final KafkaProducer kafkaProducer = getKafkaProducer(globalConfig); + + return new KafkaNotificationPublisher(kafkaProducer); } @Override - public NotificationPublisher create() { - requireNonNull(producerCache, "producerCache must not be null"); - return new KafkaNotificationPublisher(producerCache); + public RuntimeConfigSpec runtimeConfigSpec() { + return RuntimeConfigSpec.of( + new KafkaNotificationPublisherGlobalConfigV1(), + config -> { + if (!config.isEnabled()) { + return; + } + if (config.getBootstrapServers() == null || config.getBootstrapServers().isEmpty()) { + throw new InvalidRuntimeConfigException("No bootstrap servers provided"); + } + if (config.getTls() != null && config.getTls().isEnabled()) { + if (config.getTls().getCaCert() == null) { + throw new InvalidRuntimeConfigException("No TLS CA certificate provided"); + } + } + if (config.getmTls() != null && config.getmTls().isEnabled()) { + if (!config.getTls().isEnabled()) { + throw new InvalidRuntimeConfigException("mTLS requires TLS to be enabled"); + } + if (config.getmTls().getClientCert() == null) { + throw new InvalidRuntimeConfigException("No mTLS client certificate provided"); + } + if (config.getmTls().getClientKey() == null) { + throw new InvalidRuntimeConfigException("No mTLS client key provided"); + } + } + }); + } + + @Override + public ExtensionTestResult test(@Nullable RuntimeConfig runtimeConfig) { + requireNonNull(runtimeConfig, "runtimeConfig must not be null"); + + final var config = (KafkaNotificationPublisherGlobalConfigV1) runtimeConfig; + + final var testResult = ExtensionTestResult.ofChecks("connection"); + + if (!localConnectionsAllowed) { + final Set brokerHosts = extractBrokerHosts(config); + for (final var brokerHost : brokerHosts) { + if (isLocalHost(brokerHost)) { + return testResult.fail("connection", """ + Bootstrap server '%s' resolves to a local address, \ + but local connections are not allowed""".formatted(brokerHost)); + } + } + } + + final ProducerConfig producerConfig = createProducerConfig(config); + + // NB: The configs relevant for connecting to Kafka clusters + // are identical across all client types, so we can just reuse + // the producer config here. + try (final var adminClient = AdminClient.create(producerConfig.originals())) { + adminClient.describeCluster().clusterId().get(10, TimeUnit.SECONDS); + testResult.pass("connection"); + } catch (ExecutionException | InterruptedException | TimeoutException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOGGER.warn("Failed to connect to Kafka cluster", e); + testResult.fail("connection", "Connection failed, check logs for details"); + } catch (RuntimeException e) { + LOGGER.warn("Failed to test connection to Kafka cluster", e); + testResult.fail("connection", "Internal failure, check logs for details"); + } + + return testResult; } @Override public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new KafkaNotificationRuleConfig() - .withBootstrapServers(Set.of("localhost:9092")) - .withTopicName("dependencytrack-notifications") - .withPublishProtobuf(true); + return RuntimeConfigSpec.of( + new KafkaNotificationPublisherRuleConfigV1() + .withTopicName("dependencytrack-notifications") + .withPublishProtobuf(true)); + } - return RuntimeConfigSpec.of(defaultConfig); + @Override + public @Nullable NotificationTemplate defaultTemplate() { + return null; } @Override public void close() { - if (producerCache != null) { - producerCache.invalidateAll(); + producerCacheLock.lock(); + try { + if (cachedProducer != null) { + cachedProducer.producer().close(); + cachedProducer = null; + } + } finally { + producerCacheLock.unlock(); + } + } + + private KafkaProducer getKafkaProducer(KafkaNotificationPublisherGlobalConfigV1 config) { + final ProducerConfig producerConfig = createProducerConfig(config); + + producerCacheLock.lock(); + try { + if (cachedProducer != null) { + if (Objects.equals(cachedProducer.config(), producerConfig)) { + // NB: Publishers treat closed Kafka producers as a retryable failure. + LOGGER.debug("Using cached producer with matching config"); + return cachedProducer.producer(); + } + + LOGGER.debug("Producer config has changed; Closing cached producer"); + cachedProducer.producer().close(); + cachedProducer = null; + } + + final var producer = new KafkaProducer(producerConfig.originals()); + cachedProducer = new CachedProducer(producerConfig, producer); + return producer; + } finally { + producerCacheLock.unlock(); + } + } + + private static ProducerConfig createProducerConfig(KafkaNotificationPublisherGlobalConfigV1 config) { + final var props = new Properties(); + props.put( + BOOTSTRAP_SERVERS_CONFIG, + String.join(",", config.getBootstrapServers())); + props.put(KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + props.put(VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName()); + props.put(ENABLE_IDEMPOTENCE_CONFIG, "true"); + props.put(COMPRESSION_TYPE_CONFIG, CompressionType.SNAPPY.name); + props.put(LINGER_MS_CONFIG, 0); + props.put(DELIVERY_TIMEOUT_MS_CONFIG, 10_000); // Must be >= linger.ms + request.timeout.ms. + props.put(MAX_BLOCK_MS_CONFIG, 10_000); + props.put(REQUEST_TIMEOUT_MS_CONFIG, 10_000); + + if (config.getTls() != null && config.getTls().isEnabled()) { + props.put(SECURITY_PROTOCOL_CONFIG, "SSL"); + props.put(SSL_TRUSTSTORE_TYPE_CONFIG, "PEM"); + props.put(SSL_TRUSTSTORE_CERTIFICATES_CONFIG, config.getTls().getCaCert()); + + if (config.getmTls() != null && config.getmTls().isEnabled()) { + props.put(SSL_KEYSTORE_TYPE_CONFIG, "PEM"); + props.put(SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG, config.getmTls().getClientCert()); + props.put(SSL_KEYSTORE_KEY_CONFIG, config.getmTls().getClientKey()); + } + } + + return new ProducerConfig(props); + } + + private Set extractBrokerHosts(KafkaNotificationPublisherGlobalConfigV1 config) { + return config.getBootstrapServers().stream() + .map(address -> address.split(":", 2)[0]) + .collect(Collectors.toSet()); + } + + private boolean isLocalHost(String hostname) { + try { + InetAddress hostAddress = InetAddress.getByName(hostname); + return hostAddress.isLoopbackAddress() + || hostAddress.isLinkLocalAddress() + || hostAddress.isSiteLocalAddress() + || hostAddress.isAnyLocalAddress(); + } catch (UnknownHostException e) { + // Let the actual connection logic handle this. + return false; } } diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherFactory.java index 469e0ec068..f53490295a 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherFactory.java @@ -21,7 +21,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.ExtensionContext; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; @@ -49,11 +49,6 @@ public Class extensionClass() { return MattermostNotificationPublisher.class; } - @Override - public int priority() { - return 0; - } - @Override public void init(ExtensionContext ctx) { this.httpClient = HttpClient.newBuilder() @@ -69,10 +64,9 @@ public NotificationPublisher create() { @Override public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new HttpNotificationRuleConfig() - .withDestinationUrl(URI.create("https://mattermost.example.com")); - - return RuntimeConfigSpec.of(defaultConfig); + return RuntimeConfigSpec.of( + new HttpNotificationPublisherRuleConfigV1() + .withDestinationUrl(URI.create("https://mattermost.example.com"))); } @Override diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherFactory.java index fc5e140ac6..afa6253567 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherFactory.java @@ -21,7 +21,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.ExtensionContext; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; @@ -49,11 +49,6 @@ public Class extensionClass() { return MsTeamsNotificationPublisher.class; } - @Override - public int priority() { - return 0; - } - @Override public void init(ExtensionContext ctx) { this.httpClient = HttpClient.newBuilder() @@ -69,10 +64,9 @@ public NotificationPublisher create() { @Override public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new HttpNotificationRuleConfig() - .withDestinationUrl(URI.create("https://msteams.example.com")); - - return RuntimeConfigSpec.of(defaultConfig); + return RuntimeConfigSpec.of( + new HttpNotificationPublisherRuleConfigV1() + .withDestinationUrl(URI.create("https://msteams.example.com"))); } @Override diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherFactory.java index 8f63b18afa..8a4a33c3e2 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherFactory.java @@ -21,7 +21,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.ExtensionContext; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; @@ -49,11 +49,6 @@ public Class extensionClass() { return SlackNotificationPublisher.class; } - @Override - public int priority() { - return 0; - } - @Override public void init(ExtensionContext ctx) { this.httpClient = HttpClient.newBuilder() @@ -69,10 +64,9 @@ public NotificationPublisher create() { @Override public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new HttpNotificationRuleConfig() - .withDestinationUrl(URI.create("https://slack.example.com")); - - return RuntimeConfigSpec.of(defaultConfig); + return RuntimeConfigSpec.of( + new HttpNotificationPublisherRuleConfigV1() + .withDestinationUrl(URI.create("https://slack.example.com"))); } @Override diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherFactory.java index 3f76843aec..942ec4e099 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherFactory.java @@ -21,7 +21,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.ExtensionContext; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; @@ -49,11 +49,6 @@ public Class extensionClass() { return WebexNotificationPublisher.class; } - @Override - public int priority() { - return 0; - } - @Override public void init(ExtensionContext ctx) { this.httpClient = HttpClient.newBuilder() @@ -69,10 +64,9 @@ public NotificationPublisher create() { @Override public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new HttpNotificationRuleConfig() - .withDestinationUrl(URI.create("https://webex.example.com")); - - return RuntimeConfigSpec.of(defaultConfig); + return RuntimeConfigSpec.of( + new HttpNotificationPublisherRuleConfigV1() + .withDestinationUrl(URI.create("https://webex.example.com"))); } @Override diff --git a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherFactory.java b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherFactory.java index 8d37ed35ec..c8cc42de88 100644 --- a/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherFactory.java +++ b/notification/publishing/src/main/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherFactory.java @@ -21,7 +21,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplate; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.ExtensionContext; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; @@ -49,11 +49,6 @@ public Class extensionClass() { return WebhookNotificationPublisher.class; } - @Override - public int priority() { - return 0; - } - @Override public void init(ExtensionContext ctx) { this.httpClient = HttpClient.newBuilder() @@ -69,10 +64,9 @@ public NotificationPublisher create() { @Override public RuntimeConfigSpec ruleConfigSpec() { - final var defaultConfig = new HttpNotificationRuleConfig() - .withDestinationUrl(URI.create("https://example.com")); - - return RuntimeConfigSpec.of(defaultConfig); + return RuntimeConfigSpec.of( + new HttpNotificationPublisherRuleConfigV1() + .withDestinationUrl(URI.create("https://example.com"))); } @Override diff --git a/notification/publishing/src/main/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin b/notification/publishing/src/main/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin index f68e968b29..9936165a34 100644 --- a/notification/publishing/src/main/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin +++ b/notification/publishing/src/main/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin @@ -1 +1 @@ -org.dependencytrack.notification.publishing.DefaultNotificationPublisherPlugin \ No newline at end of file +org.dependencytrack.notification.publishing.DefaultNotificationPublishersPlugin \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/console/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/console/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/console/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/console/default-template.peb diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/EmailNotificationRuleConfig.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/EmailNotificationRuleConfig.schema.json deleted file mode 100644 index fa7260eaf4..0000000000 --- a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/EmailNotificationRuleConfig.schema.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "javaInterfaces": [ - "org.dependencytrack.plugin.api.config.RuntimeConfig" - ], - "properties": { - "smtp": { - "type": "object", - "title": "SMTP Configuration", - "properties": { - "host": { - "type": "string", - "title": "SMTP Host", - "minLength": 1 - }, - "port": { - "type": "integer", - "title": "SMTP Port", - "minimum": 1 - }, - "username": { - "type": "string", - "title": "SMTP Username" - }, - "password": { - "type": "string", - "title": "SMTP Password" - }, - "sslEnabled": { - "type": "boolean", - "title": "SSL / TLS Enabled" - }, - "startTlsEnabled": { - "type": "boolean", - "title": "STARTTLS Enabled" - } - }, - "required": [ - "host", - "port", - "sslEnabled", - "startTlsEnabled" - ] - }, - "senderAddress": { - "type": "string", - "format": "email", - "title": "Sender Address", - "minLength": 1 - }, - "subjectPrefix": { - "type": "string", - "title": "Subject Prefix" - }, - "recipientAddresses": { - "type": "array", - "items": { - "type": "string", - "format": "email", - "minLength": 1 - }, - "uniqueItems": true - } - }, - "required": [ - "smtp", - "senderAddress" - ] -} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/default-template.peb diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-global-config-v1.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-global-config-v1.schema.json new file mode 100644 index 0000000000..ab5f0cdeff --- /dev/null +++ b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-global-config-v1.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/email-notification-publisher-global-config-v1.schema.json", + "type": "object", + "javaInterfaces": [ + "org.dependencytrack.plugin.api.config.RuntimeConfig" + ], + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether the publisher should be enabled.", + "existingJavaType": "boolean" + }, + "host": { + "type": "string", + "title": "SMTP Host", + "description": "Hostname or IP address of the SMTP server.", + "minLength": 1, + "examples": [ + "mail.example.com" + ] + }, + "port": { + "type": "integer", + "title": "SMTP Port", + "description": "Port of the SMTP server.", + "minimum": 1, + "maximum": 65535, + "examples": [ + 25 + ] + }, + "username": { + "type": "string", + "title": "SMTP Username", + "description": "Username to authenticate against the SMTP server with.", + "examples": [ + "user@example.com" + ] + }, + "password": { + "type": "string", + "title": "SMTP Password", + "description": "Password to authenticate against the SMTP server with.", + "minLength": 1, + "x-secret-ref": true + }, + "sslEnabled": { + "type": "boolean", + "title": "SSL / TLS Enabled", + "description": "Whether to use SSL / TLS when connecting to the SMTP server.", + "existingJavaType": "boolean" + }, + "startTlsEnabled": { + "type": "boolean", + "title": "STARTTLS Enabled", + "description": "Whether to enable [`STARTTLS`](https://en.wikipedia.org/wiki/Opportunistic_TLS).", + "existingJavaType": "boolean" + }, + "senderAddress": { + "type": "string", + "format": "email", + "title": "Sender Address", + "description": "Email address that will appear as sender.", + "minLength": 1, + "examples": [ + "from@example.com" + ] + } + }, + "required": [ + "enabled" + ] +} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-rule-config-v1.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-rule-config-v1.schema.json new file mode 100644 index 0000000000..4f4ef379ed --- /dev/null +++ b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/email/email-notification-publisher-rule-config-v1.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/email-notification-publisher-rule-config-v1.schema.json", + "type": "object", + "javaInterfaces": [ + "org.dependencytrack.plugin.api.config.RuntimeConfig" + ], + "properties": { + "subjectPrefix": { + "type": "string", + "title": "Subject Prefix", + "description": "Optional prefix for the email subject." + }, + "recipientAddresses": { + "type": "array", + "title": "Recipient Addresses", + "description": "Email addresses that should receive notifications.", + "items": { + "type": "string", + "title": "Recipient Address", + "format": "email", + "minLength": 1 + }, + "uniqueItems": true + } + } +} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/http/HttpNotificationRuleConfig.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/http/http-notification-publisher-rule-config-v1.schema.json similarity index 62% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/http/HttpNotificationRuleConfig.schema.json rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/http/http-notification-publisher-rule-config-v1.schema.json index 4545276b88..c3303d5750 100644 --- a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/http/HttpNotificationRuleConfig.schema.json +++ b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/http/http-notification-publisher-rule-config-v1.schema.json @@ -1,5 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/http-notification-publisher-rule-config-v1.schema.json", "type": "object", "javaInterfaces": [ "org.dependencytrack.plugin.api.config.RuntimeConfig" @@ -7,7 +8,8 @@ "properties": { "destinationUrl": { "type": "string", - "description": "Destination URL", + "title": "Destination URL", + "description": "The URL to send the notification to.", "format": "uri", "minLength": 1 } diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/JiraNotificationRuleConfig.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/JiraNotificationRuleConfig.schema.json deleted file mode 100644 index 2d4c24528d..0000000000 --- a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/JiraNotificationRuleConfig.schema.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "javaInterfaces": [ - "org.dependencytrack.plugin.api.config.RuntimeConfig" - ], - "properties": { - "apiUrl": { - "type": "string", - "format": "uri", - "title": "API URL", - "minLength": 1 - }, - "username": { - "type": "string", - "title": "Username", - "minLength": 1 - }, - "passwordOrToken": { - "type": "string", - "title": "Password or Token", - "minLength": 1 - }, - "projectKey": { - "type": "string", - "title": "Project Key", - "minLength": 1 - }, - "ticketType": { - "type": "string", - "title": "Ticket Type", - "minLength": 1 - } - }, - "required": [ - "apiUrl", - "passwordOrToken", - "projectKey", - "ticketType" - ] -} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/default-template.peb diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-global-config-v1.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-global-config-v1.schema.json new file mode 100644 index 0000000000..56f09fe787 --- /dev/null +++ b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-global-config-v1.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/jira-notification-publisher-global-config-v1.schema.json", + "type": "object", + "javaInterfaces": [ + "org.dependencytrack.plugin.api.config.RuntimeConfig" + ], + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether the publisher should be enabled.", + "existingJavaType": "boolean" + }, + "apiUrl": { + "type": "string", + "format": "uri", + "title": "API URL", + "description": "URL of the Jira REST API", + "minLength": 1, + "examples": [ + "https://jira.example.com" + ] + }, + "username": { + "type": "string", + "title": "Username", + "description": "Username to authenticate with. Leave empty to authenticate via access token.", + "minLength": 1 + }, + "passwordOrToken": { + "type": "string", + "title": "Password or Token", + "description": "Password or access token to authenticate with.", + "minLength": 1, + "x-secret-ref": true + } + }, + "required": [ + "enabled" + ] +} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-rule-config-v1.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-rule-config-v1.schema.json new file mode 100644 index 0000000000..81d5e7ab0b --- /dev/null +++ b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/jira/jira-notification-publisher-rule-config-v1.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/jira-notification-publisher-rule-config-v1.schema.json", + "type": "object", + "javaInterfaces": [ + "org.dependencytrack.plugin.api.config.RuntimeConfig" + ], + "properties": { + "projectKey": { + "type": "string", + "title": "Project Key", + "description": "Key of the Jira project to create issues in.", + "minLength": 1, + "examples": [ + "EXAMPLE" + ] + }, + "issueType": { + "type": "string", + "title": "Issue Type", + "description": "Type of issues to create.", + "minLength": 1, + "examples": [ + "Bug" + ] + } + }, + "required": [ + "projectKey", + "issueType" + ] +} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/KafkaNotificationRuleConfig.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/KafkaNotificationRuleConfig.schema.json deleted file mode 100644 index edff985f67..0000000000 --- a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/KafkaNotificationRuleConfig.schema.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "javaInterfaces": [ - "org.dependencytrack.plugin.api.config.RuntimeConfig" - ], - "properties": { - "bootstrapServers": { - "type": "array", - "title": "Kafka Bootstrap Servers", - "description": "Addresses of servers that may be used to establish the initial connection to the Kafka cluster.", - "items": { - "type": "string", - "title": "Kafka Server Address", - "description": "Address of a Kafka server in `host:port` format.", - "minLength": 1, - "examples": [ - "kafka.example.com:9092" - ] - }, - "minLength": 1, - "uniqueItems": true - }, - "topicName": { - "type": "string", - "title": "Kafka Topic Name", - "description": "Name of the Kafka topic to publish to.", - "minLength": 1 - }, - "publishProtobuf": { - "type": "boolean", - "title": "Publish Protobuf", - "description": "Whether to publish notifications in Protobuf format instead of applying a template." - } - }, - "required": [ - "bootstrapServers", - "topicName", - "publishProtobuf" - ] -} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/kafka-notification-publisher-global-config-v1.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/kafka-notification-publisher-global-config-v1.schema.json new file mode 100644 index 0000000000..02100203c5 --- /dev/null +++ b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/kafka-notification-publisher-global-config-v1.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/kafka-notification-publisher-global-config-v1.schema.json", + "type": "object", + "javaInterfaces": [ + "org.dependencytrack.plugin.api.config.RuntimeConfig" + ], + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether the publisher should be enabled.", + "existingJavaType": "boolean" + }, + "bootstrapServers": { + "type": "array", + "title": "Kafka Bootstrap Servers", + "description": "Addresses of servers that may be used to establish the initial connection to the Kafka cluster.", + "items": { + "type": "string", + "title": "Kafka Server Address", + "description": "Address of a Kafka server in `host:port` format.", + "minLength": 1, + "examples": [ + "kafka.example.com:9092" + ] + }, + "uniqueItems": true + }, + "tls": { + "type": "object", + "title": "TLS Configuration", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether to use transport encryption via TLS.", + "existingJavaType": "boolean" + }, + "caCert": { + "type": "string", + "title": "CA Certificate", + "description": "CA certificate chain in PEM format.", + "x-ui-hint": { + "inputType": "textarea" + } + } + } + }, + "mTls": { + "type": "object", + "title": "mTLS Configuration", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether to use mutual TLS for authentication. \nRequires TLS to be configured.", + "existingJavaType": "boolean" + }, + "clientCert": { + "type": "string", + "title": "Client Certificate", + "description": "Client certificate chain in PEM format.", + "x-ui-hint": { + "inputType": "textarea" + } + }, + "clientKey": { + "type": "string", + "title": "Client Key", + "description": "Client certificate key in PEM-encoded [PKCS #8](https://en.wikipedia.org/wiki/PKCS_8) format. \nMust **not** be password-protected.", + "x-secret-ref": true + } + } + } + }, + "required": [ + "enabled" + ] +} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/kafka-notification-publisher-rule-config-v1.schema.json b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/kafka-notification-publisher-rule-config-v1.schema.json new file mode 100644 index 0000000000..f43bc6d679 --- /dev/null +++ b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/kafka/kafka-notification-publisher-rule-config-v1.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/kafka-notification-publisher-rule-config-v1.schema.json", + "type": "object", + "javaInterfaces": [ + "org.dependencytrack.plugin.api.config.RuntimeConfig" + ], + "properties": { + "topicName": { + "type": "string", + "title": "Kafka Topic Name", + "description": "Name of the Kafka topic to publish to.", + "minLength": 1 + }, + "publishProtobuf": { + "type": "boolean", + "title": "Publish Protobuf", + "description": "Whether to publish notifications in Protobuf format instead of applying a template." + } + }, + "required": [ + "topicName" + ] +} \ No newline at end of file diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/mattermost/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/mattermost/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/mattermost/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/mattermost/default-template.peb diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/msteams/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/msteams/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/msteams/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/msteams/default-template.peb diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/slack/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/slack/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/slack/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/slack/default-template.peb diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webex/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webex/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webex/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webex/default-template.peb diff --git a/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webhook/DefaultTemplate.peb b/notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webhook/default-template.peb similarity index 100% rename from notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webhook/DefaultTemplate.peb rename to notification/publishing/src/main/resources/org/dependencytrack/notification/publishing/webhook/default-template.peb diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/AbstractNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/AbstractNotificationPublisherTest.java index 08aa83e07f..672d259834 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/AbstractNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/AbstractNotificationPublisherTest.java @@ -20,28 +20,33 @@ import com.google.protobuf.Timestamp; import com.google.protobuf.util.Timestamps; +import org.dependencytrack.notification.api.TestNotificationFactory; import org.dependencytrack.notification.api.publishing.NotificationPublishContext; import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.api.templating.NotificationTemplateRenderer; +import org.dependencytrack.notification.proto.v1.Group; +import org.dependencytrack.notification.proto.v1.Level; import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.notification.proto.v1.Scope; import org.dependencytrack.notification.templating.pebble.PebbleNotificationTemplateRendererFactory; import org.dependencytrack.plugin.api.ExtensionContext; import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; +import org.dependencytrack.plugin.runtime.config.RuntimeConfigMapper; import org.dependencytrack.plugin.testing.MockConfigRegistry; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import java.util.ArrayList; +import java.util.HashMap; import java.util.Map; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.dependencytrack.notification.api.TestNotificationFactory.createBomConsumedTestNotification; -import static org.dependencytrack.notification.api.TestNotificationFactory.createBomProcessingFailedTestNotification; -import static org.dependencytrack.notification.api.TestNotificationFactory.createBomValidationFailedTestNotification; -import static org.dependencytrack.notification.api.TestNotificationFactory.createNewVulnerabilityTestNotification; -import static org.dependencytrack.notification.api.TestNotificationFactory.createNewVulnerableDependencyTestNotification; public abstract class AbstractNotificationPublisherTest { @@ -54,13 +59,36 @@ public abstract class AbstractNotificationPublisherTest { protected abstract NotificationPublisherFactory createPublisherFactory(); + protected void customizeDeploymentConfig(Map deploymentConfig) { + } + + protected void customizeGlobalConfig(RuntimeConfig globalConfig) { + } + protected void customizeRuleConfig(RuntimeConfig ruleConfig) { } @BeforeEach protected void beforeEach() throws Exception { publisherFactory = createPublisherFactory(); - publisherFactory.init(new ExtensionContext(new MockConfigRegistry())); + + final var deploymentConfig = new HashMap(); + customizeDeploymentConfig(deploymentConfig); + + RuntimeConfig globalConfig = null; + final RuntimeConfigSpec globalConfigSpec = publisherFactory.runtimeConfigSpec(); + if (globalConfigSpec != null) { + globalConfig = globalConfigSpec.defaultConfig(); + customizeGlobalConfig(globalConfig); + } + + final var configRegistry = new MockConfigRegistry( + deploymentConfig, + globalConfigSpec, + RuntimeConfigMapper.getInstance(), + globalConfig); + + publisherFactory.init(new ExtensionContext(configRegistry)); publisher = publisherFactory.create(); final var templateRendererFactory = @@ -91,84 +119,39 @@ protected void afterEach() { } } - protected abstract void validateBomConsumedNotificationPublish(Notification notification) throws Exception; - - @Test - void shouldPublishBomConsumedNotification() throws Exception { - final Notification notification = - createBomConsumedTestNotification().toBuilder() - .setId(NOTIFICATION_ID) - .setTimestamp(NOTIFICATION_TIMESTAMP) - .build(); - + @ParameterizedTest + @MethodSource("testNotificationPublishArguments") + void testNotificationPublish(Notification notification) throws Exception { assertThatNoException() .isThrownBy(() -> publisher.publish(publishContext, notification)); - validateBomConsumedNotificationPublish(notification); + validateNotificationPublish(notification); } - protected abstract void validateBomProcessingFailedNotificationPublish(Notification notification) throws Exception; - - @Test - void shouldPublishBomProcessingFailedNotification() throws Exception { - final Notification notification = - createBomProcessingFailedTestNotification().toBuilder() - .setId(NOTIFICATION_ID) - .setTimestamp(NOTIFICATION_TIMESTAMP) - .build(); - - assertThatNoException() - .isThrownBy(() -> publisher.publish(publishContext, notification)); - - validateBomProcessingFailedNotificationPublish(notification); - } - - protected abstract void validateBomValidationFailedNotificationPublish(Notification notification) throws Exception; - - @Test - void shouldPublishBomValidationFailedNotification() throws Exception { - final Notification notification = - createBomValidationFailedTestNotification().toBuilder() - .setId(NOTIFICATION_ID) - .setTimestamp(NOTIFICATION_TIMESTAMP) - .build(); - - assertThatNoException() - .isThrownBy(() -> publisher.publish(publishContext, notification)); - - validateBomValidationFailedNotificationPublish(notification); - } - - protected abstract void validateNewVulnerabilityNotificationPublish(Notification notification) throws Exception; - - @Test - void shouldPublishNewVulnerabilityNotification() throws Exception { - final Notification notification = - createNewVulnerabilityTestNotification().toBuilder() - .setId(NOTIFICATION_ID) - .setTimestamp(NOTIFICATION_TIMESTAMP) - .build(); - - assertThatNoException() - .isThrownBy(() -> publisher.publish(publishContext, notification)); - - validateNewVulnerabilityNotificationPublish(notification); - } - - protected abstract void validateNewVulnerableDependencyNotificationPublish(Notification notification) throws Exception; + protected abstract void validateNotificationPublish(Notification notification) throws Exception; + + private static Stream testNotificationPublishArguments() { + final var notifications = new ArrayList(); + + for (final var scope : Scope.values()) { + for (final var group : Group.values()) { + for (final var level : Level.values()) { + final Notification notification = + TestNotificationFactory.createTestNotification(scope, group, level); + if (notification != null) { + notifications.add(notification); + } + } + } + } - @Test - void shouldPublishNewVulnerableDependencyNotification() throws Exception { - final Notification notification = - createNewVulnerableDependencyTestNotification().toBuilder() + return notifications.stream() + // Ensure notification data is deterministic. + .map(notification -> notification.toBuilder() .setId(NOTIFICATION_ID) .setTimestamp(NOTIFICATION_TIMESTAMP) - .build(); - - assertThatNoException() - .isThrownBy(() -> publisher.publish(publishContext, notification)); - - validateNewVulnerableDependencyNotificationPublish(notification); + .build()) + .map(Arguments::of); } } diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherTest.java index 58d54d27b7..3b6ef8bfd8 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/console/ConsoleNotificationPublisherTest.java @@ -36,7 +36,17 @@ protected NotificationPublisherFactory createPublisherFactory() { } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { assertThat(outputStream).asString().isEqualTo(""" -------------------------------------------------------------------------------- Notification @@ -49,8 +59,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { assertThat(outputStream).asString().isEqualTo(""" -------------------------------------------------------------------------------- Notification @@ -63,8 +72,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { assertThat(outputStream).asString().isEqualTo(""" -------------------------------------------------------------------------------- Notification @@ -77,8 +85,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { assertThat(outputStream).asString().isEqualTo(""" -------------------------------------------------------------------------------- Notification @@ -91,8 +98,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { assertThat(outputStream).asString().isEqualTo(""" -------------------------------------------------------------------------------- Notification diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherTest.java index c6b935d5a9..7b26cf35e8 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherTest.java @@ -32,6 +32,7 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.util.Map; import java.util.Set; import static com.icegreen.greenmail.configuration.GreenMailConfiguration.aConfig; @@ -49,21 +50,40 @@ protected NotificationPublisherFactory createPublisherFactory() { return new EmailNotificationPublisherFactory(); } + @Override + protected void customizeDeploymentConfig(Map deploymentConfig) { + deploymentConfig.put("allow-local-connections", "true"); + } + + @Override + protected void customizeGlobalConfig(RuntimeConfig globalConfig) { + final var emailGlobalConfig = (EmailNotificationPublisherGlobalConfigV1) globalConfig; + emailGlobalConfig.setEnabled(true); + emailGlobalConfig.setHost(GREEN_MAIL.getSmtp().getBindTo()); + emailGlobalConfig.setPort(GREEN_MAIL.getSmtp().getPort()); + emailGlobalConfig.setUsername("username"); + emailGlobalConfig.setPassword("password"); + emailGlobalConfig.setSenderAddress("dependencytrack@example.com"); + } + @Override protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var emailRuleConfig = (EmailNotificationRuleConfig) ruleConfig; - - emailRuleConfig - .withSmtp(new Smtp() - .withHost(GREEN_MAIL.getSmtp().getBindTo()) - .withPort(GREEN_MAIL.getSmtp().getPort()) - .withUsername("username") - .withPassword("password")) - .withRecipientAddresses(Set.of("username@example.com")); + final var emailRuleConfig = (EmailNotificationPublisherRuleConfigV1) ruleConfig; + emailRuleConfig.setRecipientAddresses(Set.of("username@example.com")); } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { final ReceivedMessage message = getReceivedMessage(); assertThat(message.subject()).isEqualTo("[Dependency-Track] Bill of Materials Consumed"); assertThat(message.content()).isEqualToNormalizingNewlines(""" @@ -86,8 +106,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { final ReceivedMessage message = getReceivedMessage(); assertThat(message.subject()).isEqualTo("[Dependency-Track] Bill of Materials Processing Failed"); assertThat(message.content()).isEqualToNormalizingNewlines(""" @@ -115,8 +134,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { final ReceivedMessage message = getReceivedMessage(); assertThat(message.subject()).isEqualTo("[Dependency-Track] Bill of Materials Validation Failed"); assertThat(message.content()).isEqualToNormalizingNewlines(""" @@ -148,8 +166,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { final ReceivedMessage message = getReceivedMessage(); assertThat(message.subject()).isEqualTo("[Dependency-Track] New Vulnerability Identified on Project: [projectName : projectVersion]"); assertThat(message.content()).isEqualToNormalizingNewlines(""" @@ -181,8 +198,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { final ReceivedMessage message = getReceivedMessage(); assertThat(message.subject()).isEqualTo("[Dependency-Track] Vulnerable Dependency Introduced on Project: [projectName : projectVersion]"); assertThat(message.content()).isEqualToNormalizingNewlines(""" diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherTlsTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherTlsTest.java new file mode 100644 index 0000000000..cc08525498 --- /dev/null +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/email/EmailNotificationPublisherTlsTest.java @@ -0,0 +1,116 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.notification.publishing.email; + +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.DummySSLSocketFactory; +import com.icegreen.greenmail.util.ServerSetup; +import org.dependencytrack.notification.api.TestNotificationFactory; +import org.dependencytrack.notification.api.publishing.NotificationPublishContext; +import org.dependencytrack.notification.api.publishing.NotificationPublisher; +import org.dependencytrack.notification.api.templating.NotificationTemplateRenderer; +import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.notification.templating.pebble.PebbleNotificationTemplateRendererFactory; +import org.dependencytrack.plugin.api.ExtensionContext; +import org.dependencytrack.plugin.runtime.config.RuntimeConfigMapper; +import org.dependencytrack.plugin.testing.MockConfigRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.util.Map; +import java.util.Set; + +import static com.icegreen.greenmail.configuration.GreenMailConfiguration.aConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +class EmailNotificationPublisherTlsTest { + + @RegisterExtension + private static final GreenMailExtension GREEN_MAIL = + new GreenMailExtension(ServerSetup.SMTPS.dynamicPort()) + .withConfiguration(aConfig().withUser("username", "password")); + + private EmailNotificationPublisherFactory publisherFactory; + private NotificationPublisher publisher; + private NotificationPublishContext publishContext; + + @BeforeEach + void beforeEach() { + publisherFactory = + new EmailNotificationPublisherFactory( + Map.of("mail.smtp.ssl.checkserveridentity", "false"), + DummySSLSocketFactory.class); + + final var emailGlobalConfig = (EmailNotificationPublisherGlobalConfigV1) + publisherFactory.runtimeConfigSpec().defaultConfig(); + emailGlobalConfig.setEnabled(true); + emailGlobalConfig.setHost(GREEN_MAIL.getSmtps().getBindTo()); + emailGlobalConfig.setPort(GREEN_MAIL.getSmtps().getPort()); + emailGlobalConfig.setSslEnabled(true); + emailGlobalConfig.setUsername("username"); + emailGlobalConfig.setPassword("password"); + emailGlobalConfig.setSenderAddress("dependencytrack@example.com"); + + final var configRegistry = new MockConfigRegistry( + Map.of("allow-local-connections", "true"), + publisherFactory.runtimeConfigSpec(), + RuntimeConfigMapper.getInstance(), + emailGlobalConfig); + + publisherFactory.init(new ExtensionContext(configRegistry)); + publisher = publisherFactory.create(); + + final var templateRendererFactory = + new PebbleNotificationTemplateRendererFactory( + Map.of("baseUrl", () -> "https://example.com")); + final NotificationTemplateRenderer templateRenderer = + templateRendererFactory.createRenderer( + publisherFactory.defaultTemplate()); + + final var emailRuleConfig = (EmailNotificationPublisherRuleConfigV1) + publisherFactory.ruleConfigSpec().defaultConfig(); + emailRuleConfig.setRecipientAddresses(Set.of("username@example.com")); + + publishContext = new NotificationPublishContext(emailRuleConfig, templateRenderer); + } + + @AfterEach + void afterEach() { + if (publisher != null) { + publisher.close(); + } + if (publisherFactory != null) { + publisherFactory.close(); + } + } + + @Test + void test() { + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + assertThatNoException() + .isThrownBy(() -> publisher.publish(publishContext, notification)); + + assertThat(GREEN_MAIL.getReceivedMessages()).hasSize(1); + } + +} diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherTest.java index c331b7d585..6a093609f7 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/jira/JiraNotificationPublisherTest.java @@ -51,13 +51,19 @@ protected NotificationPublisherFactory createPublisherFactory() { } @Override - protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var jiraRuleConfig = (JiraNotificationRuleConfig) ruleConfig; + protected void customizeGlobalConfig(RuntimeConfig globalConfig) { + final var jiraGlobalConfig = (JiraNotificationPublisherGlobalConfigV1) globalConfig; + jiraGlobalConfig.setEnabled(true); + jiraGlobalConfig.setApiUrl(URI.create(WIREMOCK.baseUrl())); + jiraGlobalConfig.setUsername("username"); + jiraGlobalConfig.setPasswordOrToken("password"); + } - jiraRuleConfig - .withApiUrl(URI.create(WIREMOCK.baseUrl())) - .withUsername("username") - .withPasswordOrToken("password"); + @Override + protected void customizeRuleConfig(RuntimeConfig ruleConfig) { + final var jiraRuleConfig = (JiraNotificationPublisherRuleConfigV1) ruleConfig; + jiraRuleConfig.setProjectKey("EXAMPLE"); + jiraRuleConfig.setIssueType("TASK"); } @BeforeEach @@ -71,7 +77,17 @@ protected void beforeEach() throws Exception { } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/rest/api/2/issue")) .withBasicAuth(new BasicCredentials("username", "password")) .withHeader("Content-Type", equalTo("application/json")) @@ -91,8 +107,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """))); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/rest/api/2/issue")) .withBasicAuth(new BasicCredentials("username", "password")) .withHeader("Content-Type", equalTo("application/json")) @@ -112,8 +127,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/rest/api/2/issue")) .withBasicAuth(new BasicCredentials("username", "password")) .withHeader("Content-Type", equalTo("application/json")) @@ -133,8 +147,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/rest/api/2/issue")) .withBasicAuth(new BasicCredentials("username", "password")) .withHeader("Content-Type", equalTo("application/json")) @@ -154,8 +167,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """))); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/rest/api/2/issue")) .withBasicAuth(new BasicCredentials("username", "password")) .withHeader("Content-Type", equalTo("application/json")) diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherTest.java index f073f2b0b0..b73ddbab64 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/kafka/KafkaNotificationPublisherTest.java @@ -18,10 +18,8 @@ */ package org.dependencytrack.notification.publishing.kafka; -import org.apache.kafka.clients.CommonClientConfigs; import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.NewTopic; -import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.KafkaConsumer; @@ -29,9 +27,12 @@ import org.apache.kafka.common.serialization.ByteArrayDeserializer; import org.apache.kafka.common.serialization.StringDeserializer; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; +import org.dependencytrack.notification.proto.v1.Group; import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.notification.proto.v1.Scope; import org.dependencytrack.notification.publishing.AbstractNotificationPublisherTest; import org.dependencytrack.plugin.api.config.RuntimeConfig; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -41,14 +42,20 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; +import static org.apache.kafka.clients.CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG; +import static org.apache.kafka.clients.consumer.ConsumerConfig.AUTO_OFFSET_RESET_CONFIG; +import static org.apache.kafka.clients.consumer.ConsumerConfig.GROUP_ID_CONFIG; +import static org.apache.kafka.clients.consumer.ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG; +import static org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG; import static org.assertj.core.api.Assertions.assertThat; @Testcontainers class KafkaNotificationPublisherTest extends AbstractNotificationPublisherTest { @Container - private final KafkaContainer kafkaContainer = new KafkaContainer("apache/kafka-native:4.1.1"); + private static final KafkaContainer kafkaContainer = new KafkaContainer("apache/kafka:4.1.1"); @Override protected NotificationPublisherFactory createPublisherFactory() { @@ -56,69 +63,62 @@ protected NotificationPublisherFactory createPublisherFactory() { } @Override - protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var kafkaRuleConfig = (KafkaNotificationRuleConfig) ruleConfig; - kafkaRuleConfig.setBootstrapServers(Set.of(kafkaContainer.getBootstrapServers())); + protected void customizeDeploymentConfig(Map deploymentConfig) { + deploymentConfig.put("allow-local-connections", "true"); + } + + @Override + protected void customizeGlobalConfig(RuntimeConfig globalConfig) { + final var kafkaGlobalConfig = (KafkaNotificationPublisherGlobalConfigV1) globalConfig; + kafkaGlobalConfig.setEnabled(true); + kafkaGlobalConfig.setBootstrapServers(Set.of(kafkaContainer.getBootstrapServers())); } @BeforeEach @Override protected void beforeEach() throws Exception { try (final var adminClient = AdminClient.create(Map.of( - CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, kafkaContainer.getBootstrapServers()))) { + BOOTSTRAP_SERVERS_CONFIG, kafkaContainer.getBootstrapServers()))) { adminClient.createTopics(List.of(new NewTopic("dependencytrack-notifications", 1, (short) 1))).all().get(); } super.beforeEach(); } - @Override - protected void validateBomConsumedNotificationPublish(Notification notification) throws Exception { - final ConsumerRecord record = pollNotificationRecord(); - assertThat(record.key()).isEqualTo("TODO"); - assertThat(record.headers()).containsExactly(new RecordHeader("content-type", "application/protobuf".getBytes())); - assertThat(Notification.parseFrom(record.value())).isEqualTo(notification); - } - - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification notification) throws Exception { - final ConsumerRecord record = pollNotificationRecord(); - assertThat(record.key()).isEqualTo("TODO"); - assertThat(record.headers()).containsExactly(new RecordHeader("content-type", "application/protobuf".getBytes())); - assertThat(Notification.parseFrom(record.value())).isEqualTo(notification); - } - - @Override - protected void validateBomValidationFailedNotificationPublish(Notification notification) throws Exception { - final ConsumerRecord record = pollNotificationRecord(); - assertThat(record.key()).isEqualTo("TODO"); - assertThat(record.headers()).containsExactly(new RecordHeader("content-type", "application/protobuf".getBytes())); - assertThat(Notification.parseFrom(record.value())).isEqualTo(notification); - } + @AfterEach + protected void afterEach() { + try (final var adminClient = AdminClient.create(Map.of( + BOOTSTRAP_SERVERS_CONFIG, kafkaContainer.getBootstrapServers()))) { + adminClient.deleteTopics(List.of("dependencytrack-notifications")); + } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification notification) throws Exception { - final ConsumerRecord record = pollNotificationRecord(); - assertThat(record.key()).isEqualTo("TODO"); - assertThat(record.headers()).containsExactly(new RecordHeader("content-type", "application/protobuf".getBytes())); - assertThat(Notification.parseFrom(record.value())).isEqualTo(notification); + super.afterEach(); } @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification notification) throws Exception { + protected void validateNotificationPublish(Notification notification) throws Exception { final ConsumerRecord record = pollNotificationRecord(); - assertThat(record.key()).isEqualTo("TODO"); + if (notification.getScope() == Scope.SCOPE_PORTFOLIO) { + assertThat(record.key()).isEqualTo("c9c9539a-e381-4b36-ac52-6a7ab83b2c95"); + } else { + if (notification.getGroup() == Group.GROUP_USER_CREATED + || notification.getGroup() == Group.GROUP_USER_DELETED) { + assertThat(record.key()).isEqualTo("username"); + } else { + assertThat(record.key()).isNull(); + } + } assertThat(record.headers()).containsExactly(new RecordHeader("content-type", "application/protobuf".getBytes())); assertThat(Notification.parseFrom(record.value())).isEqualTo(notification); } private ConsumerRecord pollNotificationRecord() { try (final var consumer = new KafkaConsumer(Map.ofEntries( - Map.entry(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, kafkaContainer.getBootstrapServers()), - Map.entry(ConsumerConfig.GROUP_ID_CONFIG, "test-group"), - Map.entry(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"), - Map.entry(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()), - Map.entry(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class.getName())))) { + Map.entry(BOOTSTRAP_SERVERS_CONFIG, kafkaContainer.getBootstrapServers()), + Map.entry(GROUP_ID_CONFIG, UUID.randomUUID().toString()), + Map.entry(AUTO_OFFSET_RESET_CONFIG, "earliest"), + Map.entry(KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()), + Map.entry(VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class.getName())))) { consumer.subscribe(List.of("dependencytrack-notifications")); final ConsumerRecords records = consumer.poll(Duration.ofSeconds(1)); diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherTest.java index b91c9f1961..3d90231e3a 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/mattermost/MattermostNotificationPublisherTest.java @@ -23,7 +23,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.notification.publishing.AbstractNotificationPublisherTest; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -52,7 +52,7 @@ protected NotificationPublisherFactory createPublisherFactory() { @Override protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var httpRuleConfig = (HttpNotificationRuleConfig) ruleConfig; + final var httpRuleConfig = (HttpNotificationPublisherRuleConfigV1) ruleConfig; httpRuleConfig.setDestinationUrl(URI.create(WIREMOCK.baseUrl())); } @@ -67,7 +67,17 @@ protected void beforeEach() throws Exception { } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -79,8 +89,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """))); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -92,8 +101,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -105,8 +113,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -118,8 +125,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """))); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherTest.java index 96dcee6855..0faa5aafad 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/msteams/MsTeamsNotificationPublisherTest.java @@ -23,7 +23,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.notification.publishing.AbstractNotificationPublisherTest; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -52,7 +52,7 @@ protected NotificationPublisherFactory createPublisherFactory() { @Override protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var httpRuleConfig = (HttpNotificationRuleConfig) ruleConfig; + final var httpRuleConfig = (HttpNotificationPublisherRuleConfigV1) ruleConfig; httpRuleConfig.setDestinationUrl(URI.create(WIREMOCK.baseUrl())); } @@ -67,7 +67,17 @@ protected void beforeEach() throws Exception { } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -102,8 +112,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """))); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -146,8 +155,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -182,8 +190,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -222,8 +229,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """))); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherTest.java index 6c86f3cab2..f517e141ee 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/slack/SlackNotificationPublisherTest.java @@ -23,7 +23,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.notification.publishing.AbstractNotificationPublisherTest; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -52,7 +52,7 @@ protected NotificationPublisherFactory createPublisherFactory() { @Override protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var httpRuleConfig = (HttpNotificationRuleConfig) ruleConfig; + final var httpRuleConfig = (HttpNotificationPublisherRuleConfigV1) ruleConfig; httpRuleConfig.setDestinationUrl(URI.create(WIREMOCK.baseUrl())); } @@ -67,7 +67,17 @@ protected void beforeEach() throws Exception { } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -111,8 +121,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """))); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -156,8 +165,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -201,8 +209,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -296,8 +303,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """))); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherTest.java index a9d4a597bd..329387d23c 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webex/WebexNotificationPublisherTest.java @@ -23,7 +23,7 @@ import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.notification.publishing.AbstractNotificationPublisherTest; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -52,7 +52,7 @@ protected NotificationPublisherFactory createPublisherFactory() { @Override protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var httpRuleConfig = (HttpNotificationRuleConfig) ruleConfig; + final var httpRuleConfig = (HttpNotificationPublisherRuleConfigV1) ruleConfig; httpRuleConfig.setDestinationUrl(URI.create(WIREMOCK.baseUrl())); } @@ -67,7 +67,17 @@ protected void beforeEach() throws Exception { } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -77,8 +87,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """))); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -88,8 +97,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -99,8 +107,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -110,8 +117,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """))); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { WIREMOCK.verify(postRequestedFor(urlPathEqualTo("/")) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ diff --git a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherTest.java b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherTest.java index b678a38501..c2b93c94bf 100644 --- a/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherTest.java +++ b/notification/publishing/src/test/java/org/dependencytrack/notification/publishing/webhook/WebhookNotificationPublisherTest.java @@ -25,7 +25,7 @@ import org.dependencytrack.notification.api.publishing.RetryablePublishException; import org.dependencytrack.notification.proto.v1.Notification; import org.dependencytrack.notification.publishing.AbstractNotificationPublisherTest; -import org.dependencytrack.notification.publishing.http.HttpNotificationRuleConfig; +import org.dependencytrack.notification.publishing.http.HttpNotificationPublisherRuleConfigV1; import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -57,7 +57,7 @@ protected NotificationPublisherFactory createPublisherFactory() { @Override protected void customizeRuleConfig(RuntimeConfig ruleConfig) { - final var httpRuleConfig = (HttpNotificationRuleConfig) ruleConfig; + final var httpRuleConfig = (HttpNotificationPublisherRuleConfigV1) ruleConfig; httpRuleConfig.setDestinationUrl(URI.create(WIREMOCK.baseUrl())); } @@ -72,7 +72,17 @@ protected void beforeEach() throws Exception { } @Override - protected void validateBomConsumedNotificationPublish(Notification ignored) { + protected void validateNotificationPublish(Notification notification) { + switch (notification.getGroup()) { + case GROUP_BOM_CONSUMED -> validateBomConsumedNotificationPublish(); + case GROUP_BOM_PROCESSING_FAILED -> validateBomProcessingFailedNotificationPublish(); + case GROUP_BOM_VALIDATION_FAILED -> validateBomValidationFailedNotificationPublish(); + case GROUP_NEW_VULNERABILITY -> validateNewVulnerabilityNotificationPublish(); + case GROUP_NEW_VULNERABLE_DEPENDENCY -> validateNewVulnerableDependencyNotificationPublish(); + } + } + + private void validateBomConsumedNotificationPublish() { WIREMOCK.verify(postRequestedFor(anyUrl()) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -110,8 +120,7 @@ protected void validateBomConsumedNotificationPublish(Notification ignored) { """))); } - @Override - protected void validateBomProcessingFailedNotificationPublish(Notification ignored) { + private void validateBomProcessingFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(anyUrl()) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -150,8 +159,7 @@ protected void validateBomProcessingFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateBomValidationFailedNotificationPublish(Notification ignored) { + private void validateBomValidationFailedNotificationPublish() { WIREMOCK.verify(postRequestedFor(anyUrl()) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -190,8 +198,7 @@ protected void validateBomValidationFailedNotificationPublish(Notification ignor """))); } - @Override - protected void validateNewVulnerabilityNotificationPublish(Notification ignored) { + private void validateNewVulnerabilityNotificationPublish() { WIREMOCK.verify(postRequestedFor(anyUrl()) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ @@ -278,8 +285,7 @@ protected void validateNewVulnerabilityNotificationPublish(Notification ignored) """))); } - @Override - protected void validateNewVulnerableDependencyNotificationPublish(Notification ignored) { + private void validateNewVulnerableDependencyNotificationPublish() { WIREMOCK.verify(postRequestedFor(anyUrl()) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(/* language=JSON */ """ diff --git a/plugin/api/src/main/java/org/dependencytrack/plugin/api/config/RuntimeConfigSpec.java b/plugin/api/src/main/java/org/dependencytrack/plugin/api/config/RuntimeConfigSpec.java index 493539074d..4a48b2c995 100644 --- a/plugin/api/src/main/java/org/dependencytrack/plugin/api/config/RuntimeConfigSpec.java +++ b/plugin/api/src/main/java/org/dependencytrack/plugin/api/config/RuntimeConfigSpec.java @@ -83,8 +83,12 @@ private static String loadSchema( if (schemaSource != null) { schema = schemaSource.getSchema(configClass); } else { - final String schemaResourcePath = "%s.schema.json".formatted( - configClass.getName().replaceAll("\\.", "/")); + final String configClassNameKebab = configClass.getSimpleName() + .replaceAll("([a-z])([A-Z])", "$1-$2") + .toLowerCase(); + + final String schemaResourcePath = "%s/%s.schema.json".formatted( + configClass.getPackageName().replaceAll("\\.", "/"), configClassNameKebab); schema = new RuntimeConfigSchemaSource.Resource(schemaResourcePath).getSchema(configClass); } diff --git a/plugin/runtime/src/main/java/org/dependencytrack/plugin/runtime/config/RuntimeConfigMapper.java b/plugin/runtime/src/main/java/org/dependencytrack/plugin/runtime/config/RuntimeConfigMapper.java index 299f7a29c2..8207e58bb7 100644 --- a/plugin/runtime/src/main/java/org/dependencytrack/plugin/runtime/config/RuntimeConfigMapper.java +++ b/plugin/runtime/src/main/java/org/dependencytrack/plugin/runtime/config/RuntimeConfigMapper.java @@ -66,8 +66,12 @@ */ public final class RuntimeConfigMapper { + private static class CustomAnnotations { + private static final String SECRET_REF = "x-secret-ref"; + private static final String UI_HINT = "x-ui-hint"; + } + private static final RuntimeConfigMapper INSTANCE = new RuntimeConfigMapper(); - private static final String SECRET_REF_ANNOTATION = "x-secret-ref"; private final ObjectMapper jsonMapper; private final JsonSchemaFactory schemaFactory; @@ -90,7 +94,9 @@ public final class RuntimeConfigMapper { new NonValidationKeyword("javaName"), new NonValidationKeyword("javaType"))) // Don't emit warning when encountering custom annotations. - .keyword(new NonValidationKeyword(SECRET_REF_ANNOTATION)) + .keywords(List.of( + new NonValidationKeyword(CustomAnnotations.SECRET_REF), + new NonValidationKeyword(CustomAnnotations.UI_HINT))) .build(); this.schemaFactory = JsonSchemaFactory .builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)) @@ -127,9 +133,7 @@ public String serialize(RuntimeConfig config) { requireNonNull(config, "config must not be null"); try { - return jsonMapper - .writerWithDefaultPrettyPrinter() - .writeValueAsString(config); + return jsonMapper.writeValueAsString(config); } catch (IOException e) { throw new UncheckedIOException(e); } @@ -231,6 +235,10 @@ private RuntimeConfigSchema getSchema(RuntimeConfigSpec runtimeConfigSpec) { } final JsonSchema jsonSchema = schemaFactory.getSchema(schemaNode); + if (jsonSchema.getId() == null || jsonSchema.getId().isBlank()) { + throw new IllegalStateException("Schema does not define an ID"); + } + final Set secretRefPaths = getSecretRefPaths(schemaNode, null); return new RuntimeConfigSchema(jsonSchema, secretRefPaths); @@ -274,15 +282,15 @@ private Set getSecretRefPaths(JsonNode schemaNode, @Nullable String curr } private boolean hasSecretRef(JsonNode propertyNode, String path) { - if (!propertyNode.has(SECRET_REF_ANNOTATION)) { + if (!propertyNode.has(CustomAnnotations.SECRET_REF)) { return false; } - final JsonNode secretRefNode = propertyNode.get(SECRET_REF_ANNOTATION); + final JsonNode secretRefNode = propertyNode.get(CustomAnnotations.SECRET_REF); if (!secretRefNode.isBoolean()) { throw new IllegalStateException( "Invalid %s node type at %s: Expected %s but was %s".formatted( - SECRET_REF_ANNOTATION, path, JsonNodeType.BOOLEAN, secretRefNode.getNodeType())); + CustomAnnotations.SECRET_REF, path, JsonNodeType.BOOLEAN, secretRefNode.getNodeType())); } return secretRefNode.asBoolean(); diff --git a/plugin/runtime/src/test/resources/org/dependencytrack/plugin/runtime/config/TestRuntimeConfig.schema.json b/plugin/runtime/src/test/resources/org/dependencytrack/plugin/runtime/config/test-runtime-config.schema.json similarity index 95% rename from plugin/runtime/src/test/resources/org/dependencytrack/plugin/runtime/config/TestRuntimeConfig.schema.json rename to plugin/runtime/src/test/resources/org/dependencytrack/plugin/runtime/config/test-runtime-config.schema.json index 47b070f241..0abf33d206 100644 --- a/plugin/runtime/src/test/resources/org/dependencytrack/plugin/runtime/config/TestRuntimeConfig.schema.json +++ b/plugin/runtime/src/test/resources/org/dependencytrack/plugin/runtime/config/test-runtime-config.schema.json @@ -1,5 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema/test", "type": "object", "title": "Test Runtime Config", "javaInterfaces" : ["org.dependencytrack.plugin.api.config.RuntimeConfig"], diff --git a/plugin/testing/src/main/java/org/dependencytrack/plugin/testing/MockConfigRegistry.java b/plugin/testing/src/main/java/org/dependencytrack/plugin/testing/MockConfigRegistry.java index 067cfc1049..1eaf4c2353 100644 --- a/plugin/testing/src/main/java/org/dependencytrack/plugin/testing/MockConfigRegistry.java +++ b/plugin/testing/src/main/java/org/dependencytrack/plugin/testing/MockConfigRegistry.java @@ -65,7 +65,9 @@ public MockConfigRegistry(Map deploymentConfigs) { this(deploymentConfigs, null, null, null); } - public MockConfigRegistry(RuntimeConfigSpec runtimeConfigSpec, RuntimeConfig runtimeConfig) { + public MockConfigRegistry( + @Nullable RuntimeConfigSpec runtimeConfigSpec, + @Nullable RuntimeConfig runtimeConfig) { this( Collections.emptyMap(), runtimeConfigSpec, diff --git a/proto/pom.xml b/proto/pom.xml index c27afce4ab..6eb487c459 100644 --- a/proto/pom.xml +++ b/proto/pom.xml @@ -23,6 +23,16 @@ cyclonedx-proto ${project.version} + + org.dependencytrack + file-storage-api + ${project.version} + + + org.dependencytrack + notification-api + ${project.version} + com.google.protobuf diff --git a/apiserver/src/main/java/org/dependencytrack/notification/publisher/PublisherClass.java b/proto/src/main/proto/org/dependencytrack/internal/workflow/v1/argument_common.proto similarity index 59% rename from apiserver/src/main/java/org/dependencytrack/notification/publisher/PublisherClass.java rename to proto/src/main/proto/org/dependencytrack/internal/workflow/v1/argument_common.proto index f3e87e00a9..28b0ab8b93 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/publisher/PublisherClass.java +++ b/proto/src/main/proto/org/dependencytrack/internal/workflow/v1/argument_common.proto @@ -16,22 +16,17 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.notification.publisher; +syntax = "proto3"; -import org.dependencytrack.notification.api.publishing.NotificationPublisher; +package org.dependencytrack.internal.workflow.v1; -/** - * @deprecated To be removed in favour of dynamically discovered {@link NotificationPublisher}s. - */ -@Deprecated(forRemoval = true, since = "5.7.0") -public enum PublisherClass { +import "org/dependencytrack/filestorage/v1/filestorage.proto"; + +option java_multiple_files = true; +option java_package = "org.dependencytrack.proto.internal.workflow.v1"; - SlackPublisher, - MsTeamsPublisher, - MattermostPublisher, - SendMailPublisher, - ConsolePublisher, - WebhookPublisher, - CsWebexPublisher, - JiraPublisher; -} +// Argument of the delete-files activity. +message DeleteFilesArgument { + // Metadata of files to delete. + repeated org.dependencytrack.filestorage.v1.FileMetadata file_metadata = 1; +} \ No newline at end of file diff --git a/proto/src/main/proto/org/dependencytrack/internal/workflow/v1/argument_notification.proto b/proto/src/main/proto/org/dependencytrack/internal/workflow/v1/argument_notification.proto new file mode 100644 index 0000000000..16fbe25426 --- /dev/null +++ b/proto/src/main/proto/org/dependencytrack/internal/workflow/v1/argument_notification.proto @@ -0,0 +1,63 @@ +/* + * 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) OWASP Foundation. All Rights Reserved. + */ +syntax = "proto3"; + +package org.dependencytrack.internal.workflow.v1; + +import "org/dependencytrack/filestorage/v1/filestorage.proto"; +import "org/dependencytrack/notification/v1/notification.proto"; + +option java_multiple_files = true; +option java_package = "org.dependencytrack.proto.internal.workflow.v1"; + +// Argument of the publish-notification workflow. +message PublishNotificationWorkflowArg { + // ID of the notification. + string notification_id = 1; + + // The notification payload. + oneof payload { + // The notification itself. Used for small notifications. + org.dependencytrack.notification.v1.Notification notification = 2; + + // File metadata of the notification. Used for large notifications. + org.dependencytrack.filestorage.v1.FileMetadata notification_file_metadata = 3; + } + + // Names of notification rules for which the notification shall be published. + repeated string notification_rule_names = 4; +} + +// Argument of the publish-notification activity. +message PublishNotificationActivityArg { + // ID of the notification. + string notification_id = 1; + + // Name of the notification rule. + string notification_rule_name = 2; + + // The notification payload. + oneof payload { + // The notification itself. Used for small notifications. + org.dependencytrack.notification.v1.Notification notification = 3; + + // File metadata of the notification. Used for large notifications. + org.dependencytrack.filestorage.v1.FileMetadata notification_file_metadata = 4; + } +} \ No newline at end of file diff --git a/vuln-data-source/github/src/main/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactory.java b/vuln-data-source/github/src/main/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactory.java index 6125bfba7d..7bf3f292d6 100644 --- a/vuln-data-source/github/src/main/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactory.java +++ b/vuln-data-source/github/src/main/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactory.java @@ -73,12 +73,12 @@ public void init(final ExtensionContext ctx) { @Override public boolean isDataSourceEnabled() { - return configRegistry.getRuntimeConfig(GitHubVulnDataSourceConfig.class).isEnabled(); + return configRegistry.getRuntimeConfig(GithubVulnDataSourceConfigV1.class).isEnabled(); } @Override public VulnDataSource create() { - final var config = configRegistry.getRuntimeConfig(GitHubVulnDataSourceConfig.class); + final var config = configRegistry.getRuntimeConfig(GithubVulnDataSourceConfigV1.class); if (!config.isEnabled()) { throw new IllegalStateException("Vulnerability data source is disabled and cannot be created"); } @@ -100,7 +100,7 @@ public VulnDataSource create() { @Override public RuntimeConfigSpec runtimeConfigSpec() { - final var defaultConfig = new GitHubVulnDataSourceConfig() + final var defaultConfig = new GithubVulnDataSourceConfigV1() .withEnabled(false) .withAliasSyncEnabled(true) .withApiUrl(URI.create("https://api.github.com/graphql")); diff --git a/vuln-data-source/github/src/main/resources/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceConfig.schema.json b/vuln-data-source/github/src/main/resources/org/dependencytrack/vulndatasource/github/github-vuln-data-source-config-v1.schema.json similarity index 92% rename from vuln-data-source/github/src/main/resources/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceConfig.schema.json rename to vuln-data-source/github/src/main/resources/org/dependencytrack/vulndatasource/github/github-vuln-data-source-config-v1.schema.json index 0eea5a9034..a97bd37e5a 100644 --- a/vuln-data-source/github/src/main/resources/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceConfig.schema.json +++ b/vuln-data-source/github/src/main/resources/org/dependencytrack/vulndatasource/github/github-vuln-data-source-config-v1.schema.json @@ -1,5 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/github-vuln-data-source-config-v1.schema.json", "type": "object", "javaInterfaces": [ "org.dependencytrack.plugin.api.config.RuntimeConfig" diff --git a/vuln-data-source/github/src/test/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactoryTest.java b/vuln-data-source/github/src/test/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactoryTest.java index ed39425084..6190b2d9ae 100644 --- a/vuln-data-source/github/src/test/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactoryTest.java +++ b/vuln-data-source/github/src/test/java/org/dependencytrack/vulndatasource/github/GitHubVulnDataSourceFactoryTest.java @@ -49,7 +49,7 @@ void extensionClassShouldBeGitHubVulnDataSource() { @ParameterizedTest @ValueSource(booleans = {true, false}) void isDataSourceEnabledShouldReturnTrueWhenEnabledAndFalseOtherwise(final boolean isEnabled) { - final var config = (GitHubVulnDataSourceConfig) factory.runtimeConfigSpec().defaultConfig(); + final var config = (GithubVulnDataSourceConfigV1) factory.runtimeConfigSpec().defaultConfig(); config.setEnabled(isEnabled); config.setApiToken("dummy"); @@ -61,7 +61,7 @@ void isDataSourceEnabledShouldReturnTrueWhenEnabledAndFalseOtherwise(final boole @Test void createShouldThrowWhenDisabled() { - final var config = (GitHubVulnDataSourceConfig) factory.runtimeConfigSpec().defaultConfig(); + final var config = (GithubVulnDataSourceConfigV1) factory.runtimeConfigSpec().defaultConfig(); config.setEnabled(false); final var configRegistry = new MockConfigRegistry(factory.runtimeConfigSpec(), config); @@ -74,7 +74,7 @@ void createShouldThrowWhenDisabled() { @Test void createShouldReturnDataSource() { - final var config = (GitHubVulnDataSourceConfig) factory.runtimeConfigSpec().defaultConfig(); + final var config = (GithubVulnDataSourceConfigV1) factory.runtimeConfigSpec().defaultConfig(); config.setEnabled(true); config.setApiToken("dummy"); diff --git a/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java b/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java index 32deb5f0c2..904005b031 100644 --- a/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java +++ b/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java @@ -91,7 +91,7 @@ public void init(final ExtensionContext ctx) { @Override public RuntimeConfigSpec runtimeConfigSpec() { - final var defaultConfig = new NvdVulnDataSourceConfig() + final var defaultConfig = new NvdVulnDataSourceConfigV1() .withEnabled(true) .withCveFeedsUrl(URI.create("https://nvd.nist.gov/feeds")); @@ -108,7 +108,7 @@ public RuntimeConfigSpec runtimeConfigSpec() { @Override public boolean isDataSourceEnabled() { requireNonNull(configRegistry, "configRegistry must not be null"); - return configRegistry.getRuntimeConfig(NvdVulnDataSourceConfig.class).isEnabled(); + return configRegistry.getRuntimeConfig(NvdVulnDataSourceConfigV1.class).isEnabled(); } @Override @@ -116,7 +116,7 @@ public VulnDataSource create() { requireNonNull(configRegistry, "configRegistry must not be null"); requireNonNull(kvStore, "kvStore must not be null"); - final var config = configRegistry.getRuntimeConfig(NvdVulnDataSourceConfig.class); + final var config = configRegistry.getRuntimeConfig(NvdVulnDataSourceConfigV1.class); if (!config.isEnabled()) { throw new IllegalStateException("Vulnerability data source is disabled and cannot be created"); } @@ -132,7 +132,7 @@ public ExtensionTestResult test(@Nullable RuntimeConfig runtimeConfig) { requireNonNull(httpClient, "httpClient has not been initialized"); requireNonNull(runtimeConfig, "runtimeConfig must not be null"); - final var nvdConfig = (NvdVulnDataSourceConfig) runtimeConfig; + final var nvdConfig = (NvdVulnDataSourceConfigV1) runtimeConfig; final var testResult = ExtensionTestResult.ofChecks("connection", "feed_format"); diff --git a/vuln-data-source/nvd/src/main/resources/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceConfig.schema.json b/vuln-data-source/nvd/src/main/resources/org/dependencytrack/vulndatasource/nvd/nvd-vuln-data-source-config-v1.schema.json similarity index 88% rename from vuln-data-source/nvd/src/main/resources/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceConfig.schema.json rename to vuln-data-source/nvd/src/main/resources/org/dependencytrack/vulndatasource/nvd/nvd-vuln-data-source-config-v1.schema.json index 29514fae47..c75fd45abb 100644 --- a/vuln-data-source/nvd/src/main/resources/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceConfig.schema.json +++ b/vuln-data-source/nvd/src/main/resources/org/dependencytrack/vulndatasource/nvd/nvd-vuln-data-source-config-v1.schema.json @@ -1,5 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/nvd-vuln-data-source-config-v1.schema.json", "type": "object", "javaInterfaces": [ "org.dependencytrack.plugin.api.config.RuntimeConfig" diff --git a/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java b/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java index 32623d9fe6..983bba221e 100644 --- a/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java +++ b/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java @@ -67,7 +67,7 @@ void shouldPassConnectivityAndFeedFormatCheck(WireMockRuntimeInfo wmRuntimeInfo) new MockConfigRegistry( Map.of("allow-local-connections", "true")))); - final var runtimeConfig = new NvdVulnDataSourceConfig() + final var runtimeConfig = new NvdVulnDataSourceConfigV1() .withEnabled(true) .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); @@ -98,7 +98,7 @@ void shouldReportConnectionFailure(WireMockRuntimeInfo wmRuntimeInfo) { new MockConfigRegistry( Map.of("allow-local-connections", "true")))); - final var runtimeConfig = new NvdVulnDataSourceConfig() + final var runtimeConfig = new NvdVulnDataSourceConfigV1() .withEnabled(true) .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); @@ -125,7 +125,7 @@ void shouldReportConnectionFailureWhenLocalConnectionsAreDisallowed(WireMockRunt new MockConfigRegistry( Map.of("allow-local-connections", "false")))); - final var runtimeConfig = new NvdVulnDataSourceConfig() + final var runtimeConfig = new NvdVulnDataSourceConfigV1() .withEnabled(true) .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); @@ -156,7 +156,7 @@ void shouldReportInvalidFeedFormatFailure(WireMockRuntimeInfo wmRuntimeInfo) { new MockConfigRegistry( Map.of("allow-local-connections", "true")))); - final var runtimeConfig = new NvdVulnDataSourceConfig() + final var runtimeConfig = new NvdVulnDataSourceConfigV1() .withEnabled(true) .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); @@ -183,7 +183,7 @@ void shouldReportAllChecksSkippedWhenDisabled(WireMockRuntimeInfo wmRuntimeInfo) new MockConfigRegistry( Map.of("allow-local-connections", "true")))); - final var runtimeConfig = new NvdVulnDataSourceConfig() + final var runtimeConfig = new NvdVulnDataSourceConfigV1() .withEnabled(false) .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); diff --git a/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceTest.java b/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceTest.java index fbe9295c0e..343ca9c664 100644 --- a/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceTest.java +++ b/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceTest.java @@ -40,7 +40,7 @@ class NvdVulnDataSourceTest { @BeforeEach void beforeEach() { - final var config = new NvdVulnDataSourceConfig(); + final var config = new NvdVulnDataSourceConfigV1(); config.setEnabled(true); config.setCveFeedsUrl(URI.create("https://nvd.nist.gov/feeds")); diff --git a/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactory.java b/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactory.java index 0df1d2a927..2d9fe39101 100644 --- a/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactory.java +++ b/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactory.java @@ -27,8 +27,6 @@ import org.dependencytrack.plugin.api.storage.ExtensionKVStore; import org.dependencytrack.vulndatasource.api.VulnDataSource; import org.dependencytrack.vulndatasource.api.VulnDataSourceFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.net.URI; import java.net.http.HttpClient; @@ -39,8 +37,6 @@ */ final class OsvVulnDataSourceFactory implements VulnDataSourceFactory { - private static final Logger LOGGER = LoggerFactory.getLogger(OsvVulnDataSourceFactory.class); - private ConfigRegistry configRegistry; private ExtensionKVStore kvStore; private ObjectMapper objectMapper; @@ -74,7 +70,7 @@ public void init(ExtensionContext ctx) { @Override public RuntimeConfigSpec runtimeConfigSpec() { - final var defaultConfig = new OsvVulnDataSourceConfig() + final var defaultConfig = new OsvVulnDataSourceConfigV1() .withEnabled(false) .withAliasSyncEnabled(false) .withDataUrl(URI.create("https://storage.googleapis.com/osv-vulnerabilities")) @@ -95,12 +91,12 @@ public RuntimeConfigSpec runtimeConfigSpec() { @Override public boolean isDataSourceEnabled() { - return configRegistry.getRuntimeConfig(OsvVulnDataSourceConfig.class).isEnabled(); + return configRegistry.getRuntimeConfig(OsvVulnDataSourceConfigV1.class).isEnabled(); } @Override public VulnDataSource create() { - final var config = configRegistry.getRuntimeConfig(OsvVulnDataSourceConfig.class); + final var config = configRegistry.getRuntimeConfig(OsvVulnDataSourceConfigV1.class); if (!config.isEnabled()) { throw new IllegalStateException("Vulnerability data source is disabled and cannot be created"); } diff --git a/vuln-data-source/osv/src/main/resources/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceConfig.schema.json b/vuln-data-source/osv/src/main/resources/org/dependencytrack/vulndatasource/osv/osv-vuln-data-source-config-v1.schema.json similarity index 93% rename from vuln-data-source/osv/src/main/resources/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceConfig.schema.json rename to vuln-data-source/osv/src/main/resources/org/dependencytrack/vulndatasource/osv/osv-vuln-data-source-config-v1.schema.json index a1b5c49929..3b1a3c53c0 100644 --- a/vuln-data-source/osv/src/main/resources/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceConfig.schema.json +++ b/vuln-data-source/osv/src/main/resources/org/dependencytrack/vulndatasource/osv/osv-vuln-data-source-config-v1.schema.json @@ -1,5 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dependencytrack.org/schemas/osv-vuln-data-source-config-v1.schema.json", "type": "object", "javaInterfaces": [ "org.dependencytrack.plugin.api.config.RuntimeConfig" diff --git a/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactoryTest.java b/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactoryTest.java index ce96719879..59ccceec4c 100644 --- a/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactoryTest.java +++ b/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvVulnDataSourceFactoryTest.java @@ -54,7 +54,7 @@ void priorityShouldBeZero() { @ParameterizedTest @ValueSource(booleans = {true, false}) void isDataSourceEnabledShouldReturnTrueWhenEnabledAndFalseOtherwise(final boolean isEnabled) { - final var config = (OsvVulnDataSourceConfig) factory.runtimeConfigSpec().defaultConfig(); + final var config = (OsvVulnDataSourceConfigV1) factory.runtimeConfigSpec().defaultConfig(); config.setEnabled(isEnabled); factory.init(new ExtensionContext(new MockConfigRegistry(factory.runtimeConfigSpec(), config))); @@ -63,7 +63,7 @@ void isDataSourceEnabledShouldReturnTrueWhenEnabledAndFalseOtherwise(final boole @Test void createShouldReturnNullWhenDisabled() { - final var config = (OsvVulnDataSourceConfig) factory.runtimeConfigSpec().defaultConfig(); + final var config = (OsvVulnDataSourceConfigV1) factory.runtimeConfigSpec().defaultConfig(); config.setEnabled(false); final var configRegistry = new MockConfigRegistry(factory.runtimeConfigSpec(), config); @@ -76,7 +76,7 @@ void createShouldReturnNullWhenDisabled() { @Test void createShouldReturnDataSource() { - final var config = (OsvVulnDataSourceConfig) factory.runtimeConfigSpec().defaultConfig(); + final var config = (OsvVulnDataSourceConfigV1) factory.runtimeConfigSpec().defaultConfig(); config.setEnabled(true); final var configRegistry = new MockConfigRegistry(factory.runtimeConfigSpec(), config);