diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md index 16ed391..1c06d9e 100644 --- a/docs/src/concepts/index.md +++ b/docs/src/concepts/index.md @@ -49,6 +49,12 @@ graph TB Connections between entities forming a knowledge graph. +- 🌐 **[Webhooks](webhooks.md)** + + --- + + Runtime-configurable connectors that authenticate external events and map payloads to your data model. + --- @@ -124,3 +130,4 @@ Dive deeper into each concept: - **[Entity Templates](entity-templates.md)** - Learn how to design your data model - **[Properties](properties.md)** - Understand property types and validation - **[Relations](relations.md)** - Connect your entities into a graph +- **[Webhooks](webhooks.md)** - Configure inbound integrations and security strategies diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md new file mode 100644 index 0000000..724c9e4 --- /dev/null +++ b/docs/src/concepts/webhooks.md @@ -0,0 +1,216 @@ +--- +title: Webhooks +description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core +--- + +Webhooks let external systems push JSON events to IDP-Core through a generic HTTP endpoint. You configure a webhook connector at runtime, choose a security strategy, and define mappings that translate incoming payloads into entity data. + +## Overview + +A webhook connector combines three concerns: + +- **Connector metadata** - Identifier, title, description, and enabled flag +- **Security** - How IDP-Core authenticates incoming requests +- **Mappings** - How the payload maps to an Entity Template + +```mermaid +flowchart LR + S[External system] --> E[POST /webhooks/{configurationId}] + E --> H[InboundWebhookHandler] + H --> D[Security dispatcher] + D --> C[WebhookConnector] + C --> M[Dynamic mappings] + M --> T[Entity Template] +``` + +## Webhook Connector + +A webhook connector is the runtime configuration stored by IDP-Core for one inbound integration. + +| Field | Type | Description | +| --- | --- | --- | +| `identifier` | String | Stable key used in the webhook URL and management APIs | +| `title` | String | Human-readable name | +| `description` | String | Optional explanation of the connector purpose | +| `enabled` | Boolean | Enables or disables request processing | +| `mappings` | Array | One or more dynamic mapping rules | +| `security` | Object | Authentication strategy and configuration | + +### Example + +```json +{ + "identifier": "github-repositories", + "title": "GitHub repositories", + "description": "Receives repository events from GitHub", + "enabled": true, + "mappings": [ + { + "template": "github_repository", + "filter": ".action == \"created\" or .action == \"edited\"", + "entity": { + "identifier": ".repository.full_name | gsub(\"/\"; \"_\")", + "title": ".repository.name", + "properties": { + "name": ".repository.name", + "url": ".repository.html_url", + "language": ".repository.language // \"Unknown\"" + }, + "relations": { + "owner": ".repository.owner.login" + } + } + } + ], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } +} +``` + +## Dynamic Mappings + +Each connector contains at least one dynamic mapping. A mapping targets one Entity Template and describes how to derive entity fields from the incoming JSON payload. + +| Field | Description | +| --- | --- | +| `template` | Target Entity Template identifier | +| `filter` | Expression that decides whether the mapping applies | +| `entity.identifier` | Expression that generates the entity identifier | +| `entity.title` | Expression that generates the entity title | +| `entity.properties` | Map of template property names to extraction expressions | +| `entity.relations` | Map of template relation names to extraction expressions | + +### Validation Rules + +When you create or update a connector, IDP-Core validates each mapping against the target Entity Template. + +It checks that: + +- The referenced template exists +- Every mapped property exists in the template +- Every required property is mapped +- Every mapped relation exists in the template +- Every required relation is mapped + +This validation keeps the connector configuration aligned with the current data model. + +## Security Strategies + +Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests again at runtime. + +| Type | Required configuration keys | Runtime behavior | +| --- | --- | --- | +| `HMAC_SHA256` | `header_name`, `secret_alias` | Computes the SHA-256 HMAC of the raw body and compares it with the request header | +| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment | +| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret | +| `JWT_BEARER` | `jwks_uri` | Validates the bearer token against a JWKS endpoint | +| `NONE` | none | Skips authentication | + +> [!IMPORTANT] +> Security configuration keys accept `snake_case` and `camelCase` variants for the supported fields. +> [!WARNING] +> `secret_alias` must reference an environment variable alias in `UPPER_SNAKE_CASE`. It does not store the raw secret value in the connector configuration. + +### Example Security Configurations + +=== "HMAC_SHA256" + ```json + { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } + ``` + +=== "STATIC_TOKEN" + ```json + { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Webhook-Token", + "secret_alias": "WEBHOOK_SHARED_TOKEN" + } + } + ``` + +=== "BASIC_AUTH" + ```json + { + "type": "BASIC_AUTH", + "config": { + "username": "webhook-user", + "secret_alias": "WEBHOOK_PASSWORD" + } + } + ``` + +=== "JWT_BEARER" + ```json + { + "type": "JWT_BEARER", + "config": { + "jwks_uri": "https://issuer.example.com/.well-known/jwks.json" + } + } + ``` + +## Runtime Flow + +The webhook runtime uses a single generic endpoint: + +```text +POST /webhooks/{configurationId} +``` + +The request flow is: + +1. IDP-Core receives the request on the generic webhook endpoint. +2. The `configurationId` resolves the stored `WebhookConnector`. +3. If the connector is disabled, IDP-Core ignores the event. +4. The security dispatcher selects the matching strategy for the connector security type. +5. The strategy validates the headers and, when needed, the raw request body. +6. After authentication, the event is accepted for downstream processing. + +> [!IMPORTANT] +> The connector model, security validation, management APIs, and mapping validation are implemented now. The final payload-to-entity ingestion route is still marked as pending Camel routing in the current handler implementation. + +## Management Lifecycle + +You manage webhook connectors through the inbound webhook management API. + +| Operation | Endpoint | +| --- | --- | +| Create connector | `POST /api/v1/inbound-webhooks` | +| List connectors | `GET /api/v1/inbound-webhooks` | +| Get connector | `GET /api/v1/inbound-webhooks/{identifier}` | +| Update connector | `PUT /api/v1/inbound-webhooks/{identifier}` | +| Delete connector | `DELETE /api/v1/inbound-webhooks/{identifier}` | + +This separation keeps configuration management under versioned API routes while the event ingestion endpoint stays simple for external systems. + +## When to Use Webhooks + +Use webhooks when an external system can push JSON events over HTTP and you want to: + +- Ingest updates without redeploying IDP-Core +- Reuse one generic endpoint for multiple providers +- Apply connector-specific authentication rules +- Map external payloads to your own Entity Templates at runtime + +--- + +## Next Steps + +- **[Entity Templates](entity-templates.md)** - Define the target structures that mappings reference +- **[Entities](entities.md)** - Understand the records produced by successful ingestion +- **[Relations](relations.md)** - Model links that webhook mappings can populate +- **[Data Integration](../features/data-integration.md)** - Explore the broader ingestion roadmap diff --git a/docs/zensical.toml b/docs/zensical.toml index 9b5f230..ad9731f 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -34,7 +34,8 @@ nav = [ "concepts/entity-templates.md", "concepts/entities.md", "concepts/properties.md", - "concepts/relations.md" + "concepts/relations.md", + "concepts/webhooks.md" ]}, { "Features" = [ "features/index.md", diff --git a/pom.xml b/pom.xml index 1844818..dee742a 100644 --- a/pom.xml +++ b/pom.xml @@ -226,6 +226,14 @@ 3.20.0 + + + + com.schibsted.spt.data + jslt + 0.1.14 + + org.springframework.boot spring-boot-starter-actuator diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index c023292..1de44ac 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -44,6 +44,12 @@ public class ValidationMessages { public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + + //Webhook connector validation messages + public static final String WEBHOOK_CONNECTOR_ALREADY_EXIST="Webhook Connector already exists with the same identifier"; + public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY = "Webhook Connector identifier is mandatory and cannot be blank"; + public static final String WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST ="Webhook Connector already exist with the same name"; + public static final String WEBHOOK_CONNECTOR_MAPPINGS_MANDATORY = "Webhook mappings section is mandatory"; // Helper method to construct rules incompatibility message public static String rulesAreIncompatible(String rule1, String rule2) { return PROPERTY_RULES_MUTUALLY_EXCLUSIVE diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java new file mode 100644 index 0000000..9d73f6f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.domain.exception.entity_mapping; + +public class EntityDynamicMappingConfigurationException extends RuntimeException { + + public EntityDynamicMappingConfigurationException(String message) { + super(message); + } + + public EntityDynamicMappingConfigurationException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java new file mode 100644 index 0000000..9505bc5 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +public class PropertyNameNotFoundEntityTemplatePropertiesException extends RuntimeException { + public PropertyNameNotFoundEntityTemplatePropertiesException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java new file mode 100644 index 0000000..b0b0a63 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +public class RelationNameNotFoundEntityTemplateRelationsException extends RuntimeException { + public RelationNameNotFoundEntityTemplateRelationsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java new file mode 100644 index 0000000..b166ec4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java @@ -0,0 +1,11 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookAuthenticationException extends RuntimeException { + public WebhookAuthenticationException(String message) { + super(message); + } + + public WebhookAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java new file mode 100644 index 0000000..4812f1b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_ALREADY_EXIST; + +public class WebhookConnectorAlreadyExistException extends RuntimeException { + + public WebhookConnectorAlreadyExistException(String identifier) { + super(String.format("%s:%s", WEBHOOK_CONNECTOR_ALREADY_EXIST, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java new file mode 100644 index 0000000..8ee51c4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookConnectorNotFoundException extends RuntimeException { + + public WebhookConnectorNotFoundException(String identifier) { + super(String.format("No webhook connector found for identifier: %s", identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java new file mode 100644 index 0000000..eb037ed --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST; + +public class WebhookConnectorTitleAlreadyExistsException extends RuntimeException { + public WebhookConnectorTitleAlreadyExistsException(String webhookName) { + super(String.format("%s:%s", WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST, webhookName)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java new file mode 100644 index 0000000..9d6a9f3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookSecurityConfigurationException extends RuntimeException { + + public WebhookSecurityConfigurationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java new file mode 100644 index 0000000..4a42144 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookTemplateHasNoPropertiesException extends RuntimeException { + + public WebhookTemplateHasNoPropertiesException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java new file mode 100644 index 0000000..c0edfbe --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java @@ -0,0 +1,24 @@ +package com.decathlon.idp_core.domain.model.entity_mapping; + +import java.util.Map; + +import com.decathlon.idp_core.domain.model.webhook.WebhookConnector; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record +EntityDynamicMapping( + @NotBlank + String templateIdentifier, + @NotBlank + String filter, + @NotBlank + String entityIdentifier, + @NotBlank + String entityTitle, + @NotNull + Map properties, + @NotNull + Map relations +) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java new file mode 100644 index 0000000..1fbbf07 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.domain.model.enums; + +/// Discriminator for the security validation strategy of a [WebhookConnector]. +/// +/// | Strategy | headerName | secretAlias | prefix | username | jwksUri | +/// |--------------|------------|--------------------|----------|----------|---------| +/// | HMAC_SHA256 | Required | Required (hash key)| Optional | — | — | +/// | JWT_BEARER | — | — | — | — | Required| +/// | STATIC_TOKEN | Required | Required (target) | — | — | — | +/// | BASIC_AUTH | — | Required (password)| — | Required | — | +/// | NONE | — | — | — | — | — | +/// +/// `NONE` means the connector intentionally accepts unauthenticated requests. +public enum WebhookSecurityType { + HMAC_SHA256, + JWT_BEARER, + STATIC_TOKEN, + BASIC_AUTH, + NONE +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/webhook/WebhookConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/webhook/WebhookConnector.java new file mode 100644 index 0000000..683b7fa --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/webhook/WebhookConnector.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.domain.model.webhook; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.UUID; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_MAX_SIZE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY; + +public record WebhookConnector( + UUID id, + @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) + String identifier, + @NotBlank(message = "Webhook title is mandatory") + @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) + String title, + + String description, + boolean enabled, + + @NotEmpty + List<@Valid EntityDynamicMapping> mappings, + @NotNull + @Valid + WebhookSecurity security +) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/webhook/WebhookSecurity.java new file mode 100644 index 0000000..5270833 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/webhook/WebhookSecurity.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.model.webhook; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import jakarta.validation.constraints.NotNull; + +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record WebhookSecurity( + @NotNull WebhookSecurityType type, + @NotNull Map config +) { + + public WebhookSecurity { + if (type == null) { + throw new WebhookSecurityConfigurationException("Webhook security type is mandatory"); + } + if (config == null) { + throw new WebhookSecurityConfigurationException("Webhook security config section is mandatory"); + } + config = Map.copyOf(config); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java new file mode 100644 index 0000000..ae7a46c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.port; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public interface EntityDynamicMapperValidator { + + void validate(EntityDynamicMapping mapping); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java new file mode 100644 index 0000000..818e2b0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.domain.port; + +import com.decathlon.idp_core.domain.model.webhook.WebhookConnector; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface WebhookConnectorRepositoryPort { + + Optional findByIdentifier(String identifier); + + Page findAll(Pageable pageable); + + boolean existsByIdentifier(String identifier); + + boolean existsByTitle(String title); + + WebhookConnector save(WebhookConnector connector); + + void deleteByIdentifier(String identifier); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java new file mode 100644 index 0000000..c74faf1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java @@ -0,0 +1,43 @@ +package com.decathlon.idp_core.domain.port; + +import com.decathlon.idp_core.domain.model.webhook.WebhookSecurity; + +import java.util.Map; + +/** + * Unified strategy contract for webhook security handling. + * + * This interface consolidates two responsibilities that were previously scattered: + * 1. Validating security configuration at creation/update time + * 2. Validating incoming webhook requests at runtime + * + * Implementations should focus on security logic without side effects. + */ +public interface WebhookSecurityStrategy { + + /** + * Checks if this strategy supports the given security type. + * + * @param securityType the security type to check (e.g., "BASIC_AUTH", "HMAC_SHA256") + * @return true if this strategy handles this security type + */ + boolean supports(String securityType); + + /** + * Validates the security configuration provided at creation/update time. + * + * @param config the security configuration map (e.g., username, secret_alias) + * @throws com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException if validation fails + */ + void validateConfiguration(Map config); + + /** + * Validates an incoming webhook request against the stored security configuration. + * + * @param security the stored webhook security configuration + * @param headers the HTTP request headers from the webhook + * @param rawBody the raw request body bytes + * @throws com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException if validation fails + */ + void validateRequest(WebhookSecurity security, Map headers, byte[] rawBody); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 5e6e1e5..8410707 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -1,24 +1,15 @@ package com.decathlon.idp_core.domain.service.entity_template; -import java.util.Objects; - -import org.springframework.stereotype.Service; - -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.*; 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.port.EntityTemplateRepositoryPort; - import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; /// Domain service to centralize all functional validation rules for [EntityTemplate] operations. /// @@ -46,12 +37,12 @@ public class EntityTemplateValidationService { /// - All target templates referenced by relations must exist in the system. /// /// @param entityTemplate the template candidate to validate - /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken /// @throws EntityTemplateNameAlreadyExistsException when name is already taken /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - /// @throws PropertyNameAlreadyExistsException if duplicate property names are found - /// @throws RelationNameAlreadyExistsException if duplicate relation names are found - /// @throws RelationCannotTargetItselfException when a relation targets the template itself + /// @throws PropertyNameAlreadyExistsException if duplicate property names are found + /// @throws RelationNameAlreadyExistsException if duplicate relation names are found + /// @throws RelationCannotTargetItselfException when a relation targets the template itself public void validateForCreation(EntityTemplate entityTemplate) { validateIdentifierUniqueness(entityTemplate.identifier()); validateNameUniqueness(entityTemplate.name()); @@ -77,16 +68,16 @@ public void validateForCreation(EntityTemplate entityTemplate) { /// - Relation target template identifiers cannot be changed after creation. /// /// @param currentIdentifier the identifier of the template being replaced - /// @param existingName the current name of the template being replaced - /// @param existingTemplate the current state of the template being replaced - /// @param mergedTemplate the fully-merged template carrying the desired state - /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken + /// @param existingName the current name of the template being replaced + /// @param existingTemplate the current state of the template being replaced + /// @param mergedTemplate the fully-merged template carrying the desired state + /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - /// @throws PropertyNameAlreadyExistsException if duplicate property names are found - /// @throws RelationNameAlreadyExistsException if duplicate relation names are found - /// @throws RelationTargetTemplateChangeException when a relation target template is changed - /// @throws RelationCannotTargetItselfException when a relation targets the template itself + /// @throws PropertyNameAlreadyExistsException if duplicate property names are found + /// @throws RelationNameAlreadyExistsException if duplicate relation names are found + /// @throws RelationTargetTemplateChangeException when a relation target template is changed + /// @throws RelationCannotTargetItselfException when a relation targets the template itself public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate existingTemplate, EntityTemplate mergedTemplate) { if (!currentIdentifier.equals(mergedTemplate.identifier())) { throw new EntityTemplateIdentifierCannotChangeException(mergedTemplate.identifier()); @@ -177,7 +168,7 @@ private void validateTemplateProperties(EntityTemplate entityTemplate) { /// **Precondition:** relationsDefinitions must not be null /// /// @param entityTemplate the template containing relations to validate - /// @throws TargetTemplateNotFoundException if any referenced target template + /// @throws TargetTemplateNotFoundException if any referenced target template /// @throws RelationCannotTargetItselfException if a relation targets the template itself /// doesn't exist private void validateTemplateRelations(EntityTemplate entityTemplate) { @@ -185,4 +176,24 @@ private void validateTemplateRelations(EntityTemplate entityTemplate) { relationDefinitionValidationService.validateTargetTemplatesExist(entityTemplate.relationsDefinitions()); } + public void validatePropertyNameAlreadyExistInTemplate(List properties, String propertyName) { + if (!isPropertyNameIsOwnedByEntityTemplate(properties, propertyName)) { + throw new PropertyNameNotFoundEntityTemplatePropertiesException(String.format("Property name %s not found in entity template properties", propertyName)); + } + } + + public void validateRelationNameAlreadyExistInTemplate(List relations, String relationName) { + if (!isRelationIsOwnedByEntityTemplate(relations, relationName)) { + throw new RelationNameNotFoundEntityTemplateRelationsException(String.format("Relation name %s not found in entity template relations", relationName)); + } + } + + private boolean isPropertyNameIsOwnedByEntityTemplate(List properties, String propertyName) { + return properties != null && properties.stream().anyMatch(entityTemplateProperty -> entityTemplateProperty.name().equals(propertyName)); + } + + private boolean isRelationIsOwnedByEntityTemplate(List relations, String relationName) { + return relations != null && relations.stream().anyMatch(entityTemplateRelation -> entityTemplateRelation.name().equals(relationName)); + } + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java new file mode 100644 index 0000000..f212067 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java @@ -0,0 +1,96 @@ +package com.decathlon.idp_core.domain.service.webhook; + +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.port.EntityDynamicMapperValidator; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.Map; + +@Service +@Validated +@RequiredArgsConstructor +public class EntityDynamicMappingValidationService { + private final EntityTemplateService entityTemplateService; + private final EntityDynamicMapperValidator entityDynamicMapperValidator; + private final EntityTemplateValidationService entityTemplateValidationService; + + /** + * Validates all mappings of a WebhookConnector. + * + * @param mappings the list of {@link EntityDynamicMapping} to validate + */ + public void validateWebhookMapping(List mappings) { + mappings.forEach(this::validateMapping); + } + + /** + * Validates a single {@link EntityDynamicMapping}: + *
    + *
  • The referenced EntityTemplate must exist.
  • + *
  • Each key in {@code properties} must match a property defined in the template.
  • + *
+ * + * @param webhookMapping the mapping to validate + */ + private void validateMapping(EntityDynamicMapping webhookMapping) { + String templateIdentifier = webhookMapping.templateIdentifier(); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + EntityTemplate entityTemplate = entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier); + validatePropertiesExistInTemplate(webhookMapping.properties(), entityTemplate.propertiesDefinitions()); + validateRequiredPropertiesAreMapped(webhookMapping.properties(), entityTemplate.propertiesDefinitions()); + validateRelationNameAlreadyExistInTemplate(webhookMapping.relations(), entityTemplate); + validateRequiredRelationDefinitionsAreMapped(webhookMapping.relations(), entityTemplate.relationsDefinitions()); + entityDynamicMapperValidator.validate(webhookMapping); + } + + private void validateRequiredRelationDefinitionsAreMapped(Map mappingRelations, List templateRelations) { + List missingRelations = templateRelations.stream() + .filter(RelationDefinition::required) + .map(RelationDefinition::name) + .filter(requiredRelation -> mappingRelations == null || !mappingRelations.containsKey(requiredRelation)) + .toList(); + if (!missingRelations.isEmpty()) { + throw new WebhookTemplateHasNoPropertiesException(String.format("The mapping is missing required template relations: %s", String.join(", ", missingRelations))); + } + } + + private void validateRequiredPropertiesAreMapped(Map mappingProperties, List templateProperties) { + List missingProperties = templateProperties.stream() + .filter(PropertyDefinition::required) + .map(PropertyDefinition::name) + .filter(requiredName -> !mappingProperties.containsKey(requiredName)) + .toList(); + + if (!missingProperties.isEmpty()) { + throw new WebhookTemplateHasNoPropertiesException(String.format("The mapping is missing required template properties: %s", String.join(", ", missingProperties))); + } + + } + + private void validatePropertiesExistInTemplate(Map mappingProperties, List templateProperties) { + + if (!mappingProperties.isEmpty() && templateProperties.isEmpty()) { + throw new WebhookTemplateHasNoPropertiesException("The mapping defines properties but the target template has no property definitions"); + } + + mappingProperties.keySet().forEach(propertyName -> + entityTemplateValidationService.validatePropertyNameAlreadyExistInTemplate(templateProperties, propertyName) + ); + } + + private void validateRelationNameAlreadyExistInTemplate(Map webhookMappingRelations, EntityTemplate entityTemplate) { + if (webhookMappingRelations == null || webhookMappingRelations.isEmpty()) { + return; + } + webhookMappingRelations.keySet().forEach(relationName -> entityTemplateValidationService.validateRelationNameAlreadyExistInTemplate(entityTemplate.relationsDefinitions(), relationName)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java new file mode 100644 index 0000000..0a58be7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java @@ -0,0 +1,61 @@ +package com.decathlon.idp_core.domain.service.webhook; + +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 jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +@Service +@Validated +@RequiredArgsConstructor +public class WebhookConnectorService { + + private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final WebhookConnectorValidationService webhookConnectorValidationService; + + public WebhookConnector getWebhookConnector(String identifier) { + return webhookConnectorRepositoryPort.findByIdentifier(identifier).orElseThrow(() -> new WebhookConnectorNotFoundException("WebhookConnector with identifier " + identifier + " not found")); + } + + @Transactional + public WebhookConnector createWebhookConnector(@Valid WebhookConnector connector) { + webhookConnectorValidationService.validateWebhookConnectorForCreation(connector); + return webhookConnectorRepositoryPort.save(connector); + } + + @Transactional + public WebhookConnector updateWebhookConnector(String identifier, @Valid WebhookConnector connectorToUpdate) { + WebhookConnector webhookConnectorInDb = getWebhookConnector(identifier); + webhookConnectorValidationService.validateWebhookConnectorForUpdate(webhookConnectorInDb, connectorToUpdate); + + WebhookConnector mergedConnector = new WebhookConnector( + webhookConnectorInDb.id(), + webhookConnectorInDb.identifier(), + connectorToUpdate.title(), + connectorToUpdate.description(), + connectorToUpdate.enabled(), + connectorToUpdate.mappings(), + connectorToUpdate.security() + ); + + return webhookConnectorRepositoryPort.save(mergedConnector); + } + + @Transactional + public void deleteWebhookConnector(String webhookConnectorIdentifier) { + webhookConnectorValidationService.validateIdentifierExists(webhookConnectorIdentifier); + webhookConnectorRepositoryPort.deleteByIdentifier(webhookConnectorIdentifier); + } + + public Page getAllWebhookConnector(Pageable pageable) { + return webhookConnectorRepositoryPort.findAll(pageable); + + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java new file mode 100644 index 0000000..e97d4d3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java @@ -0,0 +1,61 @@ +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.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +@Service +@Validated +@RequiredArgsConstructor +public class WebhookConnectorValidationService { + + private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + private final WebhookSecurityValidationService webhookSecurityValidationService; + + public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) { + validateIdentifierUniqueness(webhookConnector.identifier()); + validateTitleUniqueness(webhookConnector.title()); + webhookConnectorMappingValidationService.validateWebhookMapping(webhookConnector.mappings()); + webhookSecurityValidationService.validateForCreation(webhookConnector.security()); + + } + + public void validateWebhookConnectorForUpdate(WebhookConnector existingConnector, WebhookConnector webhookConnectorToUpdate) { + if (!existingConnector.title().equals(webhookConnectorToUpdate.title())) { + validateTitleUniqueness(webhookConnectorToUpdate.title()); + } + webhookConnectorMappingValidationService.validateWebhookMapping(webhookConnectorToUpdate.mappings()); + webhookSecurityValidationService.validateForCreation(webhookConnectorToUpdate.security()); + } + + public void validateTitleUniqueness(String webhookTitle) { + if (webhookConnectorRepositoryPort.existsByTitle(webhookTitle)) { + throw new WebhookConnectorTitleAlreadyExistsException("A WebhookConnector with title " + webhookTitle + " already exists"); + } + + } + + /// Checks that no other [WebhookConnector] exists with the same identifier before allowing creation. + /// + /// @param webhookConnectorIdentifier the webhook connector identifier to check for uniqueness + /// @throws WebhookConnectorAlreadyExistException if a connector with the same identifier already exists + private void validateIdentifierUniqueness(String webhookConnectorIdentifier) { + if (webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) { + throw new WebhookConnectorAlreadyExistException(webhookConnectorIdentifier); + } + } + + public void validateIdentifierExists(String webhookConnectorIdentifier) { + if (!webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) { + throw new WebhookConnectorNotFoundException("WebhookConnector with identifier " + webhookConnectorIdentifier + " not found"); + } + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java new file mode 100644 index 0000000..9dbce5e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java @@ -0,0 +1,62 @@ +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.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * Domain service for validating webhook security configuration at creation/update time. + * + * This service ensures that the security configuration provided when creating or updating + * a webhook connector is valid before storing it in the database. + */ +@Service +public class WebhookSecurityValidationService { + + private final List strategies; + + public WebhookSecurityValidationService(List strategies) { + this.strategies = List.copyOf(strategies); + } + + /** + * Validates webhook security configuration for creation or update. + * + * @param security the security configuration to validate + * @throws WebhookSecurityConfigurationException if the configuration is invalid + */ + public void validateForCreation(WebhookSecurity security) { + if (security == null) { + throw new WebhookSecurityConfigurationException("Webhook security section is mandatory"); + } + + Map config = security.config(); + + if (security.type() == WebhookSecurityType.NONE) { + validateNoSecurityConfig(config); + return; + } + + strategies.stream() + .filter(strategy -> strategy.supports(security.type().name())) + .findFirst() + .ifPresentOrElse( + strategy -> strategy.validateConfiguration(config), + () -> { + throw new WebhookSecurityConfigurationException( + "No validator registered for security type: " + security.type()); + } + ); + } + + private void validateNoSecurityConfig(Map config) { + if (!config.isEmpty()) { + throw new WebhookSecurityConfigurationException("Webhook security config must be empty when type is NONE"); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 9242ef9..c8f6184 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -43,9 +43,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { http.authorizeHttpRequests(authorize -> authorize .requestMatchers("/actuator/**").permitAll() .requestMatchers("/", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() + .requestMatchers("/webhooks/**").permitAll() .requestMatchers("/api/v1/**").fullyAuthenticated() .anyRequest().authenticated() ) + // CSRF disabled for /webhooks/** — external systems cannot obtain a CSRF token. + .csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/**")) .cors(withDefaults()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index e9eac3e..50588fd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -103,5 +104,11 @@ public EntityPageResponse(List content, Pageable pageable, long to } } + @Schema(description = "Paginated response containing Inbound Webhook Connector objects") + public static class WebhookConnectorPageResponse extends PageImpl { + public WebhookConnectorPageResponse(List content, Pageable pageable, long total) { + super(content, pageable, total); + } + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 53aacc8..b06363f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -64,6 +64,17 @@ public class SwaggerDescription { public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY = "Get paginated Webhook connectors"; + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION = "Retrieve a paginated list of webhook connectors with optional sorting"; + + public static final String ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY = "Delete a webhook connector by identifier"; + public static final String ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION = "Remove a webhook connector from the system using its unique identifier"; + + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY = "Get a webhook connector by identifier"; + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific webhook connector using its string identifier"; + + public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY = "Update an existing webhook connector by identifier"; + public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION = "Update the details of an existing webhook connector identified by its unique string identifier"; /// API response description constants public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; @@ -83,7 +94,11 @@ public class SwaggerDescription { public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; - + public static final String RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS = "Paginated webhook connector retrieved successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_DELETED = "Webhook connector deleted successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER = "Webhook connector not found with the provided identifier"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_FOUND = "Webhook connector found"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_UPDATED = "Webhook connector updated successfully"; // --- Schema (class) descriptions --- public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java new file mode 100644 index 0000000..893c1c5 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java @@ -0,0 +1,98 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import com.decathlon.idp_core.domain.model.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook.InboundWebhookMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; +import static org.springframework.http.HttpStatus.*; + +/** + * REST controller exposing inbound webhook configuration management endpoints. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/inbound-webhooks") +@Tag(name = "Inbound Webhook Management", description = "Operations for managing inbound webhook connector configurations") +public class InboundWebhookManagementController { + + private final WebhookConnectorService webhookConnectorService; + private final InboundWebhookMapper inboundWebhookMapper; + + /** + * Creates a new inbound webhook connector configuration. + * + * @param request creation payload + * @return created connector response + */ + @Operation(summary = "Create inbound webhook configuration", description = "Creates a webhook connector configuration used by the generic inbound webhook endpoint") + @ApiResponse(responseCode = "201", description = "Webhook connector created") + @ApiResponse(responseCode = "400", description = "Invalid request payload") + @ApiResponse(responseCode = "409", description = "Identifier already exists") + @PostMapping + @ResponseStatus(CREATED) + public InboundWebhookDtoOut createInboundWebhook(@Valid @RequestBody InboundWebhookCreateDtoIn request) { + WebhookConnector webhookConnector = webhookConnectorService.createWebhookConnector(inboundWebhookMapper.toDomain(request)); + return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector); + } + + @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.WebhookConnectorPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = {@Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @GetMapping + @ResponseStatus(OK) + public Page getTemplatesPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + return webhookConnectorService.getAllWebhookConnector(pageable).map(inboundWebhookMapper::fromWebhookConnectorToDto); + } + + @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteTemplate(@PathVariable String identifier) { + webhookConnectorService.deleteWebhookConnector(identifier); + } + + @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_FOUND, content = {@Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public InboundWebhookDtoOut getTemplateByIdentifier(@PathVariable String identifier) { + WebhookConnector webhookConnector = webhookConnectorService.getWebhookConnector(identifier); + return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector); + } + + @Operation(summary = ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_UPDATED, content = {@Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload", content = {@Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = "Webhook connector title already exists", content = {@Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + @ResponseStatus(OK) + public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, @Valid @RequestBody InboundWebhookCreateDtoIn request) { + return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier, inboundWebhookMapper.toDomainForUpdate(identifier, request))); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java new file mode 100644 index 0000000..9c8de72 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_MAPPINGS_MANDATORY; + +/** + * Request payload used to create an inbound webhook connector configuration. + */ +public record InboundWebhookCreateDtoIn( + @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) + String identifier, + @NotBlank(message = "Webhook title is mandatory") + String title, + String description, + boolean enabled, + @NotEmpty(message = WEBHOOK_CONNECTOR_MAPPINGS_MANDATORY) + List<@Valid InboundWebhookMappingDtoIn> mappings, + @Valid InboundWebhookSecurityContractDtoIn security +) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java new file mode 100644 index 0000000..c75f2f0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.Map; + +/** + * Entity projection section for an inbound webhook mapping. + */ +public record InboundWebhookEntityMappingDtoIn( + @NotBlank(message = "Webhook entity identifier expression is mandatory") + String identifier, + @NotBlank(message = "Webhook entity title expression is mandatory") + String title, + @NotNull(message = "Webhook entity properties section is mandatory") + Map properties, + @NotNull(message = "Webhook entity relations section is mandatory") + Map relations +) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java new file mode 100644 index 0000000..3396944 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java @@ -0,0 +1,19 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Mapping rule request for inbound webhook transformation. + */ +public record InboundWebhookMappingDtoIn( + @NotBlank(message = "Webhook mapping template is mandatory") + String template, + @NotBlank(message = "Webhook mapping filter is mandatory") + String filter, + @NotNull(message = "Webhook mapping entity section is mandatory") + @Valid + InboundWebhookEntityMappingDtoIn entity +) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java new file mode 100644 index 0000000..8ebd529 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.Map; + +/** + * Security contract request payload represented as `{ type, config }`. + */ +public record InboundWebhookSecurityContractDtoIn( + @NotBlank(message = "Webhook security type is mandatory") + String type, + @NotNull(message = "Webhook security config section is mandatory") + Map config +) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java new file mode 100644 index 0000000..783ed78 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java @@ -0,0 +1,16 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.List; + +/** + * Response payload for created inbound webhook connector. + */ +public record InboundWebhookDtoOut( + String identifier, + String title, + String description, + boolean enabled, + List mappings, + InboundWebhookSecurityDtoOut security +) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookEntityMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookEntityMappingDtoOut.java new file mode 100644 index 0000000..fe59706 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookEntityMappingDtoOut.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.Map; + +/** + * Entity projection details exposed in webhook mapping responses. + */ +public record InboundWebhookEntityMappingDtoOut( + String identifier, + String title, + Map properties, + Map 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": {} + } +}