relations
+) {
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java
new file mode 100644
index 0000000..18423b8
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java
@@ -0,0 +1,11 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook;
+
+/**
+ * Mapping rule returned by the inbound webhook management API.
+ */
+public record InboundWebhookMappingDtoOut(
+ String template,
+ String filter,
+ InboundWebhookEntityMappingDtoOut entity
+) {
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java
new file mode 100644
index 0000000..32f9a98
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java
@@ -0,0 +1,11 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook;
+
+/**
+ * Security strategy returned for webhook configuration responses.
+ *
+ * Only returns the strategy type to avoid exposing technical secret references.
+ */
+public record InboundWebhookSecurityDtoOut(
+ String type
+) {
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java
index 70ca673..0669a9d 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java
@@ -6,9 +6,18 @@
import java.util.stream.Collectors;
import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException;
+import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException;
import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException;
import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException;
+import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException;
import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException;
+import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorTitleAlreadyExistsException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookTemplateHasNoPropertiesException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@@ -56,6 +65,42 @@ public class ApiExceptionHandler {
private ApiExceptionHandler() {
}
+ /// Handles webhook signature and credential validation failures.
+ ///
+ /// HTTP mapping: Maps WebhookAuthenticationException to HTTP 401 Unauthorized.
+ @ExceptionHandler(WebhookAuthenticationException.class)
+ public ResponseEntity handleWebhookAuthenticationException(WebhookAuthenticationException ex) {
+ log.warn("Webhook authentication failed: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.UNAUTHORIZED.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
+ }
+
+ /// Handles missing webhook connector configuration.
+ ///
+ /// HTTP mapping: Maps WebhookConnectorNotFoundException to HTTP 404 Not Found.
+ @ExceptionHandler(WebhookConnectorNotFoundException.class)
+ public ResponseEntity handleWebhookConnectorNotFoundException(WebhookConnectorNotFoundException ex) {
+ log.warn("Webhook connector not found: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage());
+ return ResponseEntity.status(NOT_FOUND).body(errorResponse);
+ }
+
+ /// Handles webhook connector identifier duplication conflicts.
+ @ExceptionHandler(WebhookConnectorAlreadyExistException.class)
+ public ResponseEntity handleWebhookConnectorAlreadyExistException(WebhookConnectorAlreadyExistException ex) {
+ log.warn("Webhook connector identifier conflict: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ /// Handles webhook connector title duplication conflicts.
+ @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class)
+ public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException(WebhookConnectorTitleAlreadyExistsException ex) {
+ log.warn("Webhook connector title conflict: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
/// Handles domain exception when entity templates are not found.
///
/// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 status
@@ -222,6 +267,41 @@ public ResponseEntity handleHttpMessageNotReadableException(HttpM
return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage);
}
+ /// Handles invalid dynamic mapping expressions (JSLT) provided in webhook configuration.
+ ///
+ /// **HTTP mapping:** Maps domain mapping configuration failures to HTTP 400,
+ /// because clients can fix these expressions and retry.
+ @ExceptionHandler(EntityDynamicMappingConfigurationException.class)
+ public ResponseEntity handleEntityDynamicMappingConfigurationException(EntityDynamicMappingConfigurationException ex) {
+ log.warn("Invalid entity dynamic mapping configuration: {}", ex.getMessage());
+ String errorMessage = "Invalid webhook mapping configuration: " + ex.getMessage();
+ return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage);
+ }
+
+ @ExceptionHandler(PropertyNameNotFoundEntityTemplatePropertiesException.class)
+ public ResponseEntity handlePropertyNameNotFoundEntityTemplatePropertiesException(PropertyNameNotFoundEntityTemplatePropertiesException ex) {
+ log.warn("Webhook mapping references unknown property: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
+ @ExceptionHandler(RelationNameNotFoundEntityTemplateRelationsException.class)
+ public ResponseEntity handleRelationNameNotFoundEntityTemplateRelationsException(RelationNameNotFoundEntityTemplateRelationsException ex) {
+ log.warn("Webhook mapping references unknown relation: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
+ @ExceptionHandler(WebhookTemplateHasNoPropertiesException.class)
+ public ResponseEntity handleWebhookTemplateHasNoPropertiesException(WebhookTemplateHasNoPropertiesException ex) {
+ log.warn("Webhook mapping invalid for template without properties: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
+ @ExceptionHandler(WebhookSecurityConfigurationException.class)
+ public ResponseEntity handleWebhookSecurityConfigurationException(WebhookSecurityConfigurationException ex) {
+ log.warn("Invalid webhook security configuration: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
/// Handles domain exception when entities are not found.
///
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java
new file mode 100644
index 0000000..1da1842
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java
@@ -0,0 +1,129 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookMappingDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookEntityMappingDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookMappingDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookSecurityDtoOut;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * Maps inbound webhook API DTOs to domain models and back.
+ */
+@Component
+public class InboundWebhookMapper {
+
+ /**
+ * Converts API input payload to the domain aggregate.
+ *
+ * @param dto inbound webhook creation request
+ * @return domain webhook connector
+ */
+ public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto) {
+ return new WebhookConnector(
+ null,
+ dto.identifier(),
+ dto.title(),
+ dto.description(),
+ dto.enabled(),
+ dto.mappings().stream().map(this::toDomain).toList(),
+ toDomain(dto.security())
+ );
+ }
+
+ /**
+ * Converts API update payload to domain aggregate using the path identifier as source of truth.
+ *
+ * @param identifier webhook connector identifier from URL path
+ * @param dto inbound webhook update request body
+ * @return domain webhook connector prepared for update
+ */
+ public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookCreateDtoIn dto) {
+ var mappings = dto.mappings().stream().map(this::toDomain).toList();
+ var security = toDomain(dto.security());
+ return new WebhookConnector(
+ null,
+ identifier,
+ dto.title(),
+ dto.description(),
+ dto.enabled(),
+ mappings,
+ security
+ );
+ }
+
+ /**
+ * Converts domain aggregate to API response payload.
+ *
+ * @param domain created webhook connector
+ * @return response DTO
+ */
+ public InboundWebhookDtoOut fromWebhookConnectorToDto(WebhookConnector domain) {
+ var mappings = domain.mappings().stream().map(this::fromEntityMappingToDto).toList();
+ var security = new InboundWebhookSecurityDtoOut(domain.security().type().name());
+ return new InboundWebhookDtoOut(
+ domain.identifier(),
+ domain.title(),
+ domain.description(),
+ domain.enabled(),
+ mappings,
+ security
+ );
+ }
+
+ private InboundWebhookMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) {
+ return new InboundWebhookMappingDtoOut(
+ mapping.templateIdentifier(),
+ mapping.filter(),
+ new InboundWebhookEntityMappingDtoOut(
+ mapping.entityIdentifier(),
+ mapping.entityTitle(),
+ Map.copyOf(mapping.properties()),
+ Map.copyOf(mapping.relations())
+ )
+ );
+ }
+
+ private EntityDynamicMapping toDomain(InboundWebhookMappingDtoIn mapping) {
+ return new EntityDynamicMapping(
+ mapping.template(),
+ mapping.filter(),
+ mapping.entity().identifier(),
+ mapping.entity().title(),
+ safeMap(mapping.entity().properties()),
+ safeMap(mapping.entity().relations())
+ );
+ }
+
+ private WebhookSecurity toDomain(InboundWebhookSecurityContractDtoIn security) {
+ if (security == null) {
+ return new WebhookSecurity(WebhookSecurityType.NONE, Map.of());
+ }
+
+ var type = parseSecurityType(security.type());
+ var config = safeMap(security.config());
+
+ return new WebhookSecurity(type, config);
+ }
+
+ private WebhookSecurityType parseSecurityType(String typeString) {
+ try {
+ return WebhookSecurityType.valueOf(typeString.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new WebhookSecurityConfigurationException("Unsupported security type: " + typeString);
+ }
+ }
+
+ private Map safeMap(Map input) {
+ return input == null ? Map.of() : Map.copyOf(input);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java
new file mode 100644
index 0000000..d71c37e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java
@@ -0,0 +1,100 @@
+package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt;
+
+import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator;
+import com.schibsted.spt.data.jslt.JsltException;
+import com.schibsted.spt.data.jslt.Parser;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Service
+public class JsltEntityMappingValidator implements EntityDynamicMapperValidator {
+
+ private static final Pattern LOCATION_PATTERN = Pattern.compile("line\\s+(\\d+),\\s+column\\s+(\\d+)");
+ private static final Pattern TOKEN_PATTERN = Pattern.compile("Encountered\\s+\"([^\"]+)\"");
+
+ @Override
+ public void validate(EntityDynamicMapping mapping) {
+ List errors = new ArrayList<>();
+
+ checkExpression(errors, "filter", mapping.filter());
+
+ checkExpression(errors, "entityIdentifier", mapping.entityIdentifier());
+ checkExpression(errors, "entityTitle", mapping.entityTitle());
+
+ mapping.properties().forEach((key, expr) -> checkExpression(errors, "properties." + key, expr));
+ mapping.relations().forEach((key, expr) -> checkExpression(errors, "relations." + key, expr));
+
+ if (!errors.isEmpty()) {
+ throw new EntityDynamicMappingConfigurationException(
+ String.format("Validation failed with %d errors: %s", errors.size(), String.join(" | ", errors))
+ );
+ }
+ }
+
+ private void checkExpression(List errors, String fieldName, String expression) {
+ if (!StringUtils.hasText(expression)) {
+ errors.add(String.format(
+ "Field '%s' is required and must contain a JSLT expression.",
+ fieldName
+ ));
+ return;
+ }
+
+ try {
+ new Parser(new StringReader(expression)).compile();
+ } catch (JsltException exception) {
+ errors.add(String.format(
+ "Invalid expression for '%s': %s",
+ fieldName,
+ formatJsltErrorMessage(exception.getMessage())
+ ));
+ }
+ }
+
+ private String formatJsltErrorMessage(String rawMessage) {
+ if (!StringUtils.hasText(rawMessage)) {
+ return "JSLT syntax error.";
+ }
+
+ String normalized = rawMessage.replaceAll("\\s+", " ").trim();
+ if (normalized.startsWith("Parse error:")) {
+ normalized = normalized.substring("Parse error:".length()).trim();
+ }
+
+ String line = null;
+ String column = null;
+ Matcher locationMatcher = LOCATION_PATTERN.matcher(rawMessage);
+ if (locationMatcher.find()) {
+ line = locationMatcher.group(1);
+ column = locationMatcher.group(2);
+ }
+
+ String token = null;
+ Matcher tokenMatcher = TOKEN_PATTERN.matcher(rawMessage);
+ if (tokenMatcher.find()) {
+ token = tokenMatcher.group(1);
+ }
+
+ if (line != null && column != null && token != null) {
+ return String.format(
+ "JSLT syntax error at line %s, column %s (unexpected token: %s).",
+ line,
+ column,
+ token
+ );
+ }
+ if (line != null && column != null) {
+ return String.format("JSLT syntax error at line %s, column %s.", line, column);
+ }
+
+ return normalized;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
new file mode 100644
index 0000000..31d7f0c
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
@@ -0,0 +1,89 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+import com.decathlon.idp_core.domain.model.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookTemplateMappingJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookConnectorRepository;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookTemplateMappingRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Persistence adapter implementing {@link WebhookConnectorRepositoryPort}.
+ * Delegates to Spring Data JPA and uses {@link WebhookConnectorPersistenceMapper}
+ * to convert between JPA entities and domain models.
+ */
+@Component
+@RequiredArgsConstructor
+public class PostgresWebhookConnectorAdapter implements WebhookConnectorRepositoryPort {
+
+ private final JpaWebhookConnectorRepository jpaWebhookConnectorRepository;
+ private final JpaWebhookTemplateMappingRepository jpaWebhookTemplateMappingRepository;
+ private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
+ private final WebhookConnectorPersistenceMapper mapper;
+
+ @Override
+ public Optional findByIdentifier(String identifier) {
+ return jpaWebhookConnectorRepository.findByIdentifier(identifier).map(mapper::toDomain);
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ return jpaWebhookConnectorRepository.findAll(pageable).map(mapper::toDomain);
+ }
+
+ @Override
+ public boolean existsByIdentifier(String identifier) {
+ return jpaWebhookConnectorRepository.existsByIdentifier(identifier);
+ }
+
+ @Override
+ public boolean existsByTitle(String title) {
+ return jpaWebhookConnectorRepository.existsByTitle(title);
+ }
+
+ @Override
+ public WebhookConnector save(WebhookConnector connector) {
+ var savedConnector = jpaWebhookConnectorRepository.save(mapper.toJpa(connector));
+ persistTemplateMappings(savedConnector.getId(), connector);
+ return mapper.toDomain(savedConnector);
+ }
+
+ @Override
+ public void deleteByIdentifier(String identifier) {
+ jpaWebhookConnectorRepository.deleteByIdentifier(identifier);
+ }
+
+ private void persistTemplateMappings(UUID webhookId, WebhookConnector connector) {
+ jpaWebhookTemplateMappingRepository.deleteByWebhookId(webhookId);
+
+ var mappings = connector.mappings().stream()
+ .map(mapping -> toJpaTemplateMapping(webhookId, mapping))
+ .toList();
+
+ if (!mappings.isEmpty()) {
+ jpaWebhookTemplateMappingRepository.saveAll(mappings);
+ }
+ }
+
+ private WebhookTemplateMappingJpaEntity toJpaTemplateMapping(UUID webhookId, EntityDynamicMapping mapping) {
+ EntityTemplate entityTemplate = entityTemplateRepositoryPort.findByIdentifier(mapping.templateIdentifier())
+ .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", mapping.templateIdentifier()));
+
+ return WebhookTemplateMappingJpaEntity.builder()
+ .webhookId(webhookId)
+ .templateId(entityTemplate.id())
+ .jsltFilter(mapping.filter())
+ .build();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/configuration/JpaAuditingConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/configuration/JpaAuditingConfiguration.java
new file mode 100644
index 0000000..dde2d47
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/configuration/JpaAuditingConfiguration.java
@@ -0,0 +1,13 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.configuration;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+/**
+ * Enables Spring Data JPA auditing so {@code @CreatedDate} and {@code @LastModifiedDate}
+ * are populated on webhook connector persistence operations.
+ */
+@Configuration
+@EnableJpaAuditing
+public class JpaAuditingConfiguration {
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java
new file mode 100644
index 0000000..7412419
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java
@@ -0,0 +1,27 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper;
+
+import com.decathlon.idp_core.domain.model.webhook.WebhookConnector;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.WebhookConnectorJsonbHelper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
+
+/**
+ * MapStruct persistence mapper for {@link WebhookConnector}.
+ *
+ * Follows the same contract as the other persistence mappers in the project:
+ * domain model ↔ JPA entity, with JSONB conversions delegated to a dedicated helper.
+ */
+@Mapper(componentModel = SPRING, uses = WebhookConnectorJsonbHelper.class)
+public interface WebhookConnectorPersistenceMapper {
+
+ @Mapping(target = "mappings", qualifiedByName = "jsonToMappings")
+ @Mapping(target = "security", qualifiedByName = "jsonToSecurity")
+ WebhookConnector toDomain(WebhookConnectorJpaEntity jpa);
+
+ @Mapping(target = "mappings", qualifiedByName = "mappingsToJson")
+ @Mapping(target = "security", qualifiedByName = "securityToJson")
+ WebhookConnectorJpaEntity toJpa(WebhookConnector domain);
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java
new file mode 100644
index 0000000..4b888d7
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java
@@ -0,0 +1,70 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.util.StringUtils;
+import org.mapstruct.Named;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Technical helper for JSONB serialization/deserialization in the persistence layer.
+ *
+ *
Provides named conversion methods used by {@link WebhookConnectorPersistenceMapper}
+ * via MapStruct's {@code qualifiedByName} annotation. This is a pure infrastructure utility,
+ * not a domain mapper.
+ */
+@Component
+public class WebhookConnectorJsonbHelper {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Named("jsonToMappings")
+ public List toMappings(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ return OBJECT_MAPPER.readValue(json, new TypeReference<>() {
+ });
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Invalid webhook connector mappings JSONB", e);
+ }
+ }
+
+ @Named("mappingsToJson")
+ public String toJson(List mappings) {
+ try {
+ return OBJECT_MAPPER.writeValueAsString(mappings);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Unable to serialize webhook connector mappings", e);
+ }
+ }
+
+ @Named("jsonToSecurity")
+ public WebhookSecurity toSecurity(String json) {
+ if (!StringUtils.hasText(json)) {
+ return new WebhookSecurity(WebhookSecurityType.NONE, java.util.Map.of());
+ }
+ try {
+ return OBJECT_MAPPER.readValue(json, WebhookSecurity.class);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Invalid webhook connector security JSONB", e);
+ }
+ }
+
+ @Named("securityToJson")
+ public String toSecurityJson(WebhookSecurity security) {
+ try {
+ return OBJECT_MAPPER.writeValueAsString(security);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Unable to serialize webhook connector security", e);
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java
new file mode 100644
index 0000000..e5c42b6
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java
@@ -0,0 +1,74 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook;
+
+import java.time.Instant;
+import java.util.UUID;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/// JPA entity mapping to the `webhook_connector` PostgreSQL table.
+///
+/// JSONB columns (mappings, security) are stored as raw JSON strings and deserialized
+/// in [WebhookConnectorPersistenceMapper] using Jackson.
+/// The webhook security payload follows the generic [SecurityContract] shape at the adapter boundary.
+@Entity
+@Table(name = "webhook_connector")
+@EntityListeners(AuditingEntityListener.class)
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WebhookConnectorJpaEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ /// Business key used in the webhook URL: POST /webhooks/{identifier}
+ @Column(nullable = false, unique = true, length = 255)
+ private String identifier;
+
+ /// Human-readable name displayed in the management UI
+ @Column(nullable = false, length = 255)
+ private String title;
+
+ /// Optional description of the connector purpose
+ @Column(columnDefinition = "TEXT")
+ private String description;
+
+ /// When false, the connector rejects all inbound events without processing them
+ @Column(nullable = false)
+ private Boolean enabled;
+
+ /// JSONB array of mapping rules — deserialized to List by the mapper.
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(nullable = false, columnDefinition = "jsonb")
+ private String mappings;
+
+ /// JSONB security configuration — deserialized to WebhookSecurity by the mapper.
+ /// The "type" discriminator field drives polymorphic deserialization.
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(nullable = false, columnDefinition = "jsonb")
+ private String security;
+
+ /// Timestamp of connector creation
+ @CreatedDate
+ @Column(nullable = false, updatable = false)
+ private Instant createdAt;
+
+ /// Timestamp of last connector update
+ @LastModifiedDate
+ @Column(nullable = false)
+ private Instant updatedAt;
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookTemplateMappingJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookTemplateMappingJpaEntity.java
new file mode 100644
index 0000000..f03e736
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookTemplateMappingJpaEntity.java
@@ -0,0 +1,42 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook;
+
+import java.util.UUID;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/// JPA entity for the `webhook_template_mapping` table.
+///
+/// Stores the link between a webhook connector and the entity template referenced
+/// by one inbound mapping rule.
+@Entity
+@Table(name = "webhook_template_mapping")
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WebhookTemplateMappingJpaEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(name = "webhook_id", nullable = false)
+ private UUID webhookId;
+
+ @Column(name = "template_id", nullable = false)
+ private UUID templateId;
+
+ @Column(name = "jslt_filter", columnDefinition = "TEXT")
+ private String jsltFilter;
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java
new file mode 100644
index 0000000..97882c6
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java
@@ -0,0 +1,17 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.repository;
+
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface JpaWebhookConnectorRepository extends JpaRepository {
+ Optional findByIdentifier(String identifier);
+
+ boolean existsByIdentifier(String identifier);
+
+ boolean existsByTitle(String title);
+
+ void deleteByIdentifier(String identifier);
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java
new file mode 100644
index 0000000..18695cb
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java
@@ -0,0 +1,11 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.repository;
+
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookTemplateMappingJpaEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.UUID;
+
+public interface JpaWebhookTemplateMappingRepository extends JpaRepository {
+
+ void deleteByWebhookId(UUID webhookId);
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/controller/InboundWebhookController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/controller/InboundWebhookController.java
new file mode 100644
index 0000000..45eb6f5
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/controller/InboundWebhookController.java
@@ -0,0 +1,59 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.controller;
+
+import com.decathlon.idp_core.infrastructure.adapters.webhook.service.InboundWebhookHandler;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+import static org.springframework.http.HttpStatus.ACCEPTED;
+
+/// Generic inbound webhook controller single entry point for ALL external event sources.
+///
+/// Architecture rationale (ADR-0003):
+/// One endpoint receives every inbound webhook regardless of origin (GitHub, SonarQube…).
+/// The configurationId path parameter identifies which connector configuration to apply
+/// (security strategy, JQ mapping rules, target EntityTemplate).
+/// Camel reads the connector configuration at runtime and handles the routing.
+///
+/// Security: public endpoint — each connector declares its own strategy (HMAC_SHA256,
+/// STATIC_TOKEN, BASIC_AUTH, JWT_BEARER, NONE) validated before payload processing.
+@RestController
+@RequestMapping("/webhooks")
+@RequiredArgsConstructor
+@Tag(name = "Webhooks", description = "Generic inbound webhook endpoint for all external event sources")
+public class InboundWebhookController {
+
+ private final InboundWebhookHandler handler;
+
+ /// Receives any external webhook event for the given connector configuration.
+ ///
+ /// @param configurationId identifies the connector configuration in the database
+ /// @param headers all HTTP request headers, forwarded to the security validator
+ /// @param rawBody raw request body bytes for signature verification compatibility
+ /// @return 202 Accepted when the event is accepted for processing
+ @Operation(
+ summary = "Receive inbound webhook event",
+ description = "Generic endpoint. Security and mapping are driven by the connector configuration stored in DB."
+ )
+ @ApiResponse(responseCode = "202", description = "Event accepted for processing")
+ @ApiResponse(responseCode = "401", description = "Invalid or missing credentials")
+ @ApiResponse(responseCode = "404", description = "Unknown configurationId")
+ @ApiResponse(responseCode = "400", description = "Malformed request body")
+ @PostMapping(value = "/{configurationId}", consumes = "application/json")
+ @ResponseStatus(ACCEPTED)
+ public ResponseEntity receiveWebhookEvent(
+ @Parameter(description = "Connector configuration identifier", required = true)
+ @PathVariable String configurationId,
+ @RequestHeader Map headers,
+ @RequestBody byte[] rawBody
+ ) {
+ handler.handle(configurationId, headers, rawBody);
+ return ResponseEntity.accepted().build();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java
new file mode 100644
index 0000000..c7d6bb0
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java
@@ -0,0 +1,16 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.model;
+
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record BasicAuthConfig(
+ @NotBlank String username,
+ @NotBlank String secretAlias
+) implements SecurityConfig {
+
+ @Override
+ public WebhookSecurityType type() {
+ return WebhookSecurityType.BASIC_AUTH;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java
new file mode 100644
index 0000000..6c2eb46
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java
@@ -0,0 +1,29 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.model;
+
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record HmacConfig(
+ @NotBlank String headerName,
+ @NotBlank String secretAlias,
+ String prefix,
+ String encoding
+) implements SecurityConfig {
+
+ public static final String DEFAULT_ENCODING = "hex";
+
+ public HmacConfig {
+ if (prefix == null) {
+ prefix = "";
+ }
+ if (encoding == null || encoding.isBlank()) {
+ encoding = DEFAULT_ENCODING;
+ }
+ }
+
+ @Override
+ public WebhookSecurityType type() {
+ return WebhookSecurityType.HMAC_SHA256;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java
new file mode 100644
index 0000000..01ababd
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java
@@ -0,0 +1,15 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.model;
+
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record JwtBearerConfig(
+ @NotBlank String jwksUri
+) implements SecurityConfig {
+
+ @Override
+ public WebhookSecurityType type() {
+ return WebhookSecurityType.JWT_BEARER;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java
new file mode 100644
index 0000000..3438531
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java
@@ -0,0 +1,16 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.model;
+
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = HmacConfig.class, name = "HMAC_SHA256"),
+ @JsonSubTypes.Type(value = StaticTokenConfig.class, name = "STATIC_TOKEN"),
+ @JsonSubTypes.Type(value = BasicAuthConfig.class, name = "BASIC_AUTH"),
+ @JsonSubTypes.Type(value = JwtBearerConfig.class, name = "JWT_BEARER")
+})
+public interface SecurityConfig {
+ WebhookSecurityType type();
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java
new file mode 100644
index 0000000..967f6cd
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java
@@ -0,0 +1,16 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.model;
+
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record StaticTokenConfig(
+ @NotBlank String headerName,
+ @NotBlank String secretAlias
+) implements SecurityConfig {
+
+ @Override
+ public WebhookSecurityType type() {
+ return WebhookSecurityType.STATIC_TOKEN;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java
new file mode 100644
index 0000000..a42536a
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java
@@ -0,0 +1,51 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+/**
+ * Basic Authentication security strategy for webhooks.
+ *
+ * Validates Basic Auth credentials at both creation time (configuration validation)
+ * and runtime (request authentication).
+ */
+@Component
+public class BasicAuthSecurityValidator implements WebhookSecurityStrategy {
+
+ @Override
+ public boolean supports(String securityType) {
+ return "BASIC_AUTH".equalsIgnoreCase(securityType);
+ }
+
+ @Override
+ public void validateConfiguration(Map config) {
+ WebhookSecurityConfigurationUtils.required(config, "username");
+ String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", "secretAlias");
+ WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias);
+ }
+
+ @Override
+ public void validateRequest(WebhookSecurity security, Map headers, byte[] rawBody) {
+ String username = WebhookSecurityConfigurationUtils.requiredAtRuntime(security.config(), "username");
+ String alias = WebhookSecurityConfigurationUtils.requiredAtRuntime(security.config(), "secret_alias", "secretAlias");
+
+ String password = WebhookSecurityConfigurationUtils.getSecretFromEnvironment(alias);
+
+ String authorization = headers.get("Authorization");
+ if (authorization == null || !authorization.startsWith("Basic ")) {
+ throw new WebhookAuthenticationException("Missing Authorization Basic header");
+ }
+
+ String expectedRaw = username + ":" + password;
+ String expected = "Basic " + Base64.getEncoder().encodeToString(expectedRaw.getBytes(StandardCharsets.UTF_8));
+ if (!expected.equals(authorization)) {
+ throw new WebhookAuthenticationException("Invalid basic authentication credentials");
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java
new file mode 100644
index 0000000..57c35ae
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java
@@ -0,0 +1,55 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * HMAC SHA256 security strategy for webhooks.
+ *
+ * Validates HMAC SHA256 signature configuration at creation time and authenticates
+ * incoming webhook requests by verifying the signature against a stored secret.
+ */
+@Component
+public class HmacSha256SecurityValidator implements WebhookSecurityStrategy {
+
+ private final HmacSignatureValidator signatureValidator;
+
+ public HmacSha256SecurityValidator(HmacSignatureValidator signatureValidator) {
+ this.signatureValidator = signatureValidator;
+ }
+
+ @Override
+ public boolean supports(String securityType) {
+ return "HMAC_SHA256".equalsIgnoreCase(securityType);
+ }
+
+ @Override
+ public void validateConfiguration(Map config) {
+ WebhookSecurityConfigurationUtils.required(config, "header_name", "headerName");
+ String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", "secretAlias");
+ WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias);
+ }
+
+ @Override
+ public void validateRequest(WebhookSecurity security, Map headers, byte[] rawBody) {
+ String headerName = WebhookSecurityConfigurationUtils.requiredAtRuntime(security.config(), "header_name", "headerName");
+ String alias = WebhookSecurityConfigurationUtils.requiredAtRuntime(security.config(), "secret_alias", "secretAlias");
+ String prefix = WebhookSecurityConfigurationUtils.optional(security.config(), "prefix", "");
+
+ String provided = headers.get(headerName);
+ if (provided == null || provided.isBlank()) {
+ throw new WebhookAuthenticationException("Missing signature header: " + headerName);
+ }
+
+ String secret = WebhookSecurityConfigurationUtils.getSecretFromEnvironment(alias);
+
+ String expected = prefix + signatureValidator.computeHexSha256(rawBody, secret);
+ if (!expected.equals(provided)) {
+ throw new WebhookAuthenticationException("Invalid HMAC signature");
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java
new file mode 100644
index 0000000..9c8c527
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java
@@ -0,0 +1,31 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+
+@Component
+public class HmacSignatureValidator {
+
+ public String computeHexSha256(byte[] payload, String secret) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ byte[] digest = mac.doFinal(payload);
+ return toHex(digest);
+ } catch (Exception exception) {
+ throw new WebhookAuthenticationException("Unable to compute HMAC signature");
+ }
+ }
+
+ private String toHex(byte[] input) {
+ StringBuilder sb = new StringBuilder(input.length * 2);
+ for (byte value : input) {
+ sb.append(String.format("%02x", value));
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java
new file mode 100644
index 0000000..b04cc48
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java
@@ -0,0 +1,63 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+import org.springframework.security.oauth2.jwt.JwtException;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * JWT Bearer security strategy for webhooks.
+ *
+ * Validates JWT Bearer configuration at creation time and authenticates incoming
+ * webhook requests by verifying the JWT token against a JWKS endpoint.
+ */
+@Component
+public class JwtBearerSecurityValidator implements WebhookSecurityStrategy {
+
+ private final WebhookJwtDecoderProvider jwtDecoderProvider;
+
+ public JwtBearerSecurityValidator(WebhookJwtDecoderProvider jwtDecoderProvider) {
+ this.jwtDecoderProvider = jwtDecoderProvider;
+ }
+
+ @Override
+ public boolean supports(String securityType) {
+ return "JWT_BEARER".equalsIgnoreCase(securityType);
+ }
+
+ @Override
+ public void validateConfiguration(Map config) {
+ String jwksUri = WebhookSecurityConfigurationUtils.required(config, "jwks_uri", "jwksUri");
+ if (jwksUri.isBlank()) {
+ throw new WebhookSecurityConfigurationException("Invalid jwks_uri for JWT_BEARER security");
+ }
+ }
+
+ @Override
+ public void validateRequest(WebhookSecurity security, Map headers, byte[] rawBody) {
+ String jwksUri = WebhookSecurityConfigurationUtils.requiredAtRuntime(security.config(), "jwks_uri", "jwksUri");
+ if (jwksUri.isBlank()) {
+ throw new WebhookAuthenticationException("Invalid jwks_uri for JWT_BEARER security");
+ }
+
+ String authorization = headers.get("Authorization");
+ if (authorization == null || !authorization.startsWith("Bearer ")) {
+ throw new WebhookAuthenticationException("Missing Authorization Bearer header");
+ }
+
+ String token = authorization.substring("Bearer ".length()).trim();
+ if (token.isBlank()) {
+ throw new WebhookAuthenticationException("Missing bearer token");
+ }
+
+ try {
+ jwtDecoderProvider.get(jwksUri).decode(token);
+ } catch (JwtException exception) {
+ throw new WebhookAuthenticationException("Invalid JWT bearer token", exception);
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java
new file mode 100644
index 0000000..d398fd2
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java
@@ -0,0 +1,47 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * Static Token security strategy for webhooks.
+ *
+ * Validates static token configuration at creation time and authenticates incoming
+ * webhook requests by comparing the provided token against the stored secret.
+ */
+@Component
+public class StaticTokenSecurityValidator implements WebhookSecurityStrategy {
+
+ @Override
+ public boolean supports(String securityType) {
+ return "STATIC_TOKEN".equalsIgnoreCase(securityType);
+ }
+
+ @Override
+ public void validateConfiguration(Map config) {
+ WebhookSecurityConfigurationUtils.required(config, "header_name", "headerName");
+ String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", "secretAlias");
+ WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias);
+ }
+
+ @Override
+ public void validateRequest(WebhookSecurity security, Map headers, byte[] rawBody) {
+ String headerName = WebhookSecurityConfigurationUtils.requiredAtRuntime(security.config(), "header_name", "headerName");
+ String alias = WebhookSecurityConfigurationUtils.requiredAtRuntime(security.config(), "secret_alias", "secretAlias");
+
+ String provided = headers.get(headerName);
+ if (provided == null || provided.isBlank()) {
+ throw new WebhookAuthenticationException("Missing token header: " + headerName);
+ }
+
+ String expected = WebhookSecurityConfigurationUtils.getSecretFromEnvironment(alias);
+
+ if (!expected.equals(provided)) {
+ throw new WebhookAuthenticationException("Invalid static token");
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java
new file mode 100644
index 0000000..958caf2
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java
@@ -0,0 +1,28 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Builds and caches JwtDecoder instances keyed by jwks_uri.
+ */
+@Component
+public class WebhookJwtDecoderProvider {
+
+ private final ConcurrentMap decodersByJwksUri = new ConcurrentHashMap<>();
+
+ public JwtDecoder get(String jwksUri) {
+ return decodersByJwksUri.computeIfAbsent(jwksUri, this::createDecoder);
+ }
+
+ private JwtDecoder createDecoder(String jwksUri) {
+ var decoder = NimbusJwtDecoder.withJwkSetUri(jwksUri).build();
+ decoder.setJwtValidator(JwtValidators.createDefault());
+ return decoder;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java
new file mode 100644
index 0000000..cb5b1d5
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java
@@ -0,0 +1,103 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+
+/**
+ * Common utilities for webhook security validation.
+ *
+ * Provides shared methods for extracting and validating configuration keys across all security strategies.
+ * This eliminates duplication between creation-time and runtime validation logic.
+ */
+public final class WebhookSecurityConfigurationUtils {
+
+ private WebhookSecurityConfigurationUtils() {
+ // Utility class
+ }
+
+ /**
+ * Retrieves a required configuration value, checking multiple key variants (snake_case and camelCase).
+ *
+ * @param config the configuration map
+ * @param keys the keys to check in order (e.g., "secret_alias", "secretAlias")
+ * @return the first non-blank value found
+ * @throws WebhookSecurityConfigurationException if no value is found (at creation time)
+ * @throws WebhookAuthenticationException if no value is found (at runtime)
+ */
+ public static String required(Map config, String... keys) {
+ return required(config, false, keys);
+ }
+
+ /**
+ * Retrieves a required configuration value at runtime (throws WebhookAuthenticationException).
+ *
+ * @param config the configuration map
+ * @param keys the keys to check in order
+ * @return the first non-blank value found
+ * @throws WebhookAuthenticationException if no value is found
+ */
+ public static String requiredAtRuntime(Map config, String... keys) {
+ return required(config, true, keys);
+ }
+
+ /**
+ * Retrieves an optional configuration value, returning a default if not found.
+ *
+ * @param config the configuration map
+ * @param key the key to look up
+ * @param defaultValue the value to return if key is not found
+ * @return the configuration value or the default
+ */
+ public static String optional(Map config, String key, String defaultValue) {
+ String value = config.get(key);
+ return value == null ? defaultValue : value;
+ }
+
+ /**
+ * Validates that a secret alias follows the UPPER_SNAKE_CASE convention.
+ *
+ * @param alias the alias to validate
+ * @throws WebhookSecurityConfigurationException if the alias format is invalid (at creation time)
+ */
+ public static void validateSecretAliasFormat(String alias) {
+ if (!alias.matches("^[A-Z0-9_]+$")) {
+ throw new WebhookSecurityConfigurationException(
+ "Invalid 'secret_alias'. Use an environment variable alias (UPPER_SNAKE_CASE), not the raw secret value"
+ );
+ }
+ }
+
+ /**
+ * Retrieves a secret from environment variables, throwing if not found or empty.
+ *
+ * @param alias the environment variable alias (key)
+ * @return the secret value
+ * @throws WebhookAuthenticationException if the secret is not found or empty
+ */
+ public static String getSecretFromEnvironment(String alias) {
+ String secret = System.getenv(alias);
+ if (secret == null || secret.isBlank()) {
+ throw new WebhookAuthenticationException("Missing environment secret for alias: " + alias);
+ }
+ return secret;
+ }
+
+ private static String required(Map config, boolean isRuntime, String... keys) {
+ for (String key : keys) {
+ String value = config.get(key);
+ if (StringUtils.hasText(value)) {
+ return value;
+ }
+ }
+
+ String keysStr = String.join(", ", keys);
+ if (isRuntime) {
+ throw new WebhookAuthenticationException("Missing security config key. Expected one of: " + keysStr);
+ } else {
+ throw new WebhookSecurityConfigurationException("Missing required security config key. Expected one of: " + keysStr);
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityValidatorDispatcher.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityValidatorDispatcher.java
new file mode 100644
index 0000000..4eeb63b
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityValidatorDispatcher.java
@@ -0,0 +1,46 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Dispatcher for webhook runtime authentication.
+ *
+ * Routes incoming webhook validation requests to the appropriate security strategy
+ * based on the configured security type.
+ */
+@Component
+public class WebhookSecurityValidatorDispatcher {
+
+ private final List strategies;
+
+ public WebhookSecurityValidatorDispatcher(List strategies) {
+ this.strategies = List.copyOf(strategies);
+ }
+
+ /**
+ * Dispatches webhook request validation to the appropriate security strategy.
+ *
+ * @param security the webhook security configuration
+ * @param headers HTTP request headers
+ * @param rawBody raw request body
+ * @throws WebhookAuthenticationException if validation fails or no strategy is found
+ */
+ public void dispatch(WebhookSecurity security, Map headers, byte[] rawBody) {
+ if (security.type() == WebhookSecurityType.NONE) {
+ return;
+ }
+
+ strategies.stream()
+ .filter(strategy -> strategy.supports(security.type().name()))
+ .findFirst()
+ .orElseThrow(() -> new WebhookAuthenticationException("Unsupported webhook security strategy: " + security.type()))
+ .validateRequest(security, headers, rawBody);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/service/InboundWebhookHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/service/InboundWebhookHandler.java
new file mode 100644
index 0000000..481f03f
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/service/InboundWebhookHandler.java
@@ -0,0 +1,69 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.service;
+
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException;
+import com.decathlon.idp_core.domain.model.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+import com.decathlon.idp_core.infrastructure.adapters.webhook.security.WebhookSecurityValidatorDispatcher;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/// Generic inbound webhook handler — infrastructure entry point for all connectors.
+///
+/// **Responsibilities (infrastructure only, no business logic):**
+/// 1. Resolve the [WebhookConnector] configuration from the database by `configurationId`
+/// 2. Validate the request credentials by delegating to [WebhookSecurityValidatorDispatcher]
+/// which applies the **Strategy pattern** to route to the correct security implementation.
+/// 3. Accept the request and eventually forward the raw payload to Camel for mapping + ingestion.
+/// This last step is not implemented yet: the handler currently stops after security validation.
+///
+/// **Architecture note (ADR-0003):**
+/// This class intentionally contains no source-specific logic (no GitHub, no SonarQube, etc.).
+/// All payload interpretation (JQ filter + field mapping) is delegated to Camel,
+/// which reads the [WebhookConnector] mappings at runtime.
+///
+/// **Extension guide (Strategy pattern):**
+/// To support a new security strategy, add a new Spring bean implementing [WebhookSecurityValidator].
+/// The dispatcher auto-discovers it — no changes required here.
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class InboundWebhookHandler {
+
+ private final WebhookConnectorRepositoryPort connectorRepository;
+ private final WebhookSecurityValidatorDispatcher securityDispatcher;
+
+ /// Processes any inbound webhook event.
+ ///
+ /// @param configurationId identifies the connector configuration stored in the database
+ /// @param headers all HTTP request headers (used for signature / auth extraction)
+ /// @param rawBody raw request body bytes (used for HMAC digest + later JQ mapping)
+ /// @throws WebhookConnectorNotFoundException if no connector is registered for this identifier
+ /// @throws WebhookAuthenticationException if security validation fails
+ public void handle(String configurationId, Map headers, byte[] rawBody) {
+ WebhookConnector connector = connectorRepository.findByIdentifier(configurationId)
+ .orElseThrow(() -> new WebhookConnectorNotFoundException(configurationId));
+
+ if (!connector.enabled()) {
+ log.warn("Webhook connector '{}' is disabled. Ignoring incoming event.", configurationId);
+ return;
+ }
+
+ // Delegates to the appropriate security strategy via the Strategy pattern dispatcher.
+ // Each WebhookSecurityValidator implementation handles one WebhookSecurityType.
+ securityDispatcher.dispatch(connector.security(), headers, rawBody);
+
+ // TODO (ADR-0003 / Camel integration):
+ // Forward rawBody + connector to a Camel route:
+ // producerTemplate.sendBodyAndHeader("direct:webhook-ingest", rawBody, "connector", connector);
+ // The Camel route will apply JQ mappings and call EntityService.
+ // Until then, webhook ingestion is effectively "accepted after auth" only.
+ log.info("Webhook event received for connector '{}' ({} bytes). Pending Camel routing.",
+ configurationId, rawBody.length);
+ }
+}
diff --git a/src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql b/src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql
new file mode 100644
index 0000000..754df38
--- /dev/null
+++ b/src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql
@@ -0,0 +1,15 @@
+CREATE TABLE webhook_connector (
+ id UUID PRIMARY KEY,
+ identifier VARCHAR(255) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ enabled BOOLEAN DEFAULT TRUE,
+ mappings JSONB NOT NULL DEFAULT '[]'::jsonb,
+ security JSONB NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE UNIQUE INDEX idx_webhook_connector_identifier ON webhook_connector (identifier);
+
+CREATE INDEX idx_webhook_security_type ON webhook_connector USING GIN (security);
diff --git a/src/main/resources/db/migration/V4_2__create_webhook_template_mapping_table.sql b/src/main/resources/db/migration/V4_2__create_webhook_template_mapping_table.sql
new file mode 100644
index 0000000..ab70450
--- /dev/null
+++ b/src/main/resources/db/migration/V4_2__create_webhook_template_mapping_table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE webhook_template_mapping
+(
+ id UUID PRIMARY KEY,
+ webhook_id UUID NOT NULL,
+ template_id UUID NOT NULL,
+
+ jslt_filter TEXT,
+
+ CONSTRAINT fk_webhook_connector FOREIGN KEY (webhook_id) REFERENCES webhook_connector (id) ON DELETE CASCADE,
+ CONSTRAINT fk_entity_template FOREIGN KEY (template_id) REFERENCES entity_template (id) ON DELETE RESTRICT
+);
diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java
new file mode 100644
index 0000000..15f05c7
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java
@@ -0,0 +1,437 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException;
+import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException;
+import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookTemplateHasNoPropertiesException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition;
+import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition;
+import com.decathlon.idp_core.domain.model.enums.PropertyType;
+import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator;
+import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService;
+import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for {@link EntityDynamicMappingValidationService}.
+ */
+@DisplayName("EntityDynamicMappingValidationService Tests")
+@ExtendWith(MockitoExtension.class)
+class EntityDynamicMappingValidationServiceTest {
+
+ @Mock
+ private EntityTemplateService entityTemplateService;
+
+ @Mock
+ private EntityDynamicMapperValidator entityDynamicMapperValidator;
+
+ @Mock
+ private EntityTemplateValidationService entityTemplateValidationService;
+
+ private EntityDynamicMappingValidationService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new EntityDynamicMappingValidationService(
+ entityTemplateService,
+ entityDynamicMapperValidator,
+ entityTemplateValidationService
+ );
+ }
+
+ private EntityDynamicMapping buildMapping(String templateIdentifier,
+ Map properties,
+ Map relations) {
+ return new EntityDynamicMapping(
+ templateIdentifier,
+ ".eventType == \"DEPLOYED\"",
+ ".id",
+ ".name",
+ properties,
+ relations
+ );
+ }
+
+ private EntityTemplate buildEntityTemplate(List properties,
+ List relations) {
+ return new EntityTemplate(
+ UUID.randomUUID(),
+ "deployment",
+ "Deployment",
+ "A deployment template",
+ properties,
+ relations
+ );
+ }
+
+ private PropertyDefinition buildProperty(String name, boolean required) {
+ return new PropertyDefinition(UUID.randomUUID(), name, name + " description", PropertyType.STRING, required, null);
+ }
+
+ private RelationDefinition buildRelation(String name, boolean required) {
+ return new RelationDefinition(UUID.randomUUID(), name, "service", required, false);
+ }
+
+ @Nested
+ @DisplayName("validateWebhookMapping - happy paths")
+ class ValidateWebhookMappingHappyPathTests {
+
+ @Test
+ @DisplayName("Should pass with valid mapping having matching properties")
+ void shouldPassWithValidMappingMatchingProperties() {
+ PropertyDefinition property = buildProperty("environment", false);
+ EntityTemplate template = buildEntityTemplate(List.of(property), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of("environment", ".env"), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+ doNothing().when(entityTemplateValidationService).validatePropertyNameAlreadyExistInTemplate(
+ template.propertiesDefinitions(), "environment");
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+
+ verify(entityDynamicMapperValidator).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should pass with empty properties mapping and empty template properties")
+ void shouldPassWithEmptyPropertiesAndEmptyTemplateProperties() {
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+
+ verify(entityDynamicMapperValidator).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should pass with null relations (no relations in mapping)")
+ void shouldPassWithNullRelations() {
+ PropertyDefinition property = buildProperty("env", false);
+ EntityTemplate template = buildEntityTemplate(List.of(property), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".env"), null);
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+ doNothing().when(entityTemplateValidationService).validatePropertyNameAlreadyExistInTemplate(
+ template.propertiesDefinitions(), "env");
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+ }
+
+ @Test
+ @DisplayName("Should pass with empty relations in mapping and no required relations in template")
+ void shouldPassWithEmptyRelationsAndNoRequiredRelations() {
+ RelationDefinition relation = buildRelation("service", false);
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation));
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+ }
+
+ @Test
+ @DisplayName("Should validate each mapping in the list")
+ void shouldValidateEachMappingInList() {
+ PropertyDefinition property1 = buildProperty("env", false);
+ EntityTemplate template1 = buildEntityTemplate(List.of(property1), List.of());
+ EntityDynamicMapping mapping1 = buildMapping("deployment", Map.of("env", ".env"), Map.of());
+
+ PropertyDefinition property2 = buildProperty("version", false);
+ EntityTemplate template2 = buildEntityTemplate(List.of(property2), List.of());
+ EntityDynamicMapping mapping2 = new EntityDynamicMapping(
+ "service", ".type == \"SERVICE\"", ".id", ".name",
+ Map.of("version", ".ver"), Map.of()
+ );
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("service");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template1);
+ when(entityTemplateService.getEntityTemplateByIdentifier("service")).thenReturn(template2);
+ doNothing().when(entityTemplateValidationService).validatePropertyNameAlreadyExistInTemplate(
+ template1.propertiesDefinitions(), "env");
+ doNothing().when(entityTemplateValidationService).validatePropertyNameAlreadyExistInTemplate(
+ template2.propertiesDefinitions(), "version");
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping1, mapping2)));
+
+ verify(entityDynamicMapperValidator).validate(mapping1);
+ verify(entityDynamicMapperValidator).validate(mapping2);
+ }
+
+ @Test
+ @DisplayName("Should pass when mapping has valid relations matching template")
+ void shouldPassWithValidRelations() {
+ RelationDefinition relation = buildRelation("owner", true);
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation));
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of("owner", ".owner"));
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+ doNothing().when(entityTemplateValidationService).validateRelationNameAlreadyExistInTemplate(
+ template.relationsDefinitions(), "owner");
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+
+ verify(entityDynamicMapperValidator).validate(mapping);
+ }
+ }
+
+ @Nested
+ @DisplayName("validateWebhookMapping - template existence")
+ class ValidateTemplateExistenceTests {
+
+ @Test
+ @DisplayName("Should throw EntityTemplateNotFoundException when template does not exist")
+ void shouldThrowWhenTemplateDoesNotExist() {
+ EntityDynamicMapping mapping = buildMapping("unknown-template", Map.of(), Map.of());
+
+ doThrow(new EntityTemplateNotFoundException("identifier", "unknown-template"))
+ .when(entityTemplateValidationService).validateTemplateExists("unknown-template");
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(EntityTemplateNotFoundException.class);
+
+ verify(entityTemplateService, never()).getEntityTemplateByIdentifier("unknown-template");
+ verify(entityDynamicMapperValidator, never()).validate(mapping);
+ }
+ }
+
+ @Nested
+ @DisplayName("validateWebhookMapping - properties validation")
+ class ValidatePropertiesTests {
+
+ @Test
+ @DisplayName("Should throw WebhookTemplateHasNoPropertiesException when mapping has properties but template has none")
+ void shouldThrowWhenMappingHasPropertiesButTemplateHasNone() {
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".env"), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(WebhookTemplateHasNoPropertiesException.class)
+ .hasMessageContaining("no property definitions");
+
+ verify(entityDynamicMapperValidator, never()).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should throw PropertyNameNotFoundEntityTemplatePropertiesException when property not in template")
+ void shouldThrowWhenPropertyNotFoundInTemplate() {
+ var property = buildProperty("environment", false);
+ EntityTemplate template = buildEntityTemplate(List.of(property), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of("unknown-prop", ".x"), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+ doThrow(new PropertyNameNotFoundEntityTemplatePropertiesException("Property name unknown-prop not found in entity template properties"))
+ .when(entityTemplateValidationService).validatePropertyNameAlreadyExistInTemplate(
+ template.propertiesDefinitions(), "unknown-prop");
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(PropertyNameNotFoundEntityTemplatePropertiesException.class)
+ .hasMessageContaining("unknown-prop");
+
+ verify(entityDynamicMapperValidator, never()).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should throw WebhookTemplateHasNoPropertiesException when required property is missing from mapping")
+ void shouldThrowWhenRequiredPropertyMissingFromMapping() {
+ PropertyDefinition requiredProp = buildProperty("env", true);
+ EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of());
+ // mapping does not include the required property "env"
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(WebhookTemplateHasNoPropertiesException.class)
+ .hasMessageContaining("env");
+
+ verify(entityDynamicMapperValidator, never()).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should pass when all required properties are mapped")
+ void shouldPassWhenAllRequiredPropertiesMapped() {
+ PropertyDefinition requiredProp = buildProperty("env", true);
+ EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".environment"), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+ doNothing().when(entityTemplateValidationService).validatePropertyNameAlreadyExistInTemplate(
+ template.propertiesDefinitions(), "env");
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+
+ verify(entityDynamicMapperValidator).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should throw when multiple required properties are missing from mapping")
+ void shouldThrowWhenMultipleRequiredPropertiesMissing() {
+ PropertyDefinition prop1 = buildProperty("env", true);
+ PropertyDefinition prop2 = buildProperty("version", true);
+ EntityTemplate template = buildEntityTemplate(List.of(prop1, prop2), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(WebhookTemplateHasNoPropertiesException.class)
+ .hasMessageContaining("env")
+ .hasMessageContaining("version");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateWebhookMapping - relations validation")
+ class ValidateRelationsTests {
+
+ @Test
+ @DisplayName("Should throw RelationNameNotFoundEntityTemplateRelationsException when relation not in template")
+ void shouldThrowWhenRelationNotFoundInTemplate() {
+ RelationDefinition relation = buildRelation("owner", false);
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation));
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of("unknown-relation", ".x"));
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+ doThrow(new RelationNameNotFoundEntityTemplateRelationsException("Relation name unknown-relation not found in entity template relations"))
+ .when(entityTemplateValidationService).validateRelationNameAlreadyExistInTemplate(
+ template.relationsDefinitions(), "unknown-relation");
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(RelationNameNotFoundEntityTemplateRelationsException.class)
+ .hasMessageContaining("unknown-relation");
+
+ verify(entityDynamicMapperValidator, never()).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should throw WebhookTemplateHasNoPropertiesException when required relation is missing from mapping")
+ void shouldThrowWhenRequiredRelationMissingFromMapping() {
+ RelationDefinition requiredRelation = buildRelation("owner", true);
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation));
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(WebhookTemplateHasNoPropertiesException.class)
+ .hasMessageContaining("owner");
+
+ verify(entityDynamicMapperValidator, never()).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should pass when required relation is mapped")
+ void shouldPassWhenRequiredRelationMapped() {
+ RelationDefinition requiredRelation = buildRelation("owner", true);
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation));
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of("owner", ".ownerId"));
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+ doNothing().when(entityTemplateValidationService).validateRelationNameAlreadyExistInTemplate(
+ template.relationsDefinitions(), "owner");
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+
+ verify(entityDynamicMapperValidator).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should throw when multiple required relations are missing from mapping")
+ void shouldThrowWhenMultipleRequiredRelationsMissing() {
+ RelationDefinition rel1 = buildRelation("owner", true);
+ RelationDefinition rel2 = buildRelation("team", true);
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of(rel1, rel2));
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(WebhookTemplateHasNoPropertiesException.class)
+ .hasMessageContaining("owner")
+ .hasMessageContaining("team");
+ }
+
+ @Test
+ @DisplayName("Should skip relation validation when relations map is null")
+ void shouldSkipRelationValidationWhenNull() {
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), null);
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping)));
+
+ verify(entityTemplateValidationService, never())
+ .validateRelationNameAlreadyExistInTemplate(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
+ }
+ }
+
+ @Nested
+ @DisplayName("validateWebhookMapping - mapper validator delegation")
+ class MapperValidatorDelegationTests {
+
+ @Test
+ @DisplayName("Should delegate to entityDynamicMapperValidator after all domain checks pass")
+ void shouldDelegateToMapperValidator() {
+ EntityTemplate template = buildEntityTemplate(List.of(), List.of());
+ EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of());
+
+ doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment");
+ when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template);
+
+ service.validateWebhookMapping(List.of(mapping));
+
+ verify(entityDynamicMapperValidator).validate(mapping);
+ }
+
+ @Test
+ @DisplayName("Should NOT call entityDynamicMapperValidator when domain check throws")
+ void shouldNotCallMapperValidatorWhenDomainCheckFails() {
+ EntityDynamicMapping mapping = buildMapping("bad-template", Map.of(), Map.of());
+
+ doThrow(new EntityTemplateNotFoundException("identifier", "bad-template"))
+ .when(entityTemplateValidationService).validateTemplateExists("bad-template");
+
+ assertThatThrownBy(() -> service.validateWebhookMapping(List.of(mapping)))
+ .isInstanceOf(EntityTemplateNotFoundException.class);
+
+ verify(entityDynamicMapperValidator, never()).validate(mapping);
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java
new file mode 100644
index 0000000..657a128
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java
@@ -0,0 +1,277 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@DisplayName("WebhookConnectorService Tests")
+@ExtendWith(MockitoExtension.class)
+class WebhookConnectorServiceTest {
+
+ @Mock
+ private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort;
+
+ @Mock
+ private WebhookConnectorValidationService webhookConnectorValidationService;
+
+ private WebhookConnectorService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new WebhookConnectorService(webhookConnectorRepositoryPort, webhookConnectorValidationService);
+ }
+
+ @Nested
+ @DisplayName("getWebhookConnector")
+ class GetWebhookConnectorTests {
+
+ @Test
+ @DisplayName("Should return connector when it exists")
+ void shouldReturnConnectorWhenExists() {
+ WebhookConnector existing = buildWebhookConnector(UUID.randomUUID(), "github-dora", "GitHub DORA", "desc", true);
+ when(webhookConnectorRepositoryPort.findByIdentifier("github-dora"))
+ .thenReturn(Optional.of(existing));
+
+ WebhookConnector result = service.getWebhookConnector("github-dora");
+
+ assertThat(result).isEqualTo(existing);
+ }
+
+ @Test
+ @DisplayName("Should throw WebhookConnectorNotFoundException when not found")
+ void shouldThrowWhenConnectorNotFound() {
+ when(webhookConnectorRepositoryPort.findByIdentifier("unknown"))
+ .thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.getWebhookConnector("unknown"))
+ .isInstanceOf(WebhookConnectorNotFoundException.class)
+ .hasMessageContaining("unknown");
+ }
+ }
+
+ @Nested
+ @DisplayName("createWebhookConnector")
+ class CreateWebhookConnectorTests {
+
+ @Test
+ @DisplayName("Should validate then save and return the connector")
+ void shouldValidateAndSave() {
+ WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", true);
+ WebhookConnector saved = buildWebhookConnector(UUID.randomUUID(), "github-dora", "GitHub DORA", "desc", true);
+ when(webhookConnectorRepositoryPort.save(any())).thenReturn(saved);
+
+ WebhookConnector result = service.createWebhookConnector(toCreate);
+
+ verify(webhookConnectorValidationService).validateWebhookConnectorForCreation(toCreate);
+ verify(webhookConnectorRepositoryPort).save(toCreate);
+ assertThat(result.id()).isNotNull();
+ assertThat(result.identifier()).isEqualTo("github-dora");
+ }
+
+ @Test
+ @DisplayName("Should NOT save when validation throws")
+ void shouldNotSaveWhenValidationFails() {
+ WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", true);
+ org.mockito.Mockito.doThrow(new RuntimeException("validation error"))
+ .when(webhookConnectorValidationService).validateWebhookConnectorForCreation(toCreate);
+
+ assertThatThrownBy(() -> service.createWebhookConnector(toCreate))
+ .hasMessageContaining("validation error");
+
+ verify(webhookConnectorRepositoryPort, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("updateWebhookConnector")
+ class UpdateWebhookConnectorTests {
+
+ private static final UUID EXISTING_ID = UUID.randomUUID();
+ private static final String IDENTIFIER = "github-dora";
+
+ @Test
+ @DisplayName("Should preserve id and identifier from the stored connector")
+ void shouldPreserveIdAndIdentifier() {
+ WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", "Old desc", true);
+ WebhookConnector incoming = buildWebhookConnector(null, "ignored-from-body", "New title", "New desc", false);
+
+ when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)).thenReturn(Optional.of(existing));
+ when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ WebhookConnector result = service.updateWebhookConnector(IDENTIFIER, incoming);
+
+ assertThat(result.id()).isEqualTo(EXISTING_ID);
+ assertThat(result.identifier()).isEqualTo(IDENTIFIER);
+ }
+
+ @Test
+ @DisplayName("Should apply updated fields from the incoming connector")
+ void shouldApplyIncomingFields() {
+ WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", "Old desc", true);
+ WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", false);
+
+ when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)).thenReturn(Optional.of(existing));
+ when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ WebhookConnector result = service.updateWebhookConnector(IDENTIFIER, incoming);
+
+ assertThat(result.title()).isEqualTo("New title");
+ assertThat(result.description()).isEqualTo("New desc");
+ assertThat(result.enabled()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should delegate validation before saving")
+ void shouldDelegateValidationBeforeSave() {
+ WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", "Old desc", true);
+ WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", false);
+
+ when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)).thenReturn(Optional.of(existing));
+ when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.updateWebhookConnector(IDENTIFIER, incoming);
+
+ InOrder order = org.mockito.Mockito.inOrder(webhookConnectorValidationService, webhookConnectorRepositoryPort);
+ order.verify(webhookConnectorValidationService).validateWebhookConnectorForUpdate(existing, incoming);
+ order.verify(webhookConnectorRepositoryPort).save(any());
+ }
+
+ @Test
+ @DisplayName("Should save the merged connector with correct fields")
+ void shouldSaveMergedConnector() {
+ WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", "Old desc", true);
+ WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", false);
+
+ when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)).thenReturn(Optional.of(existing));
+ when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.updateWebhookConnector(IDENTIFIER, incoming);
+
+ var captor = ArgumentCaptor.forClass(WebhookConnector.class);
+ verify(webhookConnectorRepositoryPort).save(captor.capture());
+ var saved = captor.getValue();
+
+ assertThat(saved.id()).isEqualTo(EXISTING_ID);
+ assertThat(saved.identifier()).isEqualTo(IDENTIFIER);
+ assertThat(saved.title()).isEqualTo("New title");
+ assertThat(saved.description()).isEqualTo("New desc");
+ assertThat(saved.enabled()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should throw WebhookConnectorNotFoundException when connector is missing")
+ void shouldThrowWhenConnectorMissing() {
+ var incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", true);
+ when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.updateWebhookConnector(IDENTIFIER, incoming))
+ .isInstanceOf(WebhookConnectorNotFoundException.class)
+ .hasMessageContaining(IDENTIFIER);
+
+ verify(webhookConnectorValidationService, never()).validateWebhookConnectorForUpdate(any(), any());
+ verify(webhookConnectorRepositoryPort, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("deleteWebhookConnector")
+ class DeleteWebhookConnectorTests {
+
+ @Test
+ @DisplayName("Should validate existence then delete")
+ void shouldValidateAndDelete() {
+ service.deleteWebhookConnector("github-dora");
+
+ var order = org.mockito.Mockito.inOrder(webhookConnectorValidationService, webhookConnectorRepositoryPort);
+ order.verify(webhookConnectorValidationService).validateIdentifierExists("github-dora");
+ order.verify(webhookConnectorRepositoryPort).deleteByIdentifier("github-dora");
+ }
+
+ @Test
+ @DisplayName("Should NOT delete when validation throws")
+ void shouldNotDeleteWhenValidationFails() {
+ org.mockito.Mockito.doThrow(new WebhookConnectorNotFoundException("github-dora not found"))
+ .when(webhookConnectorValidationService).validateIdentifierExists("github-dora");
+
+ assertThatThrownBy(() -> service.deleteWebhookConnector("github-dora"))
+ .isInstanceOf(WebhookConnectorNotFoundException.class);
+
+ verify(webhookConnectorRepositoryPort, never()).deleteByIdentifier(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("getAllWebhookConnector")
+ class GetAllWebhookConnectorTests {
+
+ @Test
+ @DisplayName("Should return paginated connectors from repository")
+ void shouldReturnPaginatedConnectors() {
+ PageRequest pageable = PageRequest.of(0, 10);
+ WebhookConnector c1 = buildWebhookConnector(UUID.randomUUID(), "connector-a", "A", "desc", true);
+ WebhookConnector c2 = buildWebhookConnector(UUID.randomUUID(), "connector-b", "B", "desc", false);
+ var page = new PageImpl<>(List.of(c1, c2), pageable, 2);
+ when(webhookConnectorRepositoryPort.findAll(pageable)).thenReturn(page);
+
+ Page result = service.getAllWebhookConnector(pageable);
+
+ assertThat(result.getContent()).hasSize(2);
+ assertThat(result.getTotalElements()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("Should return empty page when no connectors exist")
+ void shouldReturnEmptyPage() {
+ Pageable pageable = PageRequest.of(0, 10);
+ when(webhookConnectorRepositoryPort.findAll(pageable))
+ .thenReturn(new PageImpl<>(List.of(), pageable, 0));
+
+ Page result = service.getAllWebhookConnector(pageable);
+
+ assertThat(result.getContent()).isEmpty();
+ assertThat(result.getTotalElements()).isZero();
+ }
+ }
+
+ private WebhookConnector buildWebhookConnector(UUID id, String identifier, String title, String description, boolean enabled) {
+ EntityDynamicMapping mapping = new EntityDynamicMapping(
+ "deployment",
+ ".eventType == \"DEPLOYED\"",
+ ".id",
+ ".name",
+ Map.of("environment", ".env"),
+ Map.of()
+ );
+ WebhookSecurity security = new WebhookSecurity(
+ WebhookSecurityType.HMAC_SHA256,
+ Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")
+ );
+ return new WebhookConnector(id, identifier, title, description, enabled, List.of(mapping), security);
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java
new file mode 100644
index 0000000..7b1e914
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java
@@ -0,0 +1,237 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorTitleAlreadyExistsException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@DisplayName("WebhookConnectorValidationService Tests")
+@ExtendWith(MockitoExtension.class)
+class WebhookConnectorValidationServiceTest {
+
+ @Mock
+ private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort;
+
+ @Mock
+ private EntityDynamicMappingValidationService webhookConnectorMappingValidationService;
+
+ @Mock
+ private WebhookSecurityValidationService webhookSecurityValidationService;
+
+ private WebhookConnectorValidationService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new WebhookConnectorValidationService(
+ webhookConnectorRepositoryPort,
+ webhookConnectorMappingValidationService,
+ webhookSecurityValidationService
+ );
+ }
+
+ @Nested
+ @DisplayName("validateWebhookConnectorForCreation")
+ class ValidateWebhookConnectorForCreationTests {
+
+ @Test
+ @DisplayName("Should validate uniqueness, mapping and security for creation")
+ void shouldValidateAllChecksForCreation() {
+ WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA");
+ when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false);
+ when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(false);
+
+ service.validateWebhookConnectorForCreation(connector);
+
+ var order = inOrder(
+ webhookConnectorRepositoryPort,
+ webhookConnectorMappingValidationService,
+ webhookSecurityValidationService
+ );
+ order.verify(webhookConnectorRepositoryPort).existsByIdentifier("github-dora");
+ order.verify(webhookConnectorRepositoryPort).existsByTitle("GitHub DORA");
+ order.verify(webhookConnectorMappingValidationService).validateWebhookMapping(connector.mappings());
+ order.verify(webhookSecurityValidationService).validateForCreation(connector.security());
+ }
+
+ @Test
+ @DisplayName("Should throw when identifier already exists and stop validation chain")
+ void shouldThrowWhenIdentifierAlreadyExists() {
+ WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA");
+ when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(true);
+
+ assertThatThrownBy(() -> service.validateWebhookConnectorForCreation(connector))
+ .isInstanceOf(WebhookConnectorAlreadyExistException.class)
+ .hasMessageContaining("github-dora");
+
+ verify(webhookConnectorRepositoryPort, never()).existsByTitle(any());
+ verify(webhookConnectorMappingValidationService, never()).validateWebhookMapping(any());
+ verify(webhookSecurityValidationService, never()).validateForCreation(any());
+ }
+
+ @Test
+ @DisplayName("Should throw when title already exists and skip mapping/security validation")
+ void shouldThrowWhenTitleAlreadyExists() {
+ WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA");
+ when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false);
+ when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(true);
+
+ assertThatThrownBy(() -> service.validateWebhookConnectorForCreation(connector))
+ .isInstanceOf(WebhookConnectorTitleAlreadyExistsException.class)
+ .hasMessageContaining("GitHub DORA");
+
+ verify(webhookConnectorMappingValidationService, never()).validateWebhookMapping(any());
+ verify(webhookSecurityValidationService, never()).validateForCreation(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("validateWebhookConnectorForUpdate")
+ class ValidateWebhookConnectorForUpdateTests {
+
+ @Test
+ @DisplayName("Should validate title uniqueness when title changes")
+ void shouldValidateTitleUniquenessWhenTitleChanges() {
+ WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Old title");
+ WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "New title");
+ when(webhookConnectorRepositoryPort.existsByTitle("New title")).thenReturn(false);
+
+ service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate);
+
+ verify(webhookConnectorRepositoryPort).existsByTitle("New title");
+ verify(webhookConnectorMappingValidationService).validateWebhookMapping(connectorToUpdate.mappings());
+ verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security());
+ }
+
+ @Test
+ @DisplayName("Should skip title uniqueness check when title is unchanged")
+ void shouldSkipTitleUniquenessWhenTitleIsUnchanged() {
+ WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Same title");
+ WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Same title");
+
+ service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate);
+
+ verify(webhookConnectorRepositoryPort, never()).existsByTitle(any());
+ verify(webhookConnectorMappingValidationService).validateWebhookMapping(connectorToUpdate.mappings());
+ verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security());
+ }
+
+ @Test
+ @DisplayName("Should throw when changed title already exists and stop validation chain")
+ void shouldThrowWhenChangedTitleAlreadyExists() {
+ WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Old title");
+ WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Taken title");
+ when(webhookConnectorRepositoryPort.existsByTitle("Taken title")).thenReturn(true);
+
+ assertThatThrownBy(() -> service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate))
+ .isInstanceOf(WebhookConnectorTitleAlreadyExistsException.class)
+ .hasMessageContaining("Taken title");
+
+ verify(webhookConnectorMappingValidationService, never()).validateWebhookMapping(any());
+ verify(webhookSecurityValidationService, never()).validateForCreation(any());
+ }
+
+ @Test
+ @DisplayName("Should propagate mapping validation exception and skip security validation")
+ void shouldPropagateMappingValidationException() {
+ WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Same title");
+ WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Same title");
+ doThrow(new RuntimeException("invalid mapping"))
+ .when(webhookConnectorMappingValidationService).validateWebhookMapping(connectorToUpdate.mappings());
+
+ assertThatThrownBy(() -> service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("invalid mapping");
+
+ verify(webhookSecurityValidationService, never()).validateForCreation(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("validateTitleUniqueness")
+ class ValidateTitleUniquenessTests {
+
+ @Test
+ @DisplayName("Should pass when title is unique")
+ void shouldPassWhenTitleIsUnique() {
+ when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(false);
+
+ assertThatCode(() -> service.validateTitleUniqueness("GitHub DORA"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw when title already exists")
+ void shouldThrowWhenTitleAlreadyExists() {
+ when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(true);
+
+ assertThatThrownBy(() -> service.validateTitleUniqueness("GitHub DORA"))
+ .isInstanceOf(WebhookConnectorTitleAlreadyExistsException.class)
+ .hasMessageContaining("GitHub DORA");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateIdentifierExists")
+ class ValidateIdentifierExistsTests {
+
+ @Test
+ @DisplayName("Should pass when identifier exists")
+ void shouldPassWhenIdentifierExists() {
+ when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(true);
+
+ assertThatCode(() -> service.validateIdentifierExists("github-dora"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw when identifier does not exist")
+ void shouldThrowWhenIdentifierDoesNotExist() {
+ when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false);
+
+ assertThatThrownBy(() -> service.validateIdentifierExists("github-dora"))
+ .isInstanceOf(WebhookConnectorNotFoundException.class)
+ .hasMessageContaining("github-dora");
+ }
+ }
+
+ private WebhookConnector buildWebhookConnector(String identifier, String title) {
+ EntityDynamicMapping mapping = new EntityDynamicMapping(
+ "deployment",
+ ".eventType == \"DEPLOYED\"",
+ ".id",
+ ".name",
+ Map.of("environment", ".env"),
+ Map.of()
+ );
+ WebhookSecurity security = new WebhookSecurity(
+ WebhookSecurityType.HMAC_SHA256,
+ Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")
+ );
+ return new WebhookConnector(UUID.randomUUID(), identifier, title, "desc", true, List.of(mapping), security);
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java
new file mode 100644
index 0000000..410972e
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java
@@ -0,0 +1,121 @@
+package com.decathlon.idp_core.domain.service.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+
+@DisplayName("WebhookSecurityValidationService Tests")
+@ExtendWith(MockitoExtension.class)
+class WebhookSecurityValidationServiceTest {
+
+ @Mock
+ private WebhookSecurityStrategy hmacCreationValidator;
+
+ private WebhookSecurityValidationService service;
+
+ @BeforeEach
+ void setUp() {
+ lenient().when(hmacCreationValidator.supports("HMAC_SHA256")).thenReturn(true);
+ service = new WebhookSecurityValidationService(List.of(hmacCreationValidator));
+ }
+
+ @Nested
+ @DisplayName("validateForCreation — null/blank guards")
+ class NullBlankGuards {
+
+ @Test
+ @DisplayName("Should throw when security is null")
+ void shouldThrowWhenSecurityIsNull() {
+ assertThatThrownBy(() -> service.validateForCreation(null))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("mandatory");
+ }
+
+ @Test
+ @DisplayName("Should throw when security type is null")
+ void shouldThrowWhenTypeIsNull() {
+ assertThatThrownBy(() -> new WebhookSecurity(null, Map.of("k", "v")))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("type is mandatory");
+ }
+
+ @Test
+ @DisplayName("Should throw when config is null")
+ void shouldThrowWhenConfigIsNull() {
+ assertThatThrownBy(() -> new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, null))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("config section is mandatory");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateForCreation — known type delegation")
+ class KnownTypeDelegation {
+
+ @Test
+ @DisplayName("Should delegate to the matching creation validator for HMAC_SHA256")
+ void shouldDelegateToHmacValidator() {
+ var config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET");
+ var security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, config);
+
+ assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException();
+
+ verify(hmacCreationValidator).validateConfiguration(config);
+ }
+ }
+
+ @Nested
+ @DisplayName("validateForCreation — NONE type")
+ class NoneTypeValidation {
+
+ @Test
+ @DisplayName("Should pass for NONE type with empty config")
+ void shouldPassForNoneTypeWithEmptyConfig() {
+ var security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of());
+
+ assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should throw for NONE type with non-empty config")
+ void shouldThrowForNoneTypeWithNonEmptyConfig() {
+ var security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of("header_name", "X-Test"));
+
+ assertThatThrownBy(() -> service.validateForCreation(security))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("must be empty when type is NONE");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateForCreation — unregistered type")
+ class UnregisteredType {
+
+ @Test
+ @DisplayName("Should throw when no validator is registered for the given type")
+ void shouldThrowForUnregisteredType() {
+ lenient().when(hmacCreationValidator.supports("JWT_BEARER")).thenReturn(false);
+ var security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER, Map.of("jwks_uri", "https://example.com/.well-known/jwks"));
+
+ assertThatThrownBy(() -> service.validateForCreation(security))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("No validator registered");
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java
new file mode 100644
index 0000000..ce48df6
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java
@@ -0,0 +1,442 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.controller;
+
+import com.decathlon.idp_core.AbstractIntegrationTest;
+import com.decathlon.idp_core.domain.constant.ValidationMessages;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+/// Integration tests for {@link InboundWebhookManagementController}.
+///
+/// Covers the full HTTP contract (status codes, response shape, validation errors)
+/// for all CRUD operations on inbound webhook connectors.
+@DisplayName("InboundWebhookManagementController Integration Tests")
+@TestClassOrder(ClassOrderer.OrderAnnotation.class)
+@Sql(statements = {
+ "DELETE FROM webhook_template_mapping",
+ "DELETE FROM webhook_connector"
+}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+@Sql(scripts = "/db/test/R__1_Insert_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+@Slf4j
+class InboundWebhookManagementControllerTest extends AbstractIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ private static final String WEBHOOK_PATH = "/api/v1/inbound-webhooks";
+ private static final String JSON_PATH = "integration_test/json/webhook/v1/";
+
+ private void createWebhookConnector(String identifier, String title) throws Exception {
+ var payload = """
+ {
+ "identifier": "%s",
+ "title": "%s",
+ "description": "test connector",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "true",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": ".language"
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "NONE",
+ "config": {}
+ }
+ }
+ """.formatted(identifier, title);
+
+ mockMvc.perform(MockMvcRequestBuilders.post(WEBHOOK_PATH)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(payload))
+ .andExpect(status().isCreated());
+ }
+
+ private String buildPutPayload(String title, boolean enabled) {
+ return """
+ {
+ "identifier": "ignored-by-put",
+ "title": "%s",
+ "description": "updated description",
+ "enabled": %s,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "true",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": ".language"
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "STATIC_TOKEN",
+ "config": {
+ "header_name": "X-Token",
+ "secret_alias": "MY_TOKEN"
+ }
+ }
+ }
+ """.formatted(title, enabled);
+ }
+
+ @Nested
+ @DisplayName("GET /api/v1/inbound-webhooks")
+ @Order(1)
+ class GetWebhooksPaginatedTests {
+
+ @Test
+ @DisplayName("Should return 401 without authentication")
+ void getWebhooks_401_without_user_token() throws Exception {
+ mockMvc.perform(get(WEBHOOK_PATH).accept(APPLICATION_JSON))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 200 with empty page when no connectors exist")
+ void getWebhooks_200_empty_page() throws Exception {
+ mockMvc.perform(get(WEBHOOK_PATH).accept(APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType(APPLICATION_JSON))
+ .andExpect(jsonPath("$.content").isArray());
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /api/v1/inbound-webhooks - Create connector")
+ @Order(2)
+ class PostWebhookTests {
+
+ @Test
+ @DisplayName("Should return 401 without authentication")
+ void postWebhook_401_without_user_token() throws Exception {
+ mockMvc.perform(MockMvcRequestBuilders.post(WEBHOOK_PATH)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json")))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should create connector and return 201")
+ void postWebhook_201() throws Exception {
+ mockMvc.perform(MockMvcRequestBuilders.post(WEBHOOK_PATH)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json")))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.identifier").value("github-dora-connector"))
+ .andExpect(jsonPath("$.title").value("GitHub DORA Connector"))
+ .andExpect(jsonPath("$.security.type").value("HMAC_SHA256"));
+
+ assertWebhookTemplateMapping("github-dora-connector", "microservice", ".action == \"deployment\"");
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 409 when identifier already exists")
+ void postWebhook_409_identifier_already_exists() throws Exception {
+ createWebhookConnector("github-dora-connector", "GitHub DORA Connector");
+
+ mockMvc.perform(MockMvcRequestBuilders.post(WEBHOOK_PATH)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(getJsonTestFileContent(JSON_PATH + "postWebhook_409_identifier_already_exists.json")))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.error").value("CONFLICT"))
+ .andExpect(jsonPath("$.error_description").value(
+ containsString(ValidationMessages.WEBHOOK_CONNECTOR_ALREADY_EXIST)));
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 400 when identifier is missing")
+ void postWebhook_400_identifier_missing() throws Exception {
+ var result = postBadRequestAndAssertEquals(
+ WEBHOOK_PATH,
+ JSON_PATH + "postWebhook_400_identifier_missing.json",
+ ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY);
+ assertNotNull(result);
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 400 when identifier is blank")
+ void postWebhook_400_identifier_blank() throws Exception {
+ var result = postBadRequestAndAssertEquals(
+ WEBHOOK_PATH,
+ JSON_PATH + "postWebhook_400_identifier_blank.json",
+ ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY);
+ assertNotNull(result);
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 400 when mappings array is empty")
+ void postWebhook_400_mappings_empty() throws Exception {
+ var result = postBadRequestAndAssertEquals(
+ WEBHOOK_PATH,
+ JSON_PATH + "postWebhook_400_mappings_empty.json",
+ ValidationMessages.WEBHOOK_CONNECTOR_MAPPINGS_MANDATORY);
+ assertNotNull(result);
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 400 when security type is unknown")
+ void postWebhook_400_invalid_security_type() throws Exception {
+ var result = postBadRequestAndAssertContains(
+ WEBHOOK_PATH,
+ JSON_PATH + "postWebhook_400_invalid_security_type.json",
+ "UNKNOWN_TYPE");
+ assertNotNull(result);
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 400 when JSLT expression is invalid")
+ void postWebhook_400_invalid_jslt() throws Exception {
+ var result = postBadRequestAndAssertContains(
+ WEBHOOK_PATH,
+ JSON_PATH + "postWebhook_400_invalid_jslt.json",
+ "Invalid webhook mapping configuration");
+ assertNotNull(result);
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/v1/inbound-webhooks/{identifier} - Get by identifier")
+ @Order(3)
+ class GetWebhookByIdentifierTests {
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 200 with connector details")
+ void getWebhook_200() throws Exception {
+ mockMvc.perform(MockMvcRequestBuilders.post(WEBHOOK_PATH)
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json")))
+ .andExpect(status().isCreated());
+
+ mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.identifier").value("github-dora-connector"))
+ .andExpect(jsonPath("$.security.type").value("HMAC_SHA256"));
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 404 when connector does not exist")
+ void getWebhook_404_not_found() throws Exception {
+ mockMvc.perform(get(WEBHOOK_PATH + "/non-existent-connector").accept(APPLICATION_JSON))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.error").value("NOT_FOUND"))
+ .andExpect(jsonPath("$.error_description").exists());
+ }
+
+ @Test
+ @DisplayName("Should return 401 without authentication")
+ void getWebhook_401_without_user_token() throws Exception {
+ mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON))
+ .andExpect(status().isUnauthorized());
+ }
+ }
+
+ @Nested
+ @DisplayName("PUT /api/v1/inbound-webhooks/{identifier} - Update connector")
+ @Order(4)
+ class PutWebhookTests {
+
+ @Test
+ @DisplayName("Should return 401 without authentication")
+ void putWebhook_401_without_user_token() throws Exception {
+ mockMvc.perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/github-dora-connector")
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(getJsonTestFileContent(JSON_PATH + "putWebhook_200.json")))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should update connector and return 200")
+ void putWebhook_200() throws Exception {
+ createWebhookConnector("connector-put-200", "Connector Put Initial Title");
+
+ mockMvc.perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-200")
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(buildPutPayload("Connector Put Updated Title", false)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.identifier").value("connector-put-200"))
+ .andExpect(jsonPath("$.title").value("Connector Put Updated Title"))
+ .andExpect(jsonPath("$.enabled").value(false))
+ .andExpect(jsonPath("$.security.type").value("STATIC_TOKEN"));
+
+ mockMvc.perform(get(WEBHOOK_PATH + "/connector-put-200").accept(APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.title").value("Connector Put Updated Title"))
+ .andExpect(jsonPath("$.enabled").value(false));
+
+ assertWebhookTemplateMapping("connector-put-200", "microservice", "true");
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 409 when updating title to an existing one")
+ void putWebhook_409_title_already_exists() throws Exception {
+ createWebhookConnector("connector-put-409-a", "Connector A Title");
+ createWebhookConnector("connector-put-409-b", "Connector B Title");
+
+ mockMvc.perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-409-b")
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(buildPutPayload("Connector A Title", true)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.error").value("CONFLICT"))
+ .andExpect(jsonPath("$.error_description").value(containsString("already exists")));
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 404 when connector does not exist")
+ void putWebhook_404_not_found() throws Exception {
+ mockMvc.perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/non-existent-connector")
+ .contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON)
+ .with(csrf())
+ .content(buildPutPayload("Updated Title", false)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.error").value("NOT_FOUND"))
+ .andExpect(jsonPath("$.error_description").exists());
+ }
+ }
+
+
+ @Nested
+ @DisplayName("DELETE /api/v1/inbound-webhooks/{identifier} - Delete connector")
+ @Order(5)
+ class DeleteWebhookTests {
+
+ @Test
+ @DisplayName("Should return 401 without authentication")
+ void deleteWebhook_401_without_user_token() throws Exception {
+ mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/github-dora-connector")
+ .accept(APPLICATION_JSON)
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should delete connector and return 204")
+ void deleteWebhook_204() throws Exception {
+ createWebhookConnector("connector-delete-204", "Connector To Delete");
+
+ mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/connector-delete-204")
+ .accept(APPLICATION_JSON)
+ .with(csrf()))
+ .andExpect(status().isNoContent());
+
+ mockMvc.perform(get(WEBHOOK_PATH + "/connector-delete-204").accept(APPLICATION_JSON))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.error").value("NOT_FOUND"));
+
+ assertWebhookTemplateMappingCount("connector-delete-204", 0L);
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("Should return 404 when connector does not exist")
+ void deleteWebhook_404_not_found() throws Exception {
+ mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/non-existent-connector")
+ .accept(APPLICATION_JSON)
+ .with(csrf()))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.error").value("NOT_FOUND"))
+ .andExpect(jsonPath("$.error_description").exists());
+ }
+ }
+
+ private void assertWebhookTemplateMappingCount(String identifier, long expectedCount) {
+ Long count = jdbcTemplate.queryForObject(
+ """
+ SELECT COUNT(*)
+ FROM webhook_template_mapping wtm
+ JOIN webhook_connector wc ON wc.id = wtm.webhook_id
+ WHERE wc.identifier = ?
+ """,
+ Long.class,
+ identifier
+ );
+
+ org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount);
+ }
+
+ private void assertWebhookTemplateMapping(String identifier, String templateIdentifier, String filter) {
+ assertWebhookTemplateMappingCount(identifier, 1L);
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT et.identifier AS template_identifier, wtm.jslt_filter AS jslt_filter
+ FROM idp_core.webhook_template_mapping wtm
+ JOIN idp_core.webhook_connector wc ON wc.id = wtm.webhook_id
+ JOIN idp_core.entity_template et ON et.id = wtm.template_id
+ WHERE wc.identifier = ?
+ """,
+ identifier
+ );
+
+ org.assertj.core.api.Assertions.assertThat(row.get("template_identifier")).isEqualTo(templateIdentifier);
+ org.assertj.core.api.Assertions.assertThat(row.get("jslt_filter")).isEqualTo(filter);
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java
index 880ade3..43e936e 100644
--- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java
@@ -27,7 +27,12 @@
import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException;
import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException;
+import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException;
+import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException;
+import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookTemplateHasNoPropertiesException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@@ -111,6 +116,90 @@ void shouldHandleEntityTemplateAlreadyExistsException() {
@DisplayName("Validation Exception Handling")
class ValidationExceptionTests {
+ @Test
+ @DisplayName("Should handle EntityDynamicMappingConfigurationException with 400 status")
+ void shouldHandleEntityDynamicMappingConfigurationException() {
+ String details = "Syntax Error in 'properties.deployment_id': Parse error";
+ EntityDynamicMappingConfigurationException exception = new EntityDynamicMappingConfigurationException(details);
+
+ ResponseEntity response = exceptionHandler.handleEntityDynamicMappingConfigurationException(exception);
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ ErrorResponse body = response.getBody();
+ assertNotNull(body);
+ assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError());
+ assertEquals("Invalid webhook mapping configuration: " + details, body.getErrorDescription());
+ }
+
+ @Test
+ @DisplayName("Should handle PropertyNameNotFoundEntityTemplatePropertiesException with 400 status")
+ void shouldHandlePropertyNameNotFoundEntityTemplatePropertiesException() {
+ String details = "Property name additionalProp3 not found in entity template properties";
+ PropertyNameNotFoundEntityTemplatePropertiesException exception = new PropertyNameNotFoundEntityTemplatePropertiesException(details);
+
+
+ ResponseEntity response = exceptionHandler.handlePropertyNameNotFoundEntityTemplatePropertiesException(exception);
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ ErrorResponse body = response.getBody();
+ assertNotNull(body);
+ assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError());
+ assertEquals(details, body.getErrorDescription());
+ }
+
+ @Test
+ @DisplayName("Should handle RelationNameNotFoundEntityTemplateRelationsException with 400 status")
+ void shouldHandleRelationNameNotFoundEntityTemplateRelationsException() {
+ // Given
+ String details = "Relation name github_repository not found in entity template relations";
+ RelationNameNotFoundEntityTemplateRelationsException exception = new RelationNameNotFoundEntityTemplateRelationsException(details);
+
+ // When
+ ResponseEntity response = exceptionHandler.handleRelationNameNotFoundEntityTemplateRelationsException(exception);
+
+ // Then
+ assertNotNull(response);
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ ErrorResponse body = response.getBody();
+ assertNotNull(body);
+ assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError());
+ assertEquals(details, body.getErrorDescription());
+ }
+
+ @Test
+ @DisplayName("Should handle WebhookTemplateHasNoPropertiesException with 400 status")
+ void shouldHandleWebhookTemplateHasNoPropertiesException() {
+ String details = "The mapping defines properties but the target template has no property definitions";
+ WebhookTemplateHasNoPropertiesException exception = new WebhookTemplateHasNoPropertiesException(details);
+
+ ResponseEntity response = exceptionHandler.handleWebhookTemplateHasNoPropertiesException(exception);
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ ErrorResponse body = response.getBody();
+ assertNotNull(body);
+ assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError());
+ assertEquals(details, body.getErrorDescription());
+ }
+
+ @Test
+ @DisplayName("Should handle WebhookSecurityConfigurationException with 400 status")
+ void shouldHandleWebhookSecurityConfigurationException() {
+ String details = "Webhook security type is mandatory";
+ WebhookSecurityConfigurationException exception = new WebhookSecurityConfigurationException(details);
+
+ ResponseEntity response = exceptionHandler.handleWebhookSecurityConfigurationException(exception);
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ ErrorResponse body = response.getBody();
+ assertNotNull(body);
+ assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError());
+ assertEquals(details, body.getErrorDescription());
+ }
+
/// Tests the handling of [ConstraintViolationException] with a single validation violation.
///
/// **This test verifies that:**
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java
new file mode 100644
index 0000000..0453c58
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java
@@ -0,0 +1,125 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook;
+
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookConnector;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn;import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookEntityMappingDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookMappingDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("InboundWebhookMapper Tests")
+class InboundWebhookMapperTest {
+
+ private final InboundWebhookMapper mapper = new InboundWebhookMapper();
+
+ @Test
+ @DisplayName("Should use path identifier for update mapping")
+ void shouldUsePathIdentifierForUpdateMapping() {
+ var request = new InboundWebhookCreateDtoIn(
+ "identifier_from_body",
+ "GitHub DORA",
+ "Collect deployment events",
+ true,
+ List.of(new InboundWebhookMappingDtoIn(
+ "deployment",
+ ".eventType == \"DEPLOYED\"",
+ new InboundWebhookEntityMappingDtoIn(
+ ".id",
+ ".name",
+ Map.of("environment", ".env"),
+ Map.of("service", ".service")
+ )
+ )),
+ new InboundWebhookSecurityContractDtoIn(
+ "HMAC_SHA256",
+ Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET", "prefix", "sha256=")
+ )
+ );
+
+ WebhookConnector domain = mapper.toDomainForUpdate("identifier_from_path", request);
+
+ assertThat(domain.id()).isNull();
+ assertThat(domain.identifier()).isEqualTo("identifier_from_path");
+ assertThat(domain.title()).isEqualTo("GitHub DORA");
+ assertThat(domain.mappings()).hasSize(1);
+ assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.HMAC_SHA256);
+ assertThat(domain.security().config()).containsEntry("prefix", "sha256=");
+ }
+
+ @Test
+ @DisplayName("Should throw for unknown security type")
+ void shouldThrowForUnknownSecurityType() {
+ var request = new InboundWebhookCreateDtoIn(
+ "my-connector",
+ "Custom Security",
+ "Uses custom security",
+ true,
+ List.of(new InboundWebhookMappingDtoIn(
+ "deployment",
+ "true",
+ new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of())
+ )),
+ new InboundWebhookSecurityContractDtoIn(
+ "CUSTOM_UNKNOWN_TYPE",
+ Map.of("customKey", "customValue")
+ )
+ );
+
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> mapper.toDomain(request))
+ .isInstanceOf(com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("CUSTOM_UNKNOWN_TYPE");
+ }
+
+ @Test
+ @DisplayName("Should map NONE security type explicitly")
+ void shouldMapNoneSecurityTypeExplicitly() {
+ var request = new InboundWebhookCreateDtoIn(
+ "my-connector",
+ "No Auth",
+ "Webhook without authentication",
+ true,
+ List.of(new InboundWebhookMappingDtoIn(
+ "deployment",
+ "true",
+ new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of())
+ )),
+ new InboundWebhookSecurityContractDtoIn(
+ "NONE",
+ Map.of()
+ )
+ );
+
+ var domain = mapper.toDomain(request);
+
+ assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE);
+ assertThat(domain.security().config()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should default to NONE when security section is missing")
+ void shouldDefaultToNoneWhenSecurityIsMissing() {
+ var request = new InboundWebhookCreateDtoIn(
+ "my-connector",
+ "No Auth",
+ "Webhook without authentication",
+ true,
+ List.of(new InboundWebhookMappingDtoIn(
+ "deployment",
+ "true",
+ new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of())
+ )),
+ null
+ );
+
+ var domain = mapper.toDomain(request);
+
+ assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE);
+ assertThat(domain.security().config()).isEmpty();
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java
new file mode 100644
index 0000000..1fd4aca
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java
@@ -0,0 +1,106 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("Runtime Security: Basic Auth Validator")
+class BasicAuthSecurityValidatorTest {
+
+ private BasicAuthSecurityValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new BasicAuthSecurityValidator();
+ }
+
+ @Test
+ @DisplayName("supports() -> True for 'BASIC_AUTH' (case-insensitive), False for others")
+ void shouldReturnTrueWhenTypeIsBasicAuth() {
+ assertThat(validator.supports("BASIC_AUTH")).isTrue();
+ assertThat(validator.supports("basic_auth")).isTrue();
+ assertThat(validator.supports("HMAC_SHA256")).isFalse();
+ }
+
+ @Nested
+ @DisplayName("Execution: Header Parsing and Secret Resolution")
+ class SecretResolution {
+
+ @Test
+ @DisplayName("validateRequest() -> Processes valid Basic header but throws when secret environment variable is missing")
+ void shouldProcessHeaderAndThrowWhenEnvironmentSecretIsMissing() {
+ String username = "webhook-user";
+ String nonSecretPasswordPlaceholder = "test-password-placeholder";
+ String alias = "TEST_MISSING_PASSWORD_ALIAS";
+
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.BASIC_AUTH, Map.of("username", username, "secret_alias", alias));
+ Map headers = Map.of("Authorization", "Basic " + Base64.getEncoder().encodeToString((username + ":" + nonSecretPasswordPlaceholder).getBytes(StandardCharsets.UTF_8)));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Missing environment secret for alias");
+ }
+ }
+
+ @Nested
+ @DisplayName("Execution: Invalid or Missing Authorization Headers")
+ class InvalidHeaders {
+
+ @Test
+ @DisplayName("validateRequest() -> Throws exception when 'Authorization' header is completely missing")
+ void shouldThrowExceptionWhenAuthorizationHeaderIsMissing() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.BASIC_AUTH,
+ Map.of("username", "user", "secret_alias", "TEST_MISSING_BASIC_AUTH_ALIAS"));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, Map.of(), new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class);
+ }
+
+ @Test
+ @DisplayName("validateRequest() -> Throws exception when 'Authorization' header does not use the 'Basic' scheme")
+ void shouldThrowExceptionWhenAuthorizationHeaderIsNotBasicScheme() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.BASIC_AUTH,
+ Map.of("username", "user", "secret_alias", "TEST_MISSING_BASIC_AUTH_ALIAS"));
+ Map headers = Map.of("Authorization", "Bearer some.jwt.token");
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("Configuration Fallback: Missing Required Config Keys (Runtime Safety)")
+ class MissingConfigKeys {
+
+ @Test
+ @DisplayName("validateRequest() -> Throws exception when 'username' is missing from the stored configuration")
+ void shouldThrowExceptionWhenUsernameIsMissingFromConfig() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.BASIC_AUTH, Map.of("secret_alias", "MY_ALIAS"));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, Map.of(), new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("username");
+ }
+
+ @Test
+ @DisplayName("validateRequest() -> Throws exception when 'secret_alias' is missing from the stored configuration")
+ void shouldThrowExceptionWhenSecretAliasIsMissingFromConfig() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.BASIC_AUTH, Map.of("username", "user"));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, Map.of(), new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("secret_alias");
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java
new file mode 100644
index 0000000..93b9aec
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java
@@ -0,0 +1,69 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.HexFormat;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("HmacSignatureValidator Tests")
+class HmacSignatureValidatorTest {
+
+ private static final String SECRET = "super-secret-key";
+ private static final String BODY = "{\"action\":\"closed\"}";
+
+ private HmacSignatureValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new HmacSignatureValidator();
+ }
+
+ @Test
+ @DisplayName("Should compute a valid hex-encoded HMAC-SHA256 digest")
+ void shouldComputeValidHmacSha256() throws Exception {
+ var mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ var expected = HexFormat.of().formatHex(mac.doFinal(BODY.getBytes(StandardCharsets.UTF_8)));
+
+ var actual = validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), SECRET);
+
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("Should produce different digests for different payloads")
+ void shouldProduceDifferentDigestsForDifferentPayloads() {
+ var digest1 = validator.computeHexSha256("payload1".getBytes(StandardCharsets.UTF_8), SECRET);
+ var digest2 = validator.computeHexSha256("payload2".getBytes(StandardCharsets.UTF_8), SECRET);
+
+ assertThat(digest1).isNotEqualTo(digest2);
+ }
+
+ @Test
+ @DisplayName("Should produce different digests for different secrets")
+ void shouldProduceDifferentDigestsForDifferentSecrets() {
+ assertThat(validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), "secret-a")).isNotEqualTo(validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), "secret-b"));
+ }
+
+ @Test
+ @DisplayName("Should throw WebhookAuthenticationException on internal crypto error")
+ void shouldThrowOnCryptoError() {
+ assertThat(validator.computeHexSha256(new byte[0], SECRET)).isNotBlank();
+ }
+
+ @Test
+ @DisplayName("Should throw WebhookAuthenticationException when secret is empty string")
+ void shouldThrowWhenSecretIsEmpty() {
+ assertThatThrownBy(() -> validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), ""))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Unable to compute HMAC signature");
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java
new file mode 100644
index 0000000..4d1a3a8
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java
@@ -0,0 +1,149 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtException;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@DisplayName("JwtBearerSecurityValidator Tests")
+@ExtendWith(MockitoExtension.class)
+class JwtBearerSecurityValidatorTest {
+
+ @Mock
+ private WebhookJwtDecoderProvider jwtDecoderProvider;
+
+ @Mock
+ private JwtDecoder jwtDecoder;
+
+ private JwtBearerSecurityValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new JwtBearerSecurityValidator(jwtDecoderProvider);
+ }
+
+ @Test
+ @DisplayName("Should support JWT_BEARER (case-insensitive)")
+ void shouldSupportJwtBearer() {
+ assertThat(validator.supports("JWT_BEARER")).isTrue();
+ assertThat(validator.supports("jwt_bearer")).isTrue();
+ assertThat(validator.supports("STATIC_TOKEN")).isFalse();
+ }
+
+ @Nested
+ @DisplayName("validateRequest — missing or invalid Authorization header")
+ class MissingOrInvalidAuthorizationHeader {
+
+ @Test
+ @DisplayName("Should throw when Authorization header is absent")
+ void shouldThrowWhenAuthorizationHeaderMissing() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER,
+ Map.of("jwks_uri", "https://example.com/.well-known/jwks.json"));
+ byte[] rawBody = new byte[0];
+ Map headers = Map.of();
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, rawBody))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Missing Authorization Bearer header");
+ }
+
+ @Test
+ @DisplayName("Should throw when Authorization header does not start with 'Bearer '")
+ void shouldThrowWhenAuthorizationNotBearer() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER,
+ Map.of("jwks_uri", "https://example.com/.well-known/jwks.json"));
+ Map headers = Map.of("Authorization", "Basic dXNlcjpwYXNz");
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Missing Authorization Bearer header");
+ }
+
+ @Test
+ @DisplayName("Should throw when bearer token is blank after 'Bearer ' prefix")
+ void shouldThrowWhenBearerTokenIsBlank() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER,
+ Map.of("jwks_uri", "https://example.com/.well-known/jwks.json"));
+ Map headers = Map.of("Authorization", "Bearer ");
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Missing bearer token");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateRequest — missing config keys")
+ class MissingConfigKeys {
+
+ @Test
+ @DisplayName("Should throw when jwks_uri is missing from config")
+ void shouldThrowWhenJwksUriMissing() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER, Map.of("other_key", "value"));
+ byte[] rawBody = new byte[0];
+ Map headers = Map.of();
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, rawBody))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("jwks_uri");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateRequest — JWT signature verification")
+ class JwtSignatureVerification {
+
+ @Test
+ @DisplayName("Should decode JWT with configured jwks_uri")
+ void shouldDecodeJwtWithConfiguredJwksUri() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER,
+ Map.of("jwks_uri", "https://example.com/.well-known/jwks.json"));
+ String token = "eyJhbGciOiJSUzI1NiJ9.payload.signature";
+ Map headers = Map.of("Authorization", "Bearer " + token);
+
+ when(jwtDecoderProvider.get("https://example.com/.well-known/jwks.json")).thenReturn(jwtDecoder);
+ when(jwtDecoder.decode(token)).thenReturn(
+ Jwt.withTokenValue(token)
+ .header("alg", "RS256")
+ .claim("sub", "webhook-caller")
+ .build());
+
+ validator.validateRequest(security, headers, new byte[0]);
+
+ verify(jwtDecoderProvider).get("https://example.com/.well-known/jwks.json");
+ verify(jwtDecoder).decode(token);
+ }
+
+ @Test
+ @DisplayName("Should throw when JWT signature validation fails")
+ void shouldThrowWhenJwtSignatureValidationFails() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER,
+ Map.of("jwks_uri", "https://example.com/.well-known/jwks.json"));
+ String token = "invalid-token";
+ Map headers = Map.of("Authorization", "Bearer " + token);
+
+ when(jwtDecoderProvider.get("https://example.com/.well-known/jwks.json")).thenReturn(jwtDecoder);
+ when(jwtDecoder.decode(token)).thenThrow(new JwtException("bad signature"));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Invalid JWT bearer token");
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java
new file mode 100644
index 0000000..6ac4b0d
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java
@@ -0,0 +1,103 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("StaticTokenSecurityValidator Tests")
+class StaticTokenSecurityValidatorTest {
+
+ private StaticTokenSecurityValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new StaticTokenSecurityValidator();
+ }
+
+ @Test
+ @DisplayName("Should support STATIC_TOKEN (case-insensitive)")
+ void shouldSupportStaticToken() {
+ assertThat(validator.supports("STATIC_TOKEN")).isTrue();
+ assertThat(validator.supports("static_token")).isTrue();
+ assertThat(validator.supports("HMAC_SHA256")).isFalse();
+ }
+
+ @Nested
+ @DisplayName("validateRequest missing header")
+ class MissingHeader {
+
+ @Test
+ @DisplayName("Should throw when the token header is absent")
+ void shouldThrowWhenTokenHeaderMissing() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.STATIC_TOKEN,
+ Map.of("header_name", "X-Webhook-Token", "secret_alias", "TEST_MISSING_STATIC_TOKEN_ALIAS"));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, Map.of(), new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Missing token header");
+ }
+
+ @Test
+ @DisplayName("Should throw when the token header value is blank")
+ void shouldThrowWhenTokenHeaderBlank() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.STATIC_TOKEN,
+ Map.of("header_name", "X-Webhook-Token", "secret_alias", "TEST_MISSING_STATIC_TOKEN_ALIAS"));
+ Map headers = Map.of("X-Webhook-Token", " ");
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Missing token header");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateRequest — missing env secret")
+ class MissingEnvSecret {
+
+ @Test
+ @DisplayName("Should throw when the environment secret is not set")
+ void shouldThrowWhenEnvSecretMissing() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.STATIC_TOKEN,
+ Map.of("header_name", "X-Webhook-Token", "secret_alias", "TEST_MISSING_STATIC_TOKEN_ALIAS"));
+ Map headers = Map.of("X-Webhook-Token", "test-token-placeholder");
+
+ assertThatThrownBy(() -> validator.validateRequest(security, headers, new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Missing environment secret for alias");
+ }
+ }
+
+ @Nested
+ @DisplayName("validateRequest — missing config keys")
+ class MissingConfigKeys {
+
+ @Test
+ @DisplayName("Should throw when header_name is missing from config")
+ void shouldThrowWhenHeaderNameMissing() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.STATIC_TOKEN, Map.of("secret_alias", "MY_ALIAS"));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, Map.of(), new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("header_name");
+ }
+
+ @Test
+ @DisplayName("Should throw when secret_alias is missing from config")
+ void shouldThrowWhenSecretAliasMissing() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.STATIC_TOKEN, Map.of("header_name", "X-Token"));
+
+ assertThatThrownBy(() -> validator.validateRequest(security, Map.of(), new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("secret_alias");
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityValidatorDispatcherTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityValidatorDispatcherTest.java
new file mode 100644
index 0000000..0d19ba3
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityValidatorDispatcherTest.java
@@ -0,0 +1,106 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+@DisplayName("WebhookSecurityValidatorDispatcher Tests")
+@ExtendWith(MockitoExtension.class)
+class WebhookSecurityValidatorDispatcherTest {
+
+ @Mock
+ private WebhookSecurityStrategy hmacValidator;
+
+ @Mock
+ private WebhookSecurityStrategy staticTokenValidator;
+
+ private WebhookSecurityValidatorDispatcher dispatcher;
+
+ @BeforeEach
+ void setUp() {
+ lenient().when(hmacValidator.supports("HMAC_SHA256")).thenReturn(true);
+ lenient().when(hmacValidator.supports("STATIC_TOKEN")).thenReturn(false);
+ lenient().when(staticTokenValidator.supports("HMAC_SHA256")).thenReturn(false);
+ lenient().when(staticTokenValidator.supports("STATIC_TOKEN")).thenReturn(true);
+ dispatcher = new WebhookSecurityValidatorDispatcher(List.of(hmacValidator, staticTokenValidator));
+ }
+
+ @Nested
+ @DisplayName("dispatch — happy paths")
+ class DispatchHappyPaths {
+
+ @Test
+ @DisplayName("Should delegate to the matching validator for HMAC_SHA256")
+ void shouldDispatchToHmacValidator() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET"));
+ Map headers = Map.of("X-Hub-Signature-256", "sha256=abc");
+ byte[] body = new byte[0];
+
+ assertThatCode(() -> dispatcher.dispatch(security, headers, body)).doesNotThrowAnyException();
+
+ verify(hmacValidator).validateRequest(security, headers, body);
+ verify(staticTokenValidator, never()).validateRequest(security, headers, body);
+ }
+
+ @Test
+ @DisplayName("Should delegate to the matching validator for STATIC_TOKEN")
+ void shouldDispatchToStaticTokenValidator() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.STATIC_TOKEN, Map.of("header_name", "X-Token", "secret_alias", "TOKEN_SECRET"));
+ Map headers = Map.of("X-Token", "my-token");
+ byte[] body = new byte[0];
+
+ assertThatCode(() -> dispatcher.dispatch(security, headers, body)).doesNotThrowAnyException();
+
+ verify(staticTokenValidator).validateRequest(security, headers, body);
+ verify(hmacValidator, never()).validateRequest(security, headers, body);
+ }
+
+ @Test
+ @DisplayName("Should bypass validators for NONE security type")
+ void shouldBypassValidatorsForNoneType() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of());
+ Map headers = Map.of("Authorization", "anything");
+ byte[] body = new byte[0];
+
+ assertThatCode(() -> dispatcher.dispatch(security, headers, body)).doesNotThrowAnyException();
+
+ verify(hmacValidator, never()).validateRequest(security, headers, body);
+ verify(staticTokenValidator, never()).validateRequest(security, headers, body);
+ }
+ }
+
+ @Nested
+ @DisplayName("dispatch — unsupported type")
+ class DispatchUnsupportedType {
+
+ @Test
+ @DisplayName("Should throw WebhookAuthenticationException when no validator is registered for a type")
+ void shouldThrowForUnsupportedSecurityType() {
+ lenient().when(hmacValidator.supports("JWT_BEARER")).thenReturn(false);
+ lenient().when(staticTokenValidator.supports("JWT_BEARER")).thenReturn(false);
+
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER, Map.of("jwks_uri", "https://example.com/.well-known/jwks"));
+
+ assertThatThrownBy(() -> dispatcher.dispatch(security, Map.of(), new byte[0]))
+ .isInstanceOf(WebhookAuthenticationException.class)
+ .hasMessageContaining("Unsupported webhook security strategy");
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java
new file mode 100644
index 0000000..2ab4cba
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java
@@ -0,0 +1,82 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.infrastructure.adapters.webhook.security.BasicAuthSecurityValidator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.*;
+
+@DisplayName("Security Validator: Basic Auth Creation")
+class BasicAuthWebhookSecurityCreationValidatorTest {
+
+ private BasicAuthSecurityValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new BasicAuthSecurityValidator();
+ }
+
+ @Test
+ @DisplayName("supports() -> True for 'BASIC_AUTH' (case-insensitive), False for others")
+ void shouldReturnTrueWhenTypeIsBasicAuth() {
+ assertThat(validator.supports("BASIC_AUTH")).isTrue();
+ assertThat(validator.supports("basic_auth")).isTrue();
+ assertThat(validator.supports("HMAC_SHA256")).isFalse();
+ }
+
+ @Nested
+ @DisplayName("Valid Configurations")
+ class ValidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when both username and UPPER_SNAKE_CASE secret_alias are present")
+ void shouldPassWhenAllRequiredFieldsAreValid() {
+ Map config = Map.of("username", "webhook-user", "secret_alias", "TEST_CREDENTIAL_ALIAS");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when secret key is provided as camelCase 'secretAlias'")
+ void shouldPassWhenSecretKeyIsCamelCase() {
+ Map config = Map.of("username", "webhook-user", "secretAlias", "TEST_CREDENTIAL_ALIAS");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+ }
+
+ @Nested
+ @DisplayName("Invalid Configurations (Missing or Malformed Fields)")
+ class InvalidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'username' is completely missing")
+ void shouldThrowExceptionWhenUsernameIsMissing() {
+ Map config = Map.of("secret_alias", "TEST_CREDENTIAL_ALIAS");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("username");
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' is completely missing")
+ void shouldThrowExceptionWhenSecretAliasIsMissing() {
+ Map config = Map.of("username", "webhook-user");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("secret_alias");
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' contains lowercase letters")
+ void shouldThrowExceptionWhenSecretAliasIsNotUpperSnakeCase() {
+ Map config = Map.of("username", "webhook-user", "secret_alias", "not_upper_snake");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("UPPER_SNAKE_CASE");
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java
new file mode 100644
index 0000000..edaaa8c
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java
@@ -0,0 +1,84 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.infrastructure.adapters.webhook.security.HmacSha256SecurityValidator;
+import com.decathlon.idp_core.infrastructure.adapters.webhook.security.HmacSignatureValidator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.*;
+
+@DisplayName("Security Validator: HMAC SHA-256 Creation")
+class HmacSha256WebhookSecurityCreationValidatorTest {
+
+ private HmacSha256SecurityValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ HmacSignatureValidator signatureValidator = new HmacSignatureValidator();
+ validator = new HmacSha256SecurityValidator(signatureValidator);
+ }
+
+ @Test
+ @DisplayName("supports() -> True for 'HMAC_SHA256' (case-insensitive), False for others")
+ void shouldReturnTrueWhenTypeIsHmacSha256() {
+ assertThat(validator.supports("HMAC_SHA256")).isTrue();
+ assertThat(validator.supports("hmac_sha256")).isTrue();
+ assertThat(validator.supports("STATIC_TOKEN")).isFalse();
+ }
+
+ @Nested
+ @DisplayName("Valid Configurations")
+ class ValidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when header_name and UPPER_SNAKE_CASE secret_alias are present")
+ void shouldPassWhenAllRequiredFieldsAreValidWithSnakeCase() {
+ Map config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_GITHUB_SECRET");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when keys are provided as camelCase (headerName, secretAlias)")
+ void shouldPassWhenRequiredFieldsAreCamelCase() {
+ Map config = Map.of("headerName", "X-Hub-Signature-256", "secretAlias", "MY_GITHUB_SECRET");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+ }
+
+ @Nested
+ @DisplayName("Invalid Configurations (Missing or Malformed Fields)")
+ class InvalidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'header_name' is completely missing")
+ void shouldThrowExceptionWhenHeaderNameIsMissing() {
+ Map config = Map.of("secret_alias", "MY_SECRET");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("header_name");
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' is completely missing")
+ void shouldThrowExceptionWhenSecretAliasIsMissing() {
+ Map config = Map.of("header_name", "X-Hub-Signature-256");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("secret_alias");
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' contains lowercase letters or invalid characters")
+ void shouldThrowExceptionWhenSecretAliasIsNotUpperSnakeCase() {
+ Map config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "my-raw-secret-value");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("UPPER_SNAKE_CASE");
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java
new file mode 100644
index 0000000..8bd15ca
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java
@@ -0,0 +1,83 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.infrastructure.adapters.webhook.security.JwtBearerSecurityValidator;
+import com.decathlon.idp_core.infrastructure.adapters.webhook.security.WebhookJwtDecoderProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Security Validator: JWT Bearer Creation")
+class JwtBearerWebhookSecurityCreationValidatorTest {
+
+ private JwtBearerSecurityValidator validator;
+
+ @Mock
+ private WebhookJwtDecoderProvider jwtDecoderProvider;
+
+ @BeforeEach
+ void setUp() {
+ validator = new JwtBearerSecurityValidator(jwtDecoderProvider);
+ }
+
+ @Test
+ @DisplayName("supports() -> True for 'JWT_BEARER' (case-insensitive), False for others")
+ void shouldReturnTrueWhenTypeIsJwtBearer() {
+ assertThat(validator.supports("JWT_BEARER")).isTrue();
+ assertThat(validator.supports("jwt_bearer")).isTrue();
+ assertThat(validator.supports("HMAC_SHA256")).isFalse();
+ assertThat(validator.supports("BASIC_AUTH")).isFalse();
+ }
+
+ @Nested
+ @DisplayName("Valid Configurations")
+ class ValidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when 'jwks_uri' is provided in snake_case")
+ void shouldPassWhenJwksUriIsValidWithSnakeCase() {
+ Map config = Map.of("jwks_uri", "https://example.com/.well-known/jwks.json");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when 'jwksUri' is provided as camelCase")
+ void shouldPassWhenJwksUriIsValidWithCamelCase() {
+ Map config = Map.of("jwksUri", "https://example.com/.well-known/jwks.json");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+ }
+
+ @Nested
+ @DisplayName("Invalid Configurations (Missing or Malformed Fields)")
+ class InvalidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'jwks_uri' is completely missing")
+ void shouldThrowExceptionWhenJwksUriIsMissing() {
+ Map config = Map.of("other_key", "some-value");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("jwks_uri");
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when the configuration map is empty")
+ void shouldThrowExceptionWhenConfigIsEmpty() {
+ Map config = Map.of();
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class);
+ }
+ }
+}
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java
new file mode 100644
index 0000000..f8d8a6b
--- /dev/null
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java
@@ -0,0 +1,82 @@
+package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.infrastructure.adapters.webhook.security.StaticTokenSecurityValidator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.*;
+
+@DisplayName("Security Validator: Static Token Creation")
+class StaticTokenWebhookSecurityCreationValidatorTest {
+
+ private StaticTokenSecurityValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new StaticTokenSecurityValidator();
+ }
+
+ @Test
+ @DisplayName("supports() -> True for 'STATIC_TOKEN' (case-insensitive), False for others")
+ void shouldReturnTrueWhenTypeIsStaticToken() {
+ assertThat(validator.supports("STATIC_TOKEN")).isTrue();
+ assertThat(validator.supports("static_token")).isTrue();
+ assertThat(validator.supports("HMAC_SHA256")).isFalse();
+ }
+
+ @Nested
+ @DisplayName("Valid Configurations")
+ class ValidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when header_name and UPPER_SNAKE_CASE secret_alias are present")
+ void shouldPassWhenAllRequiredFieldsAreValidWithSnakeCase() {
+ Map config = Map.of("header_name", "X-Webhook-Token", "secret_alias", "WEBHOOK_TOKEN_SECRET");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Passes when keys are provided as camelCase (headerName, secretAlias)")
+ void shouldPassWhenRequiredFieldsAreCamelCase() {
+ Map config = Map.of("headerName", "X-Webhook-Token", "secretAlias", "WEBHOOK_TOKEN_SECRET");
+ assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException();
+ }
+ }
+
+ @Nested
+ @DisplayName("Invalid Configurations (Missing or Malformed Fields)")
+ class InvalidConfigurations {
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'header_name' is completely missing")
+ void shouldThrowExceptionWhenHeaderNameIsMissing() {
+ Map config = Map.of("secret_alias", "TOKEN_SECRET");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("header_name");
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' is completely missing")
+ void shouldThrowExceptionWhenSecretAliasIsMissing() {
+ Map config = Map.of("header_name", "X-Webhook-Token");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("secret_alias");
+ }
+
+ @Test
+ @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' contains lowercase letters or invalid characters")
+ void shouldThrowExceptionWhenSecretAliasIsNotUpperSnakeCase() {
+ Map config = Map.of("header_name", "X-Webhook-Token", "secret_alias", "plainTextSecret");
+ assertThatThrownBy(() -> validator.validateConfiguration(config))
+ .isInstanceOf(WebhookSecurityConfigurationException.class)
+ .hasMessageContaining("UPPER_SNAKE_CASE");
+ }
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json
new file mode 100644
index 0000000..430dc09
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json
@@ -0,0 +1,33 @@
+{
+ "identifier": "github-dora-connector",
+ "title": "GitHub DORA Connector",
+ "description": "Collects deployment events from GitHub",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": ".action == \"deployment\"",
+ "entity": {
+ "identifier": ".repository.name",
+ "title": ".repository.name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login + \"@example.com\"",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": "\"Java\""
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "MY_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json
new file mode 100644
index 0000000..77d7fb0
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json
@@ -0,0 +1,22 @@
+{
+ "identifier": " ",
+ "title": "Blank Identifier",
+ "description": "Should fail",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "true",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {},
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "NONE",
+ "config": {}
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json
new file mode 100644
index 0000000..2392988
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json
@@ -0,0 +1,21 @@
+{
+ "title": "Missing Identifier",
+ "description": "Should fail",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "true",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {},
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "NONE",
+ "config": {}
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json
new file mode 100644
index 0000000..2b02119
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json
@@ -0,0 +1,29 @@
+{
+ "identifier": "invalid-jslt-connector",
+ "title": "Invalid JSLT Connector",
+ "description": "Should fail due to invalid JSLT filter",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "@@@ INVALID JSLT @@@",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login + \"@example.com\"",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": "\"Java\""
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "NONE",
+ "config": {}
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json
new file mode 100644
index 0000000..c58f6b9
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json
@@ -0,0 +1,29 @@
+{
+ "identifier": "invalid-security-type-connector",
+ "title": "Invalid Security Type",
+ "description": "Should fail",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "true",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login + \"@example.com\"",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": "\"Java\""
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "UNKNOWN_TYPE",
+ "config": {}
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json
new file mode 100644
index 0000000..a96b1d2
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json
@@ -0,0 +1,11 @@
+{
+ "identifier": "no-mappings-connector",
+ "title": "No Mappings",
+ "description": "Should fail",
+ "enabled": true,
+ "mappings": [],
+ "security": {
+ "type": "NONE",
+ "config": {}
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json
new file mode 100644
index 0000000..430dc09
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json
@@ -0,0 +1,33 @@
+{
+ "identifier": "github-dora-connector",
+ "title": "GitHub DORA Connector",
+ "description": "Collects deployment events from GitHub",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": ".action == \"deployment\"",
+ "entity": {
+ "identifier": ".repository.name",
+ "title": ".repository.name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login + \"@example.com\"",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": "\"Java\""
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "MY_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json
new file mode 100644
index 0000000..2d8c1a7
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json
@@ -0,0 +1,32 @@
+{
+ "identifier": "connector-to-update",
+ "title": "Updated Title",
+ "description": "Updated description",
+ "enabled": false,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "true",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login + \"@example.com\"",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": "\"Java\""
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "STATIC_TOKEN",
+ "config": {
+ "header_name": "X-Token",
+ "secret_alias": "MY_TOKEN"
+ }
+ }
+}
diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json
new file mode 100644
index 0000000..08f7761
--- /dev/null
+++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json
@@ -0,0 +1,29 @@
+{
+ "identifier": "connector-to-update",
+ "title": "GitHub DORA Connector",
+ "description": "Duplicate title",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "microservice",
+ "filter": "true",
+ "entity": {
+ "identifier": ".id",
+ "title": ".name",
+ "properties": {
+ "applicationName": ".repository.name",
+ "ownerEmail": ".sender.login + \"@example.com\"",
+ "environment": ".deployment.environment",
+ "version": ".deployment.sha",
+ "port": "8080",
+ "programmingLanguage": "\"Java\""
+ },
+ "relations": {}
+ }
+ }
+ ],
+ "security": {
+ "type": "NONE",
+ "config": {}
+ }
+}