diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md index 1653c079..f4c91d88 100644 --- a/docs/src/concepts/index.md +++ b/docs/src/concepts/index.md @@ -29,27 +29,31 @@ graph TB --- - Blueprints that define the structure of your entitiesβ€”like database schemas defined at runtime. + Blueprints that define the structure of your entitiesβ€”like database schemas defined at runtime. - πŸ—„οΈ **[Entities](entities.md)** --- - Instances of templates with actual dataβ€”your software catalog items. + Instances of templates with actual dataβ€”your software catalog items. - πŸ“‹ **[Properties](properties.md)** --- - Data fields with types, validation rules, and constraints. + Data fields with types, validation rules, and constraints. - πŸ”— **[Relations](relations.md)** --- - Connections between entities forming a knowledge graph. + Connections between entities forming a knowledge graph. -- πŸ” **[Filtering Entities](entity-filtering.md)** +- 🌐 **[Webhooks](webhooks.md)** + + --- + + Runtime-configurable connectors to push your data from external systems within your IDP. You can map any source in your data model in minutes!- πŸ” **[Filtering Entities](entity-filtering.md)** --- @@ -105,7 +109,7 @@ erDiagram ## Quick Reference | Concept | What It Is | Example | -| ------------------- | --------------------------------------------------- | --------------------------------------------------------- | +|---------------------|-----------------------------------------------------|-----------------------------------------------------------| | **Entity Template** | Blueprint/schema | `service`, `team`, `repository` | | **Entity** | Instance based on a template that contains the data | `payment-service`, `platform-team`, `idp-core-repository` | | **Property** | Data field | `name`, `status`, `url` | @@ -130,3 +134,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 00000000..e30ea8ed --- /dev/null +++ b/docs/src/concepts/webhooks.md @@ -0,0 +1,226 @@ +--- +title: Webhooks +description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core +--- + +Webhooks let external systems push JSON events to the Internal Developer Platform 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 with JSLT expressions. + +## 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 | +| `mapping_identifiers` | Array | One or more dynamic mapping identifier | +| `security` | Object | Authentication strategy and configuration | + +### Webhook Connector Example + +```json +{ + "identifier": "github-repositories", + "title": "GitHub repositories", + "description": "Receives repository events from GitHub", + "enabled": true, + "mapping_identifiers": [], + "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 with a JSLT filter and entity projections. + +| Field | Type | Description | +| ------------- | ------ | --------------------------------------------------------------------------- | +| `template` | String | Identifier of the target Entity Template | +| `identifier` | String | Stable and unique key for this specific mapping | +| `name` | String | Human-readable name of the mapping | +| `description` | String | Optional explanation of the mapping purpose | +| `filter` | String | JSLT boolean expression to evaluate if the payload should be processed | +| `entity` | Object | JSLT projections defining how to map the payload to the entity's attributes | + +### Dynamic Mapping Example + +```json +{ + "template": "github_repository", + "identifier": "mapping-github", + "name": "mapping github", + "description": "mapping github description", + "filter": ".repository != null", + "entity": { + "identifier": "replace(.repository.name, \" \", \"-\")", + "title": ".repository.name", + "properties": { + "name": ".repository.name", + "url": ".repository.html_url", + "stars": "\"\" + .repository.stargazers_count", + "is_public": "if (.repository.private) \"false\" else \"true\"" + }, + "relations": {} + } +} +``` + +### Validation Rules + +When you create or update a connector, the IDP 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`, `prefix` | 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. + +## Management API Methods + +You manage webhook connectors through the inbound webhook management API, which exposes standard CRUD methods. + +| HTTP Method | Endpoint | Purpose | +| ----------- | ----------------------------------------- | ---------------- | +| `POST` | `/api/v1/inbound_webhooks` | Create connector | +| `GET` | `/api/v1/inbound_webhooks` | List connectors | +| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Get connector | +| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update connector | +| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete connector | + +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/src/features/data-integration.md b/docs/src/features/data-integration.md index 131b2c7d..4018fa2d 100644 --- a/docs/src/features/data-integration.md +++ b/docs/src/features/data-integration.md @@ -14,7 +14,7 @@ The Internal Developer Platform provides flexible data integration, allowing you Data integration in the Internal Developer Platform follows a three-step pattern: 1. **Configure a connector** - Set up a Webhook, Kafka consumer, or Pub/Sub subscription -2. **Define mappings** - Use JQ expressions to transform incoming data +2. **Define mappings** - Use JSLT expressions to transform incoming data 3. **Ingest data** - Data flows automatically, creating and updating entities ```mermaid @@ -55,6 +55,19 @@ flowchart LR Webhooks allow external systems to push data to IDP-Core via HTTP POST requests. +### Methods + +| Method | Endpoint | Purpose | +| ------ | -------- | ------- | +| `POST` | `/webhooks/{configurationId}` | Receive an inbound event for the connector identified in the URL | +| `POST` | `/api/v1/inbound_webhooks` | Create a webhook connector configuration | +| `GET` | `/api/v1/inbound_webhooks` | List webhook connector configurations | +| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Read one webhook connector configuration | +| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update one webhook connector configuration | +| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete one webhook connector configuration | + + These HTTP routes map to the `InboundWebhookManagementController` methods for connector management. + ### Webhook Configuration ```json @@ -83,33 +96,37 @@ Webhooks allow external systems to push data to IDP-Core via HTTP POST requests. } ], "security": { - "signature_header_name": "X-Sonar-Webhook-HMAC-SHA256", - "signature_value": "your-secret-token" + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Sonar-Webhook-HMAC-SHA256", + "secret_alias": "SONAR_WEBHOOK_SECRET", + "prefix": "sha256=" + } } } ``` ### Configuration Fields -| Field | Description | -| ------------- | ---------------------------- | -| `identifier` | Unique key for this webhook | -| `title` | Human-readable name | -| `description` | Purpose of the webhook | -| `enabled` | Toggle ingestion on/off | -| `mappings` | Array of mapping rules | -| `security` | Authentication configuration | +| Field | Description | +|---------------|-----------------------------------------------------------------| +| `identifier` | Unique key for this webhook | +| `title` | Human-readable name | +| `description` | Purpose of the webhook | +| `enabled` | Toggle ingestion on/off | +| `mappings` | Array of mapping rules | +| `security` | Authentication configuration using a `type` + `config` contract | ### Mapping Structure -| Field | Description | -| ------------------- | ------------------------------------------- | -| `template` | Target Entity Template identifier | -| `filter` | JQ expression to filter incoming payloads | -| `entity.identifier` | JQ expression to generate entity identifier | -| `entity.title` | JQ expression for entity title | -| `entity.properties` | Map of property names to JQ expressions | -| `entity.relations` | Map of relation names to JQ expressions | +| Field | Description | +|---------------------|-----------------------------------------------| +| `template` | Target Entity Template identifier | +| `filter` | JSLT expression to filter incoming payloads | +| `entity.identifier` | JSLT expression to generate entity identifier | +| `entity.title` | JSLT expression for entity title | +| `entity.properties` | Map of property names to JSLT expressions | +| `entity.relations` | Map of relation names to JSLT expressions | --- @@ -165,9 +182,9 @@ spring: --- -## JQ Mapping Reference +## JSLT Mapping Reference -The Internal Developer Platform will use [JQ](https://jqlang.github.io/jq/) for data transformation. It will access to the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Please refer to the JQ documentation for detailed usage. +The Internal Developer Platform uses [JSLT](https://github.com/schibsted/jslt) for data transformation. It accesses the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Refer to the JSLT documentation for detailed usage. --- @@ -201,8 +218,12 @@ Configure a webhook to receive GitHub repository events: } ], "security": { - "signature_header_name": "X-Hub-Signature-256", - "signature_value": "sha256=your-webhook-secret" + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } } } ``` @@ -218,8 +239,11 @@ Webhooks support signature-based authentication: ```json { "security": { - "signature_header_name": "X-Webhook-Signature", - "signature_value": "expected-secret-or-hmac" + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Webhook-Signature", + "secret_alias": "WEBHOOK_SHARED_TOKEN" + } } } ``` diff --git a/docs/src/features/index.md b/docs/src/features/index.md index 32deb947..42a7a559 100644 --- a/docs/src/features/index.md +++ b/docs/src/features/index.md @@ -13,7 +13,7 @@ The Internal Developer Platform provides a comprehensive set of features to buil --- - Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JQ expressions. + Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JSLT expressions. **Status:** πŸ• Planned diff --git a/docs/src/features/self-service-actions.md b/docs/src/features/self-service-actions.md index 5330bb6a..518a65ba 100644 --- a/docs/src/features/self-service-actions.md +++ b/docs/src/features/self-service-actions.md @@ -348,7 +348,7 @@ Use template expressions to inject dynamic values: ```bash {{ .entity.identifier }} // Entity ID -{{ .entity.title }} // Entity title +{{ .entity.name }} // Entity name {{ .entity.properties.owner }} // Entity property {{ .entity.relations.team }} // Entity relation ``` diff --git a/docs/src/index.md b/docs/src/index.md index 3e7bb9ff..e19bd660 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -131,7 +131,7 @@ Define your own **Entity Templates** that mirror your organization's specific ne ### Multi-Source Data Ingestion -Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JQ expressions. +Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JSLT expressions. ### Scorecards & Metrics diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index e5f70058..53f7fea1 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -14,7 +14,7 @@ tags: - name: Entities Management description: Operations related to entity management - name: Entities Templates Management - description: Operations related to entity template management + description: Operations related to entity entityTemplateIdentifier management paths: '/api/v1/entity-templates/{identifier}': get: @@ -45,7 +45,7 @@ paths: put: tags: - Entities Templates Management - summary: Update an existing template by template identifier + summary: Update an existing entityTemplateIdentifier by entityTemplateIdentifier identifier description: >- Update the details of an existing template identified by its unique string identifier @@ -78,8 +78,8 @@ paths: delete: tags: - Entities Templates Management - summary: Delete template by identifier - description: Remove a template from the system using its unique identifier + summary: Delete entityTemplateIdentifier by identifier + description: Remove a entityTemplateIdentifier from the system using its unique identifier operationId: deleteTemplate parameters: - name: identifier @@ -100,7 +100,7 @@ paths: get: tags: - Entities Management - summary: Get entity by entity template and identifier + summary: Get entity by entity entityTemplateIdentifier and identifier description: >- Retrieve a specific entity using its string identifier and its template identifier @@ -286,8 +286,8 @@ paths: post: tags: - Entities Templates Management - summary: Create a new template - description: Create a new template in the system with the provided information + summary: Create a new entityTemplateIdentifier + description: Create a new entityTemplateIdentifier in the system with the provided information operationId: createTemplate requestBody: content: @@ -303,7 +303,7 @@ paths: schema: $ref: '#/components/schemas/EntityTemplateDtoOut' '400': - description: Invalid template data provided + description: Invalid entityTemplateIdentifier data provided content: '*/*': schema: @@ -312,7 +312,7 @@ paths: get: tags: - Entities Management - summary: Get entities by template identifier + summary: Get entities by entityTemplateIdentifier identifier description: Retrieve a paginated list of entities with optional sorting operationId: getEntities parameters: @@ -416,7 +416,7 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' '409': - description: Entity already exists in this template + description: Entity already exists in this entityTemplateIdentifier content: '*/*': schema: @@ -550,7 +550,7 @@ components: schemas: EntityTemplateUpdateDtoIn: type: object - description: Input DTO for updating an entity template + description: Input DTO for updating an entity entityTemplateIdentifier properties: name: type: string @@ -562,15 +562,15 @@ components: description: type: string description: Entity Template description - example: A comprehensive service template + example: A comprehensive service entityTemplateIdentifier properties_definitions: type: array - description: List of property definitions for this template + description: List of property definitions for this entityTemplateIdentifier items: $ref: '#/components/schemas/PropertyDefinitionDtoIn' relations_definitions: type: array - description: List of relation definitions for this template + description: List of relation definitions for this entityTemplateIdentifier items: $ref: '#/components/schemas/RelationDefinitionDtoIn' required: @@ -663,7 +663,7 @@ components: minLength: 1 target_template_identifier: type: string - description: Identifier of the target template + description: Identifier of the target entityTemplateIdentifier example: service minLength: 1 required: @@ -681,7 +681,7 @@ components: - target_template_identifier EntityTemplateDtoOut: type: object - description: Output for entity template + description: Output for entity entityTemplateIdentifier properties: identifier: type: string @@ -694,15 +694,15 @@ components: description: type: string description: Entity Template description - example: A comprehensive service template + example: A comprehensive service entityTemplateIdentifier properties_definitions: type: array - description: List of property definitions for this template + description: List of property definitions for this entityTemplateIdentifier items: $ref: '#/components/schemas/PropertyDefinitionDtoOut' relations_definitions: type: array - description: List of relation definitions for this template + description: List of relation definitions for this entityTemplateIdentifier items: $ref: '#/components/schemas/RelationDefinitionDtoOut' PropertyDefinitionDtoOut: @@ -786,8 +786,8 @@ components: example: dependencies target_template_identifier: type: string - description: Identifier of the target template - example: component-template + description: Identifier of the target entityTemplateIdentifier + example: component-entityTemplateIdentifier required: type: boolean description: Whether this relation is required @@ -833,7 +833,7 @@ components: properties: name: type: string - description: Name of the relation (must match a template relation definition) + description: Name of the relation (must match a entityTemplateIdentifier relation definition) example: depends-on minLength: 1 target_entity_identifiers: @@ -880,7 +880,7 @@ components: type: string EntityTemplateCreateDtoIn: type: object - description: Input DTO for creating an entity template + description: Input DTO for creating an entity entityTemplateIdentifier properties: identifier: type: string @@ -897,15 +897,15 @@ components: description: type: string description: Entity Template description - example: A comprehensive service template + example: A comprehensive service entityTemplateIdentifier properties_definitions: type: array - description: List of property definitions for this template + description: List of property definitions for this entityTemplateIdentifier items: $ref: '#/components/schemas/PropertyDefinitionDtoIn' relations_definitions: type: array - description: List of relation definitions for this template + description: List of relation definitions for this entityTemplateIdentifier items: $ref: '#/components/schemas/RelationDefinitionDtoIn' required: @@ -917,7 +917,7 @@ components: properties: identifier: type: string - description: Unique identifier of the entity within the template scope + description: Unique identifier of the entity within the entityTemplateIdentifier scope example: my-web-service minLength: 1 name: @@ -1001,7 +1001,7 @@ components: Examples: template, identifier, name, relation, property.language, relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name - example: template + example: entityTemplateIdentifier operation: type: string description: >- @@ -1122,7 +1122,7 @@ components: description: Node id of the target entity type: type: string - description: Relation name as defined in the entity template + description: Relation name as defined in the entity entityTemplateIdentifier EntityGraphFlatDtoOut: type: object properties: @@ -1141,7 +1141,7 @@ components: properties: id: type: string - description: 'Unique node identifier composed of templateIdentifier:identifier' + description: 'Unique node identifier composed of entityTemplateIdentifier:identifier' label: type: string description: Human-readable entity name @@ -1150,7 +1150,7 @@ components: description: Template identifier this entity belongs to identifier: type: string - description: Business identifier of the entity within its template + description: Business identifier of the entity within its entityTemplateIdentifier data: type: object additionalProperties: {} diff --git a/docs/zensical.toml b/docs/zensical.toml index f72132b5..f9434c84 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -37,7 +37,8 @@ nav = [ "concepts/entity-filtering.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 2c775619..7f30c7fe 100644 --- a/pom.xml +++ b/pom.xml @@ -246,6 +246,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 e32977de..daadfd16 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 @@ -15,6 +15,7 @@ public class ValidationMessages { public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; + public static final String TEMPLATE_ALREADY_MAPPED = "Cannot delete template because it is currently mapped to '%s' entity dynamic mappings. Please remove the associated entity dynamic mappings before deleting the template."; // Property Definition validation messages public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; @@ -62,6 +63,15 @@ public class ValidationMessages { public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + // 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_IDENTIFIER_NOT_FOUND = "Target webhook with identifier '%s' does not exist"; + public static final String ENTITY_DYNAMIC_MAPPING_NOT_FOUND = "Entity dynamic mapping with identifier '%s' does not exist"; + public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS = "Entity dynamic mapping already exists with the same identifier '%s'"; + public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE = "Entity dynamic mapping already in use, please remove it from the associated webhook connector '%s' before deleting it"; + // Entity creation validation messages public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; @@ -114,4 +124,19 @@ public static String minMaxConstraintViolated(String constraint) { public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'"; public static final String SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH = "Property '%s' in template '%s' is of type %s; operators GT, GTE, LT, LTE require type NUMBER"; + // Entity Dynamic mapping validation messages + public static final String ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY = "Webhook mapping filter is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY = "Entity dynamic mapping identifier is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY = "Entity dynamic mapping name is mandatory"; + public static final String WEBHOOK_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY = "Webhook mapping entityTemplateIdentifier identifier is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY = "Webhook mapping entity section is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY = "Entity name is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory"; + + public static final String WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY = "Webhook security type is mandatory"; + public static final String WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY = "Webhook security config is mandatory"; + public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH = "Webhook identifier must not exceed 255 characters"; + public static final String WEBHOOK_CONNECTOR_NAME_MAX_LENGTH = "Webhook name must not exceed 255 characters"; + public static final String WEBHOOK_CONNECTOR_NAME_MANDATORY = "Webhook name is mandatory"; + } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java new file mode 100644 index 00000000..3f37a79e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS; + +public class EntityDynamicMappingAlreadyExistsException extends RuntimeException { + + public EntityDynamicMappingAlreadyExistsException(String identifier) { + super(String.format(ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java new file mode 100644 index 00000000..63cfee53 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE; + +import java.util.List; + +public class EntityDynamicMappingAlreadyInUseException extends RuntimeException { + + public EntityDynamicMappingAlreadyInUseException(List identifier) { + super(ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE.formatted(identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java new file mode 100644 index 00000000..512c3f56 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_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_dynamic_mapping/EntityDynamicMappingNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java new file mode 100644 index 00000000..7b5dadbc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_NOT_FOUND; + +public class EntityDynamicMappingNotFoundException extends RuntimeException { + + public EntityDynamicMappingNotFoundException(String identifier) { + super(String.format(ENTITY_DYNAMIC_MAPPING_NOT_FOUND, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java new file mode 100644 index 00000000..6f675865 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + +/// Domain exception for missing [EntityTemplate] business entities. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to access an EntityTemplate that doesn't exist in the system. This is a +/// critical business error since entities cannot be created without valid templates. +/// +/// **Exception design rationale:** +/// - Multiple constructors support different lookup scenarios (ID, identifier, field-based) +/// - Meaningful error messages aid in debugging and API error responses +/// - Domain-level exception keeps business logic separate from HTTP concerns +/// +/// **Usage patterns:** +/// - Template validation before entity operations +/// - Template-based entity queries +/// - Template management operations +public class EntityTemplateInUseByWebhookMappingException extends RuntimeException { + + /// Constructs a new exception with a custom error message. + /// + /// **Why this exists:** Allows for specific error messages that provide more + /// context about the search criteria or operation that failed. + /// + /// @param message the detail message explaining what was not found + public EntityTemplateInUseByWebhookMappingException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java index 2ce1db43..151aa0ae 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java @@ -1,7 +1,5 @@ package com.decathlon.idp_core.domain.exception.entity_template; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - /// Domain exception for property rule validation violations. /// /// **Business purpose:** Represents the business rule violation when property rules @@ -18,7 +16,7 @@ public class PropertyDefinitionRulesConflictException extends RuntimeException { /// @param propertyName the name of the property with invalid rules /// @param propertyType the data type of the property /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + public PropertyDefinitionRulesConflictException(String propertyName, String propertyType, String violationMessage) { super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } 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 00000000..5658985b --- /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/PropertyTypeChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java index 44d8d2a7..0f1b685b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java @@ -2,8 +2,6 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_CANNOT_CHANGE; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - /// Exception thrown when attempting any property type change. /// /// This exception represents a business rule violation where type changes are blocked @@ -20,8 +18,7 @@ public class PropertyTypeChangeException extends RuntimeException { /// @param propertyName the name of the property whose type is being changed /// @param fromType the current property type /// @param toType the requested new property type - public PropertyTypeChangeException(String propertyName, PropertyType fromType, - PropertyType toType) { + public PropertyTypeChangeException(String propertyName, String fromType, String toType) { super(String.format(PROPERTY_TYPE_CANNOT_CHANGE, propertyName, fromType, toType)); } } 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 00000000..3ac2b906 --- /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/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index 737c7c84..6f4ddd0d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -1,7 +1,5 @@ package com.decathlon.idp_core.domain.exception.property; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - /// Domain exception for property rule validation violations. /// /// **Business purpose:** Represents the business rule violation when property rules @@ -18,7 +16,7 @@ public class PropertyDefinitionRulesConflictException extends RuntimeException { /// @param propertyName the name of the property with invalid rules /// @param propertyType the data type of the property /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + public PropertyDefinitionRulesConflictException(String propertyName, String propertyType, String violationMessage) { super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } 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 00000000..eafbc81a --- /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 00000000..26b07865 --- /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/WebhookConnectorConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java new file mode 100644 index 00000000..8e848a26 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookConnectorConfigurationException extends RuntimeException { + public WebhookConnectorConfigurationException(String message) { + super(message); + } +} 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 00000000..bd9fc6bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_IDENTIFIER_NOT_FOUND; + +public class WebhookConnectorNotFoundException extends RuntimeException { + + public WebhookConnectorNotFoundException(String identifier) { + super(String.format(WEBHOOK_IDENTIFIER_NOT_FOUND, 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 00000000..da65c822 --- /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 00000000..6bc70eb0 --- /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 00000000..e7b9d6dc --- /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 00000000..aadf835d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java @@ -0,0 +1,55 @@ +package com.decathlon.idp_core.domain.model.entity_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import java.util.Map; +import java.util.UUID; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; + +/// Domain model representing dynamic entity mapping configuration. +/// +/// Each mapping defines how to transform inbound webhook events into entity instances, +/// including property/relation mappings and JSLT transformation rules. +/// +/// Note: The technical ID is managed purely at the infrastructure layer +/// (persisted in entity_dynamic_mapping table) and is NOT part of the domain model. +public record EntityDynamicMapping(UUID id, String identifier, String entityTemplateIdentifier, + String filter, String name, String description, String entityIdentifier, String entityName, + Map properties, Map relations) { + + public EntityDynamicMapping { + if (isBlank(identifier)) { + throw new EntityDynamicMappingConfigurationException( + ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY); + } + if (isBlank(name)) { + throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY); + } + if (isBlank(entityTemplateIdentifier)) { + throw new EntityDynamicMappingConfigurationException( + WEBHOOK_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY); + } + + if (isBlank(filter)) { + throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY); + } + + if (isBlank(entityIdentifier)) { + throw new EntityDynamicMappingConfigurationException( + ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY); + } + + if (isBlank(entityName)) { + throw new EntityDynamicMappingConfigurationException( + ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY); + } + + properties = properties == null ? Map.of() : Map.copyOf(properties); + relations = relations == null ? Map.of() : Map.copyOf(relations); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} 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 00000000..992e7dc8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java @@ -0,0 +1,16 @@ +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/inbound_connectors/webhook/WebhookConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java new file mode 100644 index 00000000..828a9207 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java @@ -0,0 +1,47 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorConfigurationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public record WebhookConnector(UUID id, String identifier, String name, String description, + boolean enabled, List mappings, WebhookSecurity security) { + public WebhookConnector { + mappings = mappings == null ? List.of() : List.copyOf(mappings); + + if (security == null) { + throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY); + } + + if (isBlank(identifier)) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY); + } + if (identifier.length() > 255) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH); + } + + if (isBlank(name)) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MANDATORY); + } + if (name.length() > 255) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MAX_LENGTH); + } + } + + /// Creates a copy of this connector with the specified enabled state. + /// + /// @param enabled the new enabled state + /// @return a new WebhookConnector instance with the updated enabled field + public WebhookConnector withEnabled(boolean enabled) { + return new WebhookConnector(id, identifier, name, description, enabled, mappings, security); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java new file mode 100644 index 00000000..9aba1e97 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY; + +import java.util.Map; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +public record WebhookSecurity(WebhookSecurityType type, Map config) { + + public WebhookSecurity { + if (type == null) { + throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY); + } + if (config == null) { + throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY); + } + config = Map.copyOf(config); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java new file mode 100644 index 00000000..9f8ff75e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import java.util.UUID; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + +/// Domain model representing the mapping between a webhook event and an entity entityTemplateIdentifier. +/// +/// Per the webhook_template_mapping schema: +/// - Links a webhook connector to an entity entityTemplateIdentifier for event ingestion +/// - Contains the JSLT filter to apply during transformation +/// - Includes both technical IDs (from persistence) and functional domain objects +/// +/// @param id technical identifier of the mapping record +/// @param webhookConnector domain model of the associated webhook connector +/// @param entityTemplate domain model of the target entity entityTemplateIdentifier +/// @param entityDynamicMapping domain model of the dynamic mapping configuration +/// @param jsltFilter JSLT filter expression for event ingestion +public record WebhookTemplateMapping(UUID id, WebhookConnector webhookConnector, + EntityTemplate entityTemplate, EntityDynamicMapping entityDynamicMapping, String jsltFilter) { +} 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 00000000..7c55a86f --- /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/EntityDynamicMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java new file mode 100644 index 00000000..1e19cbf8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public interface EntityDynamicMappingPort { + + List findByTemplateIdentifier(String templateIdentifier); + List findByTemplateId(UUID templateIde); + + Boolean existsByTemplateIdentifier(String templateIdentifier); + + boolean existsByIdentifier(String identifier); + + Optional findByIdentifier(String identifier); + + EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping); + + Page findAll(Pageable pageable); + + void deleteByIdentifier(String identifier); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java index 5f4f9106..0ffcebc2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java @@ -35,4 +35,6 @@ public interface EntityTemplateRepositoryPort { EntityTemplate save(EntityTemplate entityTemplate); void deleteByIdentifier(String identifier); + + boolean existsById(UUID id); } 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 00000000..fba415ae --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java @@ -0,0 +1,23 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; + +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/WebhookMappingLinkPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java new file mode 100644 index 00000000..afa3be65 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; + +public interface WebhookMappingLinkPort { + + boolean existsByEntityMappingId(UUID id); + + List findByEntityMappingId(UUID id); + +} 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 00000000..3e151508 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.port; + +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); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/DynamicMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/DynamicMappingService.java new file mode 100644 index 00000000..b988f7b9 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/DynamicMappingService.java @@ -0,0 +1,111 @@ +package com.decathlon.idp_core.domain.service.entity_dynamic_mapping; + +import java.util.List; +import java.util.UUID; + +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; + +import lombok.RequiredArgsConstructor; + +@Service +@Validated +@RequiredArgsConstructor +public class DynamicMappingService { + + private final EntityDynamicMappingPort entityDynamicMappingPort; + private final WebhookMappingLinkPort webhookTemplateMappingPort; + private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + + @Transactional + public EntityDynamicMapping createEntityDynamicMapping( + EntityDynamicMapping entityDynamicMapping) { + validateIdentifierUniqueness(entityDynamicMapping.identifier()); + webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping); + return entityDynamicMappingPort.save(entityDynamicMapping); + } + + public Page getAllEntityDynamicMapping(Pageable pageable) { + return entityDynamicMappingPort.findAll(pageable); + } + + @Transactional + public void deleteEntityDynamicMapping(String entityDynamicMappingIdentifier) { + validateIdentifierExists(entityDynamicMappingIdentifier); + UUID dynamicMappingIdentifier = entityDynamicMappingPort + .findByIdentifier(entityDynamicMappingIdentifier).orElseThrow( + () -> new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier)) + .id(); + validateIsNotInUse(dynamicMappingIdentifier); + entityDynamicMappingPort.deleteByIdentifier(entityDynamicMappingIdentifier); + } + + private void validateIsNotInUse(UUID entityDynamicMappingId) { + if (webhookTemplateMappingPort.existsByEntityMappingId(entityDynamicMappingId)) { + List webhookTemplateMappingList = webhookTemplateMappingPort + .findByEntityMappingId(entityDynamicMappingId); + List webhookIdentifiers = webhookTemplateMappingList.stream() + .map(WebhookTemplateMapping::webhookConnector) + .filter(webhook -> webhook != null && webhook.identifier() != null) + .map(WebhookConnector::identifier).distinct().toList(); + throw new EntityDynamicMappingAlreadyInUseException(webhookIdentifiers); + } + } + + private void validateIdentifierExists(String entityDynamicMappingIdentifier) { + if (!entityDynamicMappingPort.existsByIdentifier(entityDynamicMappingIdentifier)) { + throw new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier); + } + } + + /// Ensures no other dynamic mapping already uses the provided identifier. + /// + /// This enforces the `entity_dynamic_mapping_identifier_key` unique constraint + /// at the domain level, returning a meaningful conflict instead of letting the + /// database raise a low-level integrity violation. + /// + /// @param identifier the candidate mapping identifier + /// @throws EntityDynamicMappingAlreadyExistsException when the identifier is + /// already used + private void validateIdentifierUniqueness(String identifier) { + if (entityDynamicMappingPort.existsByIdentifier(identifier)) { + throw new EntityDynamicMappingAlreadyExistsException(identifier); + } + } + + public EntityDynamicMapping getEntityDynamicMapping(String identifier) { + return entityDynamicMappingPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier)); + } + + @Transactional + public EntityDynamicMapping updateEntityDynamicMapping(String identifier, + @Valid EntityDynamicMapping entityDynamicMapping) { + EntityDynamicMapping existingMapping = getEntityDynamicMapping(identifier); + webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping); + + EntityDynamicMapping mergedMapping = new EntityDynamicMapping(existingMapping.id(), + existingMapping.identifier(), entityDynamicMapping.entityTemplateIdentifier(), + entityDynamicMapping.filter(), entityDynamicMapping.name(), + entityDynamicMapping.description(), entityDynamicMapping.entityIdentifier(), + entityDynamicMapping.entityName(), entityDynamicMapping.properties(), + entityDynamicMapping.relations()); + + return entityDynamicMappingPort.save(mergedMapping); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java new file mode 100644 index 00000000..a3ec959e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java @@ -0,0 +1,120 @@ +package com.decathlon.idp_core.domain.service.entity_dynamic_mapping; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +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 com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +import lombok.RequiredArgsConstructor; + +/// Validates webhook dynamic mappings against their target entity entityTemplateIdentifier. +/// This service ensures the mapping references an existing entityTemplateIdentifier, that all +/// mapped properties and relations exist in that entityTemplateIdentifier, and that required +/// entityTemplateIdentifier elements are provided before the mapping is accepted. +@Service +@Validated +@RequiredArgsConstructor +public class EntityDynamicMappingValidationService { + private final EntityTemplateService entityTemplateService; + private final EntityDynamicMapperValidator entityDynamicMapperValidator; + private final EntityTemplateValidationService entityTemplateValidationService; + private final PropertyValidationService propertyValidationService; + + /// Validates all mappings attached to a webhook connector. + /// + /// @param mappings the mappings to validate + /// @throws WebhookTemplateHasNoPropertiesException when one or more mappings + /// are invalid + public void validateWebhookMapping(List mappings) { + mappings.forEach(this::validateMapping); + } + + /// Validates a single [EntityDynamicMapping]: + /// - The referenced EntityTemplate must exist. + /// - Each key in `properties` must match a property defined in the + /// entityTemplateIdentifier. + /// - Required properties and relations from the target entityTemplateIdentifier + /// are present. + /// - The mapping expression syntax is valid. + /// + /// @param entityDynamicMapping the mapping to validate + public void validateMapping(EntityDynamicMapping entityDynamicMapping) { + String templateIdentifier = entityDynamicMapping.entityTemplateIdentifier(); + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + + Map properties = entityDynamicMapping.properties() != null + ? entityDynamicMapping.properties() + : Collections.emptyMap(); + Map relations = entityDynamicMapping.relations() != null + ? entityDynamicMapping.relations() + : Collections.emptyMap(); + + propertyValidationService.validateAgainstTemplate(entityTemplate, + List.copyOf(properties.keySet())); + validateRequiredPropertiesAreMapped(properties, entityTemplate.propertiesDefinitions()); + + entityTemplateValidationService.validateRelationNameAlreadyExistInTemplate(relations, + entityTemplate); + validateRequiredRelationDefinitionsAreMapped(relations, entityTemplate.relationsDefinitions()); + + entityDynamicMapperValidator.validate(entityDynamicMapping); + } + + /// Validates that all required relation definitions in the target + /// entityTemplateIdentifier + /// are provided by the mapping. + /// + /// @param mappingRelations relations declared by the mapping + /// @param templateRelations relation definitions declared by the + /// entityTemplateIdentifier + /// @throws WebhookTemplateHasNoPropertiesException when one or more required + /// relations are missing in the mapping + 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 entityTemplateIdentifier relations: %s", + String.join(", ", missingRelations))); + } + } + + /// Validates that all required property definitions in the target + /// entityTemplateIdentifier + /// are provided by the mapping. + /// + /// @param mappingProperties properties declared by the mapping + /// @param templateProperties property definitions declared by the + /// entityTemplateIdentifier + /// @throws WebhookTemplateHasNoPropertiesException when one or more required + /// properties are missing in the mapping + 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 entityTemplateIdentifier properties: %s", + String.join(", ", missingProperties))); + } + + } +} 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 4ec24faa..42c5273c 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,21 +1,19 @@ package com.decathlon.idp_core.domain.service.entity_template; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_MAPPED; + +import java.util.List; +import java.util.Map; 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_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.EntityDynamicMappingPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import lombok.RequiredArgsConstructor; @@ -34,6 +32,7 @@ public class EntityTemplateValidationService { private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; private final PropertyDefinitionValidationService propertyDefinitionValidationService; private final RelationDefinitionValidationService relationDefinitionValidationService; + private final EntityDynamicMappingPort entityDynamicMappingPort; /// Validates all business rules before creating a new entity template. /// @@ -139,6 +138,31 @@ public void validateForDeletion(String identifier) { throw new EntityTemplateNotFoundException("identifier", "null"); } validateTemplateExists(identifier); + validateTemplateNotInUseMapping(identifier); + } + + /// Validates that the entity template is not currently referenced by any + /// dynamic mapping. + /// + /// Design Note: This check relies directly on the `EntityDynamicMappingPort` + /// rather than + /// injecting `DynamicMappingService`. This is a deliberate architectural choice + /// to prevent + /// a circular dependency between our domain services (TemplateValidation -> + /// DynamicMapping -> TemplateValidation). + /// It allows the template domain to enforce its own referential integrity + /// before deletion + /// while remaining loosely coupled. + public void validateTemplateNotInUseMapping(String entityTemplateIdentifier) { + EntityTemplate template = entityTemplateRepositoryPort + .findByIdentifier(entityTemplateIdentifier).orElseThrow( + () -> new EntityTemplateNotFoundException("identifier", entityTemplateIdentifier)); + List mappings = entityDynamicMappingPort.findByTemplateId(template.id()); + if (!mappings.isEmpty()) { + List webhookIds = mappings.stream().map(EntityDynamicMapping::identifier).toList(); + throw new EntityTemplateInUseByWebhookMappingException( + TEMPLATE_ALREADY_MAPPED.formatted(webhookIds)); + } } /// Checks that the entity template exists. @@ -191,6 +215,16 @@ private void validateTemplateProperties(EntityTemplate entityTemplate) { } } + public void validateRelationNameAlreadyExistInTemplate( + Map webhookMappingRelations, EntityTemplate entityTemplate) { + if (webhookMappingRelations == null || webhookMappingRelations.isEmpty()) { + return; + } + webhookMappingRelations.keySet() + .forEach(relationName -> validateRelationNameAlreadyExistInTemplate( + entityTemplate.relationsDefinitions(), relationName)); + } + /// Validates all relation definitions within the template for structural and /// referential integrity. /// @@ -213,4 +247,18 @@ private void validateTemplateRelations(EntityTemplate entityTemplate) { .validateTargetTemplatesExist(entityTemplate.relationsDefinitions()); } + 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 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/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index d61a4bda..8063c293 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -102,7 +102,8 @@ public void validateTypeChanges(List existingProperties, boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); if (propertyTypeChanged) { - throw new PropertyTypeChangeException(existing.name(), existing.type(), updated.type()); + throw new PropertyTypeChangeException(existing.name(), existing.type().name(), + updated.type().name()); } } } @@ -177,18 +178,18 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule private void validateStringConstraints(String propertyName, PropertyRules rules) { // Validate min_length is non-negative if (rules.minLength() != null && rules.minLength() < 0) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE); } // Validate max_length is not zero or negative if (rules.maxLength() != null && rules.maxLength() <= 0) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), PROPERTY_RULES_MAX_LENGTH_POSITIVE); } // Validate min_length is below or equal to max_length if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), minMaxConstraintViolated(LENGTH)); } } @@ -210,31 +211,31 @@ private void validateStringIncompatibleRules(String propertyName, PropertyRules // Reject numeric rules for STRING type if (rules.maxValue() != null || rules.minValue() != null) { String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName)); } // format, regex, and enum_values are incompatible with each other if (rules.format() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(FORMAT, ENUM_VALUES)); } if (rules.format() != null && rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(FORMAT, REGEX)); } if (rules.regex() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(REGEX, ENUM_VALUES)); } // enum_values and length constraints are incompatible with each other if (rules.enumValues() != null && rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH)); } if (rules.enumValues() != null && rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH)); } @@ -254,33 +255,33 @@ private void validateStringIncompatibleRules(String propertyName, PropertyRules /// or min/max value constraints are violated private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { if (rules.format() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(FORMAT, PropertyType.NUMBER.name())); } if (rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name())); } if (rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(REGEX, PropertyType.NUMBER.name())); } if (rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name())); } if (rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name())); } if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), minMaxConstraintViolated(VALUE)); } } @@ -299,7 +300,7 @@ private void validateBooleanPropertyRules(String propertyName, PropertyRules rul || rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null || rules.minValue() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN.name(), PROPERTY_RULES_BOOLEAN_NOT_ALLOWED); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java index ee4aca98..7a98f8d3 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java @@ -52,12 +52,12 @@ public class PropertyRegexValidationService { /// @throws PropertyDefinitionRulesConflictException if any security check fails public void validateRegexPattern(String propertyName, String regexPattern) { if (regexPattern.length() > MAX_REGEX_LENGTH) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); } if (containsDangerousPatterns(regexPattern)) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern contains potentially unsafe constructs"); } @@ -65,7 +65,7 @@ public void validateRegexPattern(String propertyName, String regexPattern) { try { compiledRegexPattern = Pattern.compile(regexPattern); } catch (PatternSyntaxException e) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Invalid regex pattern: " + e.getMessage()); } @@ -91,14 +91,14 @@ private void validatePatternWithTimeout(String propertyName, Pattern pattern) { future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (TimeoutException _) { future.cancel(true); - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); } catch (InterruptedException _) { Thread.currentThread().interrupt(); - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern validation was interrupted"); } catch (ExecutionException e) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex validation failed: " + e.getCause().getMessage()); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index f1e8ff44..0dbea7f9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -1,20 +1,12 @@ package com.decathlon.idp_core.domain.service.property; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -22,6 +14,7 @@ import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; @@ -76,6 +69,7 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, /// provided and non-blank. If a required property is missing, adds a violation. /// If the property is present, validates its value against the definition's /// rules and accumulates any violations found. + /// /// @param template the entity template whose property definitions are used for /// validation /// @param definitions the list of property definitions from the template @@ -118,6 +112,35 @@ public void validatePropertiesAgainstTemplate(final EntityTemplate template, } } + /// Validates that every provided property name is declared in the given + /// template's property definitions. + /// + /// This is a **name-level** check (as opposed to + /// [#validatePropertiesAgainstTemplate], which validates concrete runtime + /// values). It is used, for example, by dynamic mapping validation to ensure a + /// mapping only references properties that actually exist in the target + /// template. + /// + /// @param template the target template whose property definitions are the + /// source of truth + /// @param propertyNames the property names to check against the template + /// @throws PropertyNameNotFoundEntityTemplatePropertiesException when a name is + /// not declared in the template + public void validateAgainstTemplate(EntityTemplate template, List propertyNames) { + if (propertyNames == null || propertyNames.isEmpty()) { + return; + } + + Set definedPropertyNames = template.propertiesDefinitions().stream() + .map(PropertyDefinition::name).collect(Collectors.toSet()); + + propertyNames.stream().filter(name -> !definedPropertyNames.contains(name)).findFirst() + .ifPresent(name -> { + throw new PropertyNameNotFoundEntityTemplatePropertiesException( + String.format("Property name %s not found in entity template properties", name)); + }); + } + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { if (!(rawValue instanceof String stringValue)) { 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 00000000..e83ebc1d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java @@ -0,0 +1,96 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import java.util.List; + +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +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.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; + +import lombok.RequiredArgsConstructor; + +@Service +@Validated +@RequiredArgsConstructor +public class WebhookConnectorService { + + private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final WebhookConnectorValidationService webhookConnectorValidationService; + private final EntityDynamicMappingPort entityDynamicMappingPort; + + /// Resolves a list of entity dynamic mapping identifiers into their existing + /// domain models. + /// + /// Each identifier is validated against the persisted dynamic mappings. This + /// guarantees a webhook connector can only reference mappings that were + /// previously created through the `/api/v1/entity-dynamic-mappings` endpoint. + /// + /// @param mappingIdentifiers the referenced mapping identifiers (may be null or + /// empty) + /// @return the resolved mappings, in the same order as the provided identifiers + /// @throws EntityDynamicMappingNotFoundException when an identifier does not + /// match any existing mapping + public List resolveAndValidateMappings(List mappingIdentifiers) { + if (mappingIdentifiers == null || mappingIdentifiers.isEmpty()) { + return List.of(); + } + return mappingIdentifiers.stream().map(this::resolveMappingOrThrow).toList(); + } + + private EntityDynamicMapping resolveMappingOrThrow(String identifier) { + return entityDynamicMappingPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier)); + } + + public WebhookConnector getWebhookConnector(String identifier) { + return webhookConnectorRepositoryPort.findByIdentifier(identifier) + .orElseThrow(() -> new WebhookConnectorNotFoundException(identifier)); + } + + @Transactional + public WebhookConnector createWebhookConnector(WebhookConnector connector) { + webhookConnectorValidationService.validateWebhookConnectorForCreation(connector); + + WebhookConnector connectorToSave = connector.mappings().isEmpty() && connector.enabled() + ? connector.withEnabled(false) + : connector; + + return webhookConnectorRepositoryPort.save(connectorToSave); + } + + @Transactional + public WebhookConnector updateWebhookConnector(String identifier, + @Valid WebhookConnector connectorToUpdate) { + WebhookConnector webhookConnectorInDb = getWebhookConnector(identifier); + webhookConnectorValidationService.validateWebhookConnectorForUpdate(webhookConnectorInDb, + connectorToUpdate); + + boolean enabledValue = !connectorToUpdate.mappings().isEmpty() && connectorToUpdate.enabled(); + WebhookConnector mergedConnector = new WebhookConnector(webhookConnectorInDb.id(), + webhookConnectorInDb.identifier(), connectorToUpdate.name(), + connectorToUpdate.description(), enabledValue, 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 00000000..596a8419 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java @@ -0,0 +1,78 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +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.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService; +import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; + +import lombok.RequiredArgsConstructor; + +/// Domain validation service for webhook connector lifecycle operations. +/// It validates connector uniqueness rules and delegates mapping and security +/// validation to dedicated domain services. +@Service +@Validated +@RequiredArgsConstructor +public class WebhookConnectorValidationService { + + private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final WebhookMappingLinkPort webhookTemplateMappingPort; + private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + private final WebhookSecurityValidationService webhookSecurityValidationService; + + public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) { + validateIdentifierUniqueness(webhookConnector.identifier()); + validateTitleUniqueness(webhookConnector.name()); + webhookSecurityValidationService.validateForCreation(webhookConnector.security()); + + } + + public void validateWebhookConnectorForUpdate(WebhookConnector existingConnector, + WebhookConnector webhookConnectorToUpdate) { + if (!existingConnector.name().equals(webhookConnectorToUpdate.name())) { + validateTitleUniqueness(webhookConnectorToUpdate.name()); + } + validateMappingsIfPresent(webhookConnectorToUpdate); + webhookSecurityValidationService.validateForCreation(webhookConnectorToUpdate.security()); + } + + private void validateMappingsIfPresent(WebhookConnector webhookConnector) { + if (!webhookConnector.mappings().isEmpty()) { + webhookConnectorMappingValidationService.validateWebhookMapping(webhookConnector.mappings()); + } + } + + public void validateTitleUniqueness(String webhookTitle) { + if (webhookConnectorRepositoryPort.existsByTitle(webhookTitle)) { + throw new WebhookConnectorTitleAlreadyExistsException(webhookTitle); + } + + } + + /// 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(webhookConnectorIdentifier); + } + } + +} 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 00000000..538ff3a2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java @@ -0,0 +1,56 @@ +package com.decathlon.idp_core.domain.service.webhook.security; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +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.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +/// 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/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index 47ce8abb..a5e679ab 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 @@ -14,7 +14,9 @@ import org.springframework.data.domain.Pageable; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.jackson.ModelResolver; @@ -100,4 +102,21 @@ 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); + } + } + + @Schema(description = "Paginated response containing Entity Dynamic Mapping objects") + public static class EntityDynamicMappingPageResponse + extends + PageImpl { + public EntityDynamicMappingPageResponse(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 20527874..b999979a 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 @@ -42,9 +42,6 @@ public class SwaggerDescription { public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; @@ -68,11 +65,42 @@ 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_PUT_ENTITY_SUMMARY = "Update an existing entity"; public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information"; public static final String ENDPOINT_DELETE_ENTITY_SUMMARY = "Delete an existing entity"; public static final String ENDPOINT_DELETE_ENTITY_DESCRIPTION = "Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it."; + 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"; + + public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY = "Create a new webhook connector configuration"; + public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION = "Creates a webhook connector configuration used by the generic inbound webhook endpoint"; + + public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Create entity dynamic mapping"; + public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Creates a new entity dynamic mapping used by the generic inbound webhook endpoint"; + + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY = "Get paginated entity dynamic mappings"; + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entity dynamic mappings with optional sorting"; + + public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Delete an entity dynamic mapping by identifier"; + public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Remove an entity dynamic from the system using its unique identifier"; + + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY = "Get an entity dynamic mapping by identifier"; + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION = "Retrieve an entity dynamic mapping using its string identifier"; + + public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Update an existing entity dynamic mapping by identifier"; + public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Update the details of an existing entity dynamic mapping identified by its unique string identifier"; + /// API response description constants public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; @@ -98,6 +126,21 @@ public class SwaggerDescription { public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; + 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_CREATED = "Webhook connector created"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_UPDATED = "Webhook connector updated successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_CONFLICT = "Webhook connector already exists in this entityTemplateIdentifier"; + public static final String RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA = "Invalid webhook connector data provided"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED = "Entity dynamic mapping created"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA = "Invalid entity dynamic mapping data provided"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS = "Paginated entity dynamic mapping retrieved successfully"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED = "Entity dynamic mapping deleted successfully"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER = "Entity dynamic mapping not found with the provided identifier"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND = "Entity dynamic mapping found"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED = "Entity dynamic mapping 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/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java new file mode 100644 index 00000000..7ca571d2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -0,0 +1,109 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; +import static org.springframework.http.HttpStatus.*; + +import jakarta.validation.Valid; + +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 com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.DynamicMappingService; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.DynamicMappingMapper; + +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 lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/entity_dynamic_mappings") +@Tag(name = "Entity dynamic mapping", description = "Operations related to entity dynamic mapping management") +public class EntityDynamicMappingController { + + private final DynamicMappingMapper dynamicMappingMapper; + private final DynamicMappingService dynamicMappingService; + + @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA) + @ApiResponse(responseCode = CONFLICT_CODE, description = "Identifier already exists") + @PostMapping + @ResponseStatus(CREATED) + public EntityDynamicMappingDtoOut createDynamicMapping( + @Valid @RequestBody EntityDynamicMappingCreateDtoIn inboundWebhookMappingDtoIn) { + EntityDynamicMapping entityDynamicMapping = dynamicMappingService + .createEntityDynamicMapping(dynamicMappingMapper.toDomain(inboundWebhookMappingDtoIn)); + return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping); + } + + @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.EntityDynamicMappingPageResponse.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 getEntityDynamicMappingPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + return dynamicMappingService.getAllEntityDynamicMapping(pageable) + .map(dynamicMappingMapper::fromEntityMappingToDto); + } + + @Operation(summary = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteEntityDynamicMapping(@PathVariable String identifier) { + dynamicMappingService.deleteEntityDynamicMapping(identifier); + } + + @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier( + @PathVariable String identifier) { + EntityDynamicMapping entityDynamicMapping = dynamicMappingService + .getEntityDynamicMapping(identifier); + return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping); + } + + @Operation(summary = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = "", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + @ResponseStatus(OK) + public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier, + @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn) { + return dynamicMappingMapper + .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier, + dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn))); + } +} 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 00000000..b98e591d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java @@ -0,0 +1,114 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; +import static org.springframework.http.HttpStatus.*; + +import jakarta.validation.Valid; + +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 com.decathlon.idp_core.domain.model.inbound_connectors.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.connector.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 lombok.RequiredArgsConstructor; + +/// 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 = ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CREATED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CONFLICT) + @PostMapping + @ResponseStatus(CREATED) + public InboundWebhookDtoOut createInboundWebhook( + @Valid @RequestBody InboundWebhookCreateDtoIn request) { + WebhookConnector webhookConnector = webhookConnectorService + .createWebhookConnector(inboundWebhookMapper.toDomain(request, + webhookConnectorService.resolveAndValidateMappings(request.mappingIdentifiers()))); + 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 getWebhooksPaginated( + @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 deleteWebhookConnector(@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 getWebhookConnectorByIdentifier(@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 name already exists", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + @ResponseStatus(OK) + public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, + @Valid @RequestBody InboundWebhookCreateDtoIn request) { + var resolvedMappings = webhookConnectorService + .resolveAndValidateMappings(request.mappingIdentifiers()); + return inboundWebhookMapper + .fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier, + inboundWebhookMapper.toDomainForUpdate(identifier, request, resolvedMappings))); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java new file mode 100644 index 00000000..412fdc25 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java @@ -0,0 +1,23 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// Mapping rule request for inbound webhook transformation. +public record EntityDynamicMappingCreateDtoIn( + + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY) String identifier, + @NotBlank(message = WEBHOOK_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description, + @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid InboundWebhookEntityMappingDtoIn entity) { + + /// Returns a CommonFields view for compatibility with the mapper. + public EntityDynamicMappingDtoInCommonFields commonFields() { + return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name, + description, entity); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java new file mode 100644 index 00000000..7c10e55e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Common fields for entity dynamic mapping requests (create and update). +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record EntityDynamicMappingDtoInCommonFields( + @NotBlank(message = "Webhook mapping entityTemplateIdentifier is mandatory") String entityTemplateIdentifier, + @NotBlank(message = "Webhook mapping filter is mandatory") String filter, + @NotBlank(message = "Webhook name is mandatory") String name, String description, + @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/EntityDynamicMappingUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java new file mode 100644 index 00000000..f1023bad --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.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 update. +public record EntityDynamicMappingUpdateDtoIn( + @NotBlank(message = "Webhook mapping entityTemplateIdentifier is mandatory") String entityTemplateIdentifier, + @NotBlank(message = "Webhook mapping filter is mandatory") String filter, + @NotBlank(message = "Webhook name is mandatory") String name, String description, + @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn entity) { + + /// Returns a CommonFields view for compatibility with the mapper. + public EntityDynamicMappingDtoInCommonFields commonFields() { + return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name, + description, entity); + } +} 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 00000000..55808e5f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Request payload used to create an inbound webhook connector configuration. +/// +/// Mappings are no longer embedded in the connector payload. They are created +/// independently through the `/api/v1/entity-dynamic-mappings` endpoint and +/// referenced here by their identifiers. Each referenced mapping existence is +/// validated in the domain layer before the connector is persisted. +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record InboundWebhookCreateDtoIn( + @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) @Size(max = 255, message = "Webhook identifier must not exceed 255 characters") String identifier, + @NotBlank(message = "Webhook name is mandatory") @Size(max = 255, message = "Webhook name must not exceed 255 characters") String name, + String description, boolean enabled, List mappingIdentifiers, + @Valid InboundWebhookSecurityContractDtoIn security) { + + public InboundWebhookCreateDtoIn { + mappingIdentifiers = mappingIdentifiers != null ? List.copyOf(mappingIdentifiers) : List.of(); + } +} 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 00000000..406e35e9 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java @@ -0,0 +1,19 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import java.util.Map; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// 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 name expression is mandatory") String name, + @NotNull(message = "Webhook entity properties section is mandatory") Map properties, + @NotNull(message = "Webhook entity relations section is mandatory") Map relations) { + + public InboundWebhookEntityMappingDtoIn { + properties = properties != null ? Map.copyOf(properties) : null; + relations = relations != null ? Map.copyOf(relations) : null; + } +} 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 00000000..17dc2816 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java @@ -0,0 +1,16 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import java.util.Map; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// 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) { + + public InboundWebhookSecurityContractDtoIn { + config = config != null ? Map.copyOf(config) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java new file mode 100644 index 00000000..f56b8e64 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping; + +import java.util.Map; + +/// Mapping rule returned by the inbound webhook management API. +public record EntityDynamicMappingDtoOut(String identifier, String entityTemplateIdentifier, + String filter, String name, String description, InboundWebhookEntityMappingDtoOut entity) { + /// Entity projection details exposed in webhook mapping responses. + public static record InboundWebhookEntityMappingDtoOut(String identifier, String name, + Map properties, Map relations) { + + public InboundWebhookEntityMappingDtoOut { + properties = properties != null ? Map.copyOf(properties) : null; + relations = relations != null ? Map.copyOf(relations) : null; + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java new file mode 100644 index 00000000..f6bcddb1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping; + +import java.util.Map; + +/// Entity projection details exposed in webhook mapping responses. +public record InboundWebhookEntityMappingDtoOut(String identifier, String title, + Map properties, Map relations) { + + public InboundWebhookEntityMappingDtoOut { + properties = properties != null ? Map.copyOf(properties) : null; + relations = relations != null ? Map.copyOf(relations) : null; + } +} 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 00000000..459a9085 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java @@ -0,0 +1,15 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.List; + +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; + +/// Response payload for created inbound webhook connector. +public record InboundWebhookDtoOut(String identifier, String name, String description, + boolean enabled, List mappings, + InboundWebhookSecurityDtoOut security) { + + public InboundWebhookDtoOut { + mappings = mappings != null ? List.copyOf(mappings) : null; + } +} 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 00000000..a02994b1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.Map; + +/// Security strategy returned for webhook configuration responses. +/// Only returns the strategy type to avoid exposing technical secret references. +public record InboundWebhookSecurityDtoOut(String type, Map config) { + + public InboundWebhookSecurityDtoOut { + config = config != null ? Map.copyOf(config) : null; + } +} 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 9a21a6d0..8145b387 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 @@ -10,6 +10,7 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -24,19 +25,14 @@ import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; -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.PropertyTypeChangeException; -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_dynamic_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.*; import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.exception.webhook.*; import lombok.AllArgsConstructor; import lombok.Getter; @@ -79,6 +75,11 @@ public ResponseEntity handleTemplateNotFoundException( return ResponseEntity.status(NOT_FOUND).body(errorResponse); } + /// Handles domain exception for malformed filter query strings (`q=` DSL). + /// + /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad + /// Request + /// so API consumers receive clear feedback about invalid `q` parameter syntax. @ExceptionHandler(InvalidFilterDslException.class) public ResponseEntity handleInvalidFilterDslException( InvalidFilterDslException ex) { @@ -106,25 +107,27 @@ public ResponseEntity handleInvalidSearchQueryException( @ExceptionHandler(EntityTemplateAlreadyExistsException.class) public ResponseEntity handleEntityTemplateAlreadyExistsException( EntityTemplateAlreadyExistsException ex) { - log.warn("Entity template already exists: {}", ex.getMessage()); + log.warn("Entity entityTemplateIdentifier already exists: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } - /// Handles domain exception when entity template names already exist. + /// Handles domain exception when entity entityTemplateIdentifier names already + /// exist. /// /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to /// HTTP 409 status indicating business rule conflict for duplicate - /// template names. + /// entityTemplateIdentifier names. @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) public ResponseEntity handleEntityTemplateNameAlreadyExistsException( EntityTemplateNameAlreadyExistsException ex) { - log.warn("Entity template name already exists: {}", ex.getMessage()); + log.warn("Entity entityTemplateIdentifier name already exists: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } - /// Handles domain exception when attempting to change an entity template + /// Handles domain exception when attempting to change an entity + /// entityTemplateIdentifier /// identifier. /// /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException @@ -133,25 +136,26 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( EntityTemplateIdentifierCannotChangeException ex) { - log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + log.warn("Entity entityTemplateIdentifier identifier cannot be changed: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } - /// Handles domain exception for wrong entity template property rules. + /// Handles domain exception for wrong entity entityTemplateIdentifier property + /// rules. /// /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to /// HTTP 400 status indicating validation error for wrong property rules. @ExceptionHandler(PropertyDefinitionRulesConflictException.class) public ResponseEntity handleWrongPropertyRulesException( PropertyDefinitionRulesConflictException ex) { - log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + log.warn("Wrong Entity entityTemplateIdentifier property rules: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /// Handles domain exception when property names are duplicated within a - /// template. + /// entityTemplateIdentifier. /// /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 /// status indicating validation error for duplicate property names. @@ -163,7 +167,7 @@ public ResponseEntity handlePropertyNameAlreadyExistsException( } /// Handles domain exception when relation names are duplicated within a - /// template. + /// entityTemplateIdentifier. /// /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 /// status indicating validation error for duplicate relation names. @@ -175,14 +179,15 @@ public ResponseEntity handleRelationNameAlreadyExistsException( } /// Handles domain exception when a relation references a non-existent target - /// template. + /// entityTemplateIdentifier. /// /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 - /// status indicating validation error for missing target template. + /// status indicating validation error for missing target + /// entityTemplateIdentifier. @ExceptionHandler(TargetTemplateNotFoundException.class) public ResponseEntity handleTargetTemplateNotFoundException( TargetTemplateNotFoundException ex) { - log.warn("Target template not found: {}", ex.getMessage()); + log.warn("Target entityTemplateIdentifier not found: {}", ex.getMessage()); return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } @@ -196,20 +201,23 @@ public ResponseEntity handleTypeChangeException(PropertyTypeChang return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - /// Handles domain exception when relation target template changes are + /// Handles domain exception when relation target entityTemplateIdentifier + /// changes are /// attempted. /// /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP - /// 400 status indicating validation error for immutable target template field. + /// 400 status indicating validation error for immutable target + /// entityTemplateIdentifier field. @ExceptionHandler(RelationTargetTemplateChangeException.class) public ResponseEntity handleRelationTargetTemplateChangeException( RelationTargetTemplateChangeException ex) { - log.warn("Relation target template change error: {}", ex.getMessage()); + log.warn("Relation target entityTemplateIdentifier change error: {}", ex.getMessage()); return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - /// Handles domain exception when a relation's target template identifier is the - /// template itself. + /// Handles domain exception when a relation's target entityTemplateIdentifier + /// identifier is the + /// entityTemplateIdentifier itself. /// /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP /// 400 @@ -288,6 +296,48 @@ public ResponseEntity handleHttpMessageNotReadableException( 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 entityTemplateIdentifier 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. /// /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status @@ -442,6 +492,103 @@ public ResponseEntity handleGenericException(Exception ex) { return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); } + /// 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 a webhook connector referencing a non-existent entity dynamic + /// mapping. + /// + /// HTTP mapping: Maps EntityDynamicMappingNotFoundException to HTTP 404 Not + /// Found, because the referenced mapping must be created beforehand. + @ExceptionHandler(EntityDynamicMappingNotFoundException.class) + public ResponseEntity handleEntityDynamicMappingNotFoundException( + EntityDynamicMappingNotFoundException ex) { + log.warn("Referenced entity dynamic mapping not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles creation of a dynamic mapping whose identifier already exists. + /// + /// HTTP mapping: Maps EntityDynamicMappingAlreadyExistsException to HTTP 409 + /// Conflict, surfacing the uniqueness violation with business meaning. + @ExceptionHandler(EntityDynamicMappingAlreadyExistsException.class) + public ResponseEntity handleEntityDynamicMappingAlreadyExistsException( + EntityDynamicMappingAlreadyExistsException ex) { + log.warn("Entity dynamic mapping identifier conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles low-level database integrity violations (for example, unique + /// constraint breaches) that were not caught earlier by domain validation. + /// + /// HTTP mapping: Maps DataIntegrityViolationException to HTTP 409 Conflict to + /// avoid leaking technical SQL details while signaling a conflicting state. + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException( + DataIntegrityViolationException ex) { + log.warn("Data integrity violation: {}", ex.getMostSpecificCause().getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), + "The request conflicts with the current state of the resource"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(EntityDynamicMappingAlreadyInUseException.class) + public ResponseEntity handleEntityDynamicMappingAlreadyInUseException( + EntityDynamicMappingAlreadyInUseException ex) { + log.warn("Entity dynamic mapping already in use: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).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); + } + + @ExceptionHandler(EntityTemplateInUseByWebhookMappingException.class) + public ResponseEntity handleTemplateAlreadyMappedInWebhookConfiguration( + EntityTemplateInUseByWebhookMappingException ex) { + log.warn("Entity entityTemplateIdentifier in use by webhook mapping conflict: {}", + ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles webhook connector name duplication conflicts. + @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class) + public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException( + WebhookConnectorTitleAlreadyExistsException ex) { + log.warn("Webhook connector name conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + private static ResponseEntity createErrorResponse(HttpStatus httpStatus, String errorMessage) { return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java new file mode 100644 index 00000000..2d17f39c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java @@ -0,0 +1,94 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +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.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookSecurityDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.DynamicMappingMapper; + +import lombok.AllArgsConstructor; + +/// Maps inbound webhook API DTOs to domain models and back. +@Component +@AllArgsConstructor +public class InboundWebhookMapper { + + private final DynamicMappingMapper dynamicMappingMapper; + + /// Converts API input payload to the domain aggregate. + /// + /// @param dto inbound webhook creation request + /// @param resolvedMappings the existing dynamic mappings referenced by the + /// request, already resolved and validated by the domain layer + /// @return domain webhook connector + public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto, + List resolvedMappings) { + return new WebhookConnector(null, dto.identifier(), dto.name(), dto.description(), + dto.enabled(), safeMappings(resolvedMappings), 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 + /// @param resolvedMappings the existing dynamic mappings referenced by the + /// request, already resolved and validated by the domain layer + /// @return domain webhook connector prepared for update + public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookCreateDtoIn dto, + List resolvedMappings) { + return new WebhookConnector(null, identifier, dto.name(), dto.description(), dto.enabled(), + safeMappings(resolvedMappings), toDomain(dto.security())); + } + + /// Converts domain aggregate to API response payload. + /// + /// @param domain created webhook connector + /// @return response DTO + public InboundWebhookDtoOut fromWebhookConnectorToDto(WebhookConnector domain) { + List mappings = domain.mappings().stream() + .map(dynamicMappingMapper::fromEntityMappingToDto).toList(); + InboundWebhookSecurityDtoOut security = new InboundWebhookSecurityDtoOut( + domain.security().type().name(), domain.security().config()); + return new InboundWebhookDtoOut(domain.identifier(), domain.name(), domain.description(), + domain.enabled(), mappings, security); + } + + private List safeMappings(List mappings) { + return mappings == null ? List.of() : List.copyOf(mappings); + } + + 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 _) { + 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/api/mapper/entity_dynamic_mapping/DynamicMappingMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/DynamicMappingMapper.java new file mode 100644 index 00000000..780eb91d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/DynamicMappingMapper.java @@ -0,0 +1,66 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingDtoInCommonFields; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; + +@Component +public class DynamicMappingMapper { + + public EntityDynamicMapping toDomain(EntityDynamicMappingCreateDtoIn mapping) { + // Map each DTO field explicitly to its matching domain field. The + // EntityDynamicMapping + // constructor order is (id, identifier, entityTemplateIdentifier, filter, + // entityIdentifier, + // entityName, properties, relations); keeping this alignment prevents the + // entityTemplateIdentifier + // identifier and the filter expression from being swapped. + EntityDynamicMappingDtoInCommonFields fields = mapping.commonFields(); + return new EntityDynamicMapping(null, // id (assigned by persistence layer) + mapping.identifier(), // identifier + fields.entityTemplateIdentifier(), // entityTemplateIdentifier + fields.filter(), // filter + fields.name(), // titre + fields.description(), fields.entity().identifier(), // entityIdentifier + fields.entity().name(), // entityName + safeMap(fields.entity().properties()), // properties + safeMap(fields.entity().relations())); // relations + } + + public EntityDynamicMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) { + return new EntityDynamicMappingDtoOut(mapping.identifier(), mapping.entityTemplateIdentifier(), + mapping.filter(), mapping.name(), mapping.description(), + new EntityDynamicMappingDtoOut.InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(), + mapping.entityName(), Map.copyOf(mapping.properties()), + Map.copyOf(mapping.relations()))); + } + + /// Converts an update DTO to domain model, using the identifier from the path. + /// + /// @param identifier the mapping identifier from the URL path + /// @param dto the update request body + /// @return the domain model for update + public EntityDynamicMapping toDomainForUpdate(String identifier, + EntityDynamicMappingUpdateDtoIn dto) { + var fields = dto.commonFields(); + return new EntityDynamicMapping(null, // id (will be set from existing entity) + identifier, // identifier from path + fields.entityTemplateIdentifier(), // entityTemplateIdentifier + fields.filter(), // filter + fields.name(), // titre + fields.description(), fields.entity().identifier(), // entityIdentifier + fields.entity().name(), // entityName + safeMap(fields.entity().properties()), // properties + safeMap(fields.entity().relations())); // relations + } + + 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 00000000..12da5445 --- /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 java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_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 lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +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, "entityName", mapping.entityName()); + + if (mapping.properties() != null && !mapping.properties().isEmpty()) { + mapping.properties() + .forEach((key, expr) -> checkExpression(errors, "properties." + key, expr)); + } + if (mapping.relations() != null && !mapping.relations().isEmpty()) { + 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/EntityDynamicMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java new file mode 100644 index 00000000..3a1db78a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java @@ -0,0 +1,94 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +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.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter for [EntityDynamicMapping] read and write operations. +@Component +@RequiredArgsConstructor +public class EntityDynamicMappingAdaptor implements EntityDynamicMappingPort { + private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + private final EntityDynamicMappingPersistenceMapper entityDynamicMappingPersistenceMapper; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + + @Override + public List findByTemplateIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.findByTemplateIdentifier(identifier).stream() + .map(entityDynamicMappingPersistenceMapper::toDomain).toList(); + } + + @Override + public List findByTemplateId(UUID templateIde) { + return jpaEntityDynamicMappingRepository.findByTemplateId(templateIde).stream() + .map(entityDynamicMappingPersistenceMapper::toDomain).toList(); + } + + @Override + public Boolean existsByTemplateIdentifier(String templateIdentifier) { + return jpaEntityDynamicMappingRepository.existsByTemplateIdentifier(templateIdentifier); + } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.existsByIdentifier(identifier); + } + + @Override + public Optional findByIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.findByIdentifier(identifier) + .map(entityDynamicMappingPersistenceMapper::toDomain); + } + + @Override + public EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping) { + // The domain model references the entityTemplateIdentifier by its business + // identifier, but the + // foreign key persisted in `entity_dynamic_mapping.template_id` is the + // entityTemplateIdentifier + // UUID. Resolve the identifier to the entityTemplateIdentifier id before saving + // (fail-fast). + UUID templateId = entityTemplateRepositoryPort + .findByIdentifier(entityDynamicMapping.entityTemplateIdentifier()).map(EntityTemplate::id) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", + entityDynamicMapping.entityTemplateIdentifier())); + + EntityDynamicMappingJpaEntity entityToPersist = entityDynamicMappingPersistenceMapper + .toJpa(entityDynamicMapping); + entityToPersist.setTemplateId(templateId); + EntityDynamicMappingJpaEntity persistedEntity = jpaEntityDynamicMappingRepository + .save(entityToPersist); + + return new EntityDynamicMapping(persistedEntity.getId(), entityDynamicMapping.identifier(), + entityDynamicMapping.entityTemplateIdentifier(), entityDynamicMapping.filter(), + entityDynamicMapping.name(), entityDynamicMapping.description(), + entityDynamicMapping.entityIdentifier(), entityDynamicMapping.entityName(), + entityDynamicMapping.properties(), entityDynamicMapping.relations()); + } + + @Override + public Page findAll(Pageable pageable) { + return jpaEntityDynamicMappingRepository.findAll(pageable) + .map(entityDynamicMappingPersistenceMapper::toDomain); + } + + @Override + public void deleteByIdentifier(String identifier) { + jpaEntityDynamicMappingRepository.deleteByIdentifier(identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java index af72cfd0..b4de92e6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java @@ -90,6 +90,11 @@ public void deleteByIdentifier(String identifier) { jpaEntityTemplateRepository.deleteByIdentifier(identifier); } + @Override + public boolean existsById(UUID id) { + return jpaEntityTemplateRepository.existsById(id); + } + // ── Merge helpers to update a managed JPA entity from domain values ── private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) { 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 00000000..a74f9ee4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java @@ -0,0 +1,188 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +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.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookConnectorRepository; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter implementing [WebhookConnectorRepositoryPort]. +/// +/// Delegates to Spring Data JPA and uses [WebhookConnectorPersistenceMapper] +/// to convert between JPA entities and domain models. +/// +/// Handles the complex persistence of mappings across three tables: +/// - webhook_connector (core connector data) +/// - entity_dynamic_mapping (mapping configurations) +/// - webhook_template_mapping (many-to-many link) +@Component +@RequiredArgsConstructor +public class PostgresWebhookConnectorAdapter implements WebhookConnectorRepositoryPort { + + private final JpaWebhookConnectorRepository jpaWebhookConnectorRepository; + private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository; + private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final EntityDynamicMappingPort entityDynamicMappingPort; + private final WebhookConnectorPersistenceMapper mapper; + private final EntityDynamicMappingPersistenceMapper mappingMapper; + + @Override + public Optional findByIdentifier(String identifier) { + return jpaWebhookConnectorRepository.findByIdentifier(identifier) + .map(this::loadConnectorWithMappings); + } + + @Override + public Page findAll(Pageable pageable) { + Page jpaPage = jpaWebhookConnectorRepository.findAll(pageable); + + if (jpaPage.isEmpty()) { + return jpaPage.map(mapper::toDomain); + } + + // Collect all webhook IDs from the page + List webhookIds = jpaPage.stream().map(WebhookConnectorJpaEntity::getId).toList(); + + // Batch load all entityTemplateIdentifier mappings for all webhooks in the page + List allTemplateMappings = jpaWebhookTemplateMappingRepository + .findByWebhookIdIn(webhookIds); + + // Collect all unique mapping IDs + List allMappingIds = allTemplateMappings.stream() + .map(WebhookMappingLinkJpaEntity::getEntityMappingId).distinct().toList(); + + // Batch load all entity mappings in one query + Map allMappingsById = jpaEntityDynamicMappingRepository + .findAllById(allMappingIds).stream() + .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain, + (existing, replacement) -> existing)); + + // Group mappings by webhook ID + Map> mappingsByWebhookId = allTemplateMappings.stream() + .collect(Collectors.groupingBy(WebhookMappingLinkJpaEntity::getWebhookId, + Collectors.mapping(wtm -> allMappingsById.get(wtm.getEntityMappingId()), + Collectors.filtering(Objects::nonNull, Collectors.toList())))); + + // Map each JPA entity to domain with its mappings + return jpaPage.map(jpaEntity -> { + WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity); + List mappings = mappingsByWebhookId.getOrDefault(jpaEntity.getId(), + Collections.emptyList()); + + return new WebhookConnector(connectorWithoutMappings.id(), + connectorWithoutMappings.identifier(), connectorWithoutMappings.name(), + connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings, + connectorWithoutMappings.security()); + }); + } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaWebhookConnectorRepository.existsByIdentifier(identifier); + } + + @Override + public boolean existsByTitle(String title) { + return jpaWebhookConnectorRepository.existsByName(title); + } + + @Override + public WebhookConnector save(WebhookConnector connector) { + WebhookConnectorJpaEntity savedConnector = jpaWebhookConnectorRepository + .save(mapper.toJpa(connector)); + persistTemplateMappings(savedConnector.getId(), connector); + return loadConnectorWithMappings(savedConnector); + } + + @Override + public void deleteByIdentifier(String identifier) { + jpaWebhookConnectorRepository.deleteByIdentifier(identifier); + } + + /// Loads a connector with its associated mappings from the + /// webhook_template_mapping table. + /// Since WebhookConnector is a Record (immutable), we create a new instance + /// with the loaded mappings. + private WebhookConnector loadConnectorWithMappings(WebhookConnectorJpaEntity jpaEntity) { + WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity); + List mappings = loadMappingsForWebhook(jpaEntity.getId()); + + // Since WebhookConnector is a Record, create a new instance with loaded + // mappings + return new WebhookConnector(connectorWithoutMappings.id(), + connectorWithoutMappings.identifier(), connectorWithoutMappings.name(), + connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings, + connectorWithoutMappings.security()); + } + + /// Loads all dynamic mappings associated with a webhook connector. + /// Uses batch loading to avoid N+1 query problem. + private List loadMappingsForWebhook(UUID webhookId) { + List templateMappings = jpaWebhookTemplateMappingRepository + .findByWebhookId(webhookId); + List mappingIds = templateMappings.stream() + .map(WebhookMappingLinkJpaEntity::getEntityMappingId).toList(); + if (mappingIds.isEmpty()) { + return List.of(); + } + Map mappingsById = jpaEntityDynamicMappingRepository + .findAllById(mappingIds).stream() + .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain)); + return mappingIds.stream().map(mappingsById::get).filter(Objects::nonNull).toList(); + } + + /// Persists the webhook's entityTemplateIdentifier mappings in the + /// webhook_template_mapping + /// table. + /// This also persists each EntityDynamicMapping if it's new. + private void persistTemplateMappings(UUID webhookId, WebhookConnector connector) { + jpaWebhookTemplateMappingRepository.deleteByWebhookId(webhookId); + var mappings = connector.mappings().stream() + .map(mapping -> persistAndCreateTemplateMapping(webhookId, mapping)).toList(); + + if (!mappings.isEmpty()) { + jpaWebhookTemplateMappingRepository.saveAll(mappings); + } + } + + /// Persists a single EntityDynamicMapping and creates a + /// WebhookTemplateMappingJpaEntity link. + /// + /// The mapping is expected to already exist because it is created through the + /// dedicated inbound dynamic mapping endpoint. This method only creates the + /// association row in webhook_template_mapping. + private WebhookMappingLinkJpaEntity persistAndCreateTemplateMapping(UUID webhookId, + EntityDynamicMapping mapping) { + + EntityDynamicMapping entityDynamicMapping = entityDynamicMappingPort + .findByIdentifier(mapping.identifier()) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(mapping.identifier())); + + return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId) + .entityMappingId(entityDynamicMapping.id()).jsltFilter(mapping.filter()).build(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java new file mode 100644 index 00000000..25ec2552 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java @@ -0,0 +1,34 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter for webhook-entityTemplateIdentifier mapping read operations. +@Component +@RequiredArgsConstructor +public class WebhookTemplateMappingAdaptor implements WebhookMappingLinkPort { + + private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository; + private final WebhookMappingLinkPersistenceMapper webhookMappingLinkPersistenceMapper; + + @Override + public boolean existsByEntityMappingId(UUID id) { + return jpaWebhookTemplateMappingRepository.existsByEntityMappingId(id); + } + + @Override + public List findByEntityMappingId(UUID id) { + return jpaWebhookTemplateMappingRepository.findByEntityMappingId(id).stream() + .map(webhookMappingLinkPersistenceMapper::toDomain).toList(); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java new file mode 100644 index 00000000..56461634 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java @@ -0,0 +1,40 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import static org.mapstruct.MappingConstants.ComponentModel.SPRING; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.EntityDynamicMappingJsonbHelper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; + +/// MapStruct persistence mapper for [EntityDynamicMapping]. +/// +/// Maps between domain model (EntityDynamicMapping) and JPA entity (EntityDynamicMappingJpaEntity). +/// Handles JSONB columns for properties and relations via the dedicated helper. +@Mapper(componentModel = SPRING, uses = EntityDynamicMappingJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface EntityDynamicMappingPersistenceMapper { + + @Mapping(target = "properties", qualifiedByName = "jsonStringToMap") + @Mapping(target = "relations", qualifiedByName = "jsonStringToMap") + @Mapping(target = "entityTemplateIdentifier", source = "template.identifier") + // Explicit self-mapping: MapStruct otherwise silently drops the + // `entityIdentifier` property, leaving the NOT NULL column unset. + @Mapping(target = "entityIdentifier", source = "entityIdentifier") + @Mapping(target = "entityName", source = "entityName") + EntityDynamicMapping toDomain(EntityDynamicMappingJpaEntity jpa); + + @Mapping(target = "properties", qualifiedByName = "mapToJsonString") + @Mapping(target = "relations", qualifiedByName = "mapToJsonString") + // The template foreign key (UUID) is resolved from the business identifier and + // set by the persistence adapter, so both template fields are ignored here. + @Mapping(target = "templateId", ignore = true) + @Mapping(target = "template", ignore = true) + // Explicit self-mapping: MapStruct otherwise silently drops the + // `entityIdentifier` property, leaving the NOT NULL column unset. + @Mapping(target = "entityIdentifier", source = "entityIdentifier") + @Mapping(target = "entityName", source = "entityName") + EntityDynamicMappingJpaEntity toJpa(EntityDynamicMapping domain); +} 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 00000000..7cd398c4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import static org.mapstruct.MappingConstants.ComponentModel.SPRING; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.WebhookConnectorJsonbHelper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; + +/// MapStruct persistence mapper for [WebhookConnector]. +/// +/// Maps the connector's direct fields (identifier, name, description, enabled, security). +/// The mappings list is handled separately by +/// [com.decathlon.idp_core.infrastructure.adapters.persistence.PostgresWebhookConnectorAdapter] +/// through the `webhook_template_mapping` table because it requires dedicated persistence +/// for `entity_dynamic_mapping` rows. +@Mapper(componentModel = SPRING, uses = WebhookConnectorJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface WebhookConnectorPersistenceMapper { + + @Mapping(target = "mappings", ignore = true) + @Mapping(target = "security", qualifiedByName = "jsonToSecurity") + WebhookConnector toDomain(WebhookConnectorJpaEntity jpa); + + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "security", qualifiedByName = "securityToJson") + WebhookConnectorJpaEntity toJpa(WebhookConnector domain); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java new file mode 100644 index 00000000..ee15cd59 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java @@ -0,0 +1,57 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import java.util.UUID; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; + +/// Persistence mapper for [WebhookTemplateMapping]. +/// +/// Maps the association entity between webhook connector, entity entityTemplateIdentifier and +/// dynamic mapping configuration. Foreign keys are managed explicitly by adapters +/// when persisting new links. +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = { + WebhookConnectorPersistenceMapper.class, EntityTemplatePersistenceMapper.class, + EntityDynamicMappingPersistenceMapper.class}, injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface WebhookMappingLinkPersistenceMapper { + + /// Maps JPA association data to the domain model. + /// + /// @param jpa persisted association entity + /// @return mapped domain model + @Mapping(target = "id", ignore = true) + @Mapping(target = "webhookConnector", source = "webhookConnector") + @Mapping(target = "entityDynamicMapping", source = "entityMapping") + @Mapping(target = "jsltFilter", source = "jsltFilter") + WebhookTemplateMapping toDomain(WebhookMappingLinkJpaEntity jpa); + + /// Maps domain model to JPA association entity. + /// + /// All technical IDs are preserved from the domain model. + /// + /// @param domain domain mapping object + /// @return fully mapped JPA association entity + @Mapping(target = "webhookId", source = "webhookConnector.id") + @Mapping(target = "entityMappingId", source = "entityDynamicMapping.id") + @Mapping(target = "jsltFilter", source = "jsltFilter") + @Mapping(target = "webhookConnector", ignore = true) + @Mapping(target = "entityMapping", ignore = true) + WebhookMappingLinkJpaEntity toJpa(WebhookTemplateMapping domain); + + /// Builds a link row with explicit foreign keys. + /// + /// @param webhookId webhook connector technical id + /// @param entityMappingId dynamic mapping technical id + /// @param jsltFilter JSLT filter expression + /// @return link entity ready for persistence + default WebhookMappingLinkJpaEntity toJpa(UUID webhookId, UUID entityMappingId, + String jsltFilter) { + return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId) + .entityMappingId(entityMappingId).jsltFilter(jsltFilter).build(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java new file mode 100644 index 00000000..7fb0a383 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java @@ -0,0 +1,51 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common; + +import java.util.Map; + +import org.mapstruct.Named; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/// Technical helper for JSONB serialization/deserialization in the persistence layer. +/// +/// Provides named conversion methods used by [com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper] +/// via MapStruct's `qualifiedByName` annotation. +/// +/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability. +@Component +public class EntityDynamicMappingJsonbHelper { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /// Converts JSONB string to `Map`. + /// Used when loading from database. + @Named("jsonStringToMap") + public Map toMap(String json) { + if (json == null || json.trim().isEmpty()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid JSON mapping configuration", e); + } + } + + /// Converts `Map` to JSONB string. + /// Used when persisting to database. + @Named("mapToJsonString") + public String toJsonString(Map map) { + if (map == null || map.isEmpty()) { + return "{}"; + } + try { + return OBJECT_MAPPER.writeValueAsString(map); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to serialize mapping configuration", e); + } + } +} 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 00000000..ddddd4ae --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java @@ -0,0 +1,46 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common; + +import org.mapstruct.Named; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/// Technical helper for JSONB serialization/deserialization in the persistence layer. +/// +/// Provides named conversion methods used by [WebhookConnectorPersistenceMapper] +/// via MapStruct's `qualifiedByName` annotation. +/// +/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability. +@Component +public class WebhookConnectorJsonbHelper { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /// Converts JSONB string to WebhookSecurity domain model. + /// Defaults to NONE type if JSON is empty. + @Named("jsonToSecurity") + public WebhookSecurity toSecurity(String json) { + if (json == null || json.trim().isEmpty()) { + 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); + } + } + + /// Converts WebhookSecurity domain model to JSON string. + @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/entity_dynamic_mapping/EntityDynamicMappingJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_dynamic_mapping/EntityDynamicMappingJpaEntity.java new file mode 100644 index 00000000..6887d35c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_dynamic_mapping/EntityDynamicMappingJpaEntity.java @@ -0,0 +1,88 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping; + +import java.util.UUID; + +import jakarta.persistence.*; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.EntityTemplateJpaEntity; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/// JPA entity for the `entity_dynamic_mapping` table. +/// +/// Stores dynamic mapping configurations used by webhook connectors to transform +/// inbound events into entities (JSLT filters, property/relation mappings). +@Entity +@Getter +@Setter +@ToString +@EqualsAndHashCode +@Table(name = "entity_dynamic_mapping") +@NoArgsConstructor +@AllArgsConstructor +public class EntityDynamicMappingJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "identifier", nullable = false, unique = true) + @ToString.Include + private String identifier; + + /// Foreign key to the parent entity entityTemplateIdentifier + /// (`entity_template.id`). + /// + /// Persisted as the entityTemplateIdentifier UUID even though the public API + /// (DTO In) exposes + /// the entityTemplateIdentifier business identifier. The identifier is resolved + /// to this UUID + /// in the persistence adapter before saving. + @Column(name = "template_id", nullable = false) + private UUID templateId; + + /// Lazy, read-only navigation to the referenced entityTemplateIdentifier. + /// + /// Used to expose the entityTemplateIdentifier business identifier when mapping + /// back to the + /// domain model, without triggering an additional explicit query. Marked as + /// non-insertable/non-updatable because the `template_id` column above owns + /// the foreign key. + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "template_id", insertable = false, updatable = false) + @ToString.Exclude + @EqualsAndHashCode.Exclude + private EntityTemplateJpaEntity template; + + @Column(nullable = false) + private String filter; + + @Column(nullable = false) + @ToString.Include + private String name; + + @ToString.Include + private String description; + + @Column(name = "entity_identifier", nullable = false) + private String entityIdentifier; + + @Column(name = "entity_name", nullable = false) + private String entityName; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String properties; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String relations; +} 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 00000000..19c346db --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java @@ -0,0 +1,67 @@ +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.*; + +/// JPA entity mapping to the `webhook_connector` PostgreSQL table. +/// +/// The `security` JSONB column is stored as a raw JSON string and deserialized +/// in [WebhookConnectorPersistenceMapper] using Jackson. +/// The webhook security payload follows the generic `{ type, config }` contract 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 name; + + /// 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 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/WebhookMappingLinkJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookMappingLinkJpaEntity.java new file mode 100644 index 00000000..4d2cfe02 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookMappingLinkJpaEntity.java @@ -0,0 +1,70 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook; + +import java.util.UUID; + +import jakarta.persistence.*; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; + +import lombok.*; + +/// JPA entity for the webhook mapping link, representing the association between a webhook connector, +/// an entity template, and the dynamic mapping (with JSLT filter) to apply during ingestion. +/// +/// The table uses a composite primary key `(webhook_id, entity_mapping_id)`; there is no +/// surrogate id column. +@Entity +@Table(name = "webhook_mapping_link") +@IdClass(WebhookMappingLinkJpaEntity.WebhookMappingLinkId.class) +@Getter +@Setter +@Builder +@ToString(onlyExplicitlyIncluded = true) +@NoArgsConstructor +@AllArgsConstructor +public class WebhookMappingLinkJpaEntity { + + /// Foreign key to the parent webhook connector. Part of the composite primary + /// key. + @Id + @ToString.Include + @Column(name = "webhook_id", nullable = false) + private UUID webhookId; + + /// Foreign key to the dynamic mapping configuration. Part of the composite + /// primary key. + @Id + @ToString.Include + @Column(name = "entity_mapping_id", nullable = false) + private UUID entityMappingId; + + /// The JSLT filter expression applied during event ingestion. + /// Typically derived from the dynamic mapping configuration, but stored here + /// for direct access and querying. + /// + @ToString.Include + @Column(name = "jslt_filter", columnDefinition = "TEXT") + private String jsltFilter; + + /// Lazy-loaded relationship to the webhook connector (optional, for + /// navigation). + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "webhook_id", nullable = false, insertable = false, updatable = false) + private WebhookConnectorJpaEntity webhookConnector; + + /// Lazy-loaded relationship to the dynamic mapping (optional, for navigation). + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "entity_mapping_id", nullable = false, insertable = false, updatable = false) + private EntityDynamicMappingJpaEntity entityMapping; + + /// Composite primary key class for [WebhookMappingLinkJpaEntity]. + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @EqualsAndHashCode + public static class WebhookMappingLinkId implements java.io.Serializable { + private UUID webhookId; + private UUID entityMappingId; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java new file mode 100644 index 00000000..8799f1d5 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java @@ -0,0 +1,68 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.jspecify.annotations.NonNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; + +/// JPA repository for EntityDynamicMapping persistence. +/// +/// Manages the `entity_dynamic_mapping` table which stores mapping configurations +/// (JSLT filters, property/relation mappings) used by webhook template mappings. +/// +/// Read methods that are mapped back to the domain model eagerly load the +/// `template` association (via [EntityGraph] or an explicit `join fetch`). The +/// persistence mapper navigates `template.identifier`, so leaving the LAZY proxy +/// uninitialized would raise a `LazyInitializationException` once the mapping +/// happens outside the session. +@Repository +public interface JpaEntityDynamicMappingRepository + extends + JpaRepository { + + /// Filters on the associated template business identifier. An explicit query + /// with `join fetch` is used because a derived query traversing + /// `template.identifier` does not bind reliably under Hibernate. + @Query(""" + select edm from EntityDynamicMappingJpaEntity edm + join fetch edm.template t + where t.identifier = :identifier + """) + List findByTemplateIdentifier( + @Param("identifier") String identifier); + + List findByTemplateId(UUID templateId); + + @Query(""" + select count(edm) > 0 from EntityDynamicMappingJpaEntity edm + where edm.template.identifier = :identifier + """) + boolean existsByTemplateIdentifier(@Param("identifier") String identifier); + + boolean existsByIdentifier(String identifier); + + @EntityGraph(attributePaths = "template") + Optional findByIdentifier(String identifier); + + @NonNull + @EntityGraph(attributePaths = "template") + Page findAll(@NonNull Pageable pageable); + + @Override + @NonNull + @EntityGraph(attributePaths = "template") + List findAllById(@NonNull Iterable ids); + + void deleteByIdentifier(String identifier); + +} 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 00000000..7cd9d0ea --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; + +public interface JpaWebhookConnectorRepository + extends + JpaRepository { + Optional findByIdentifier(String identifier); + + boolean existsByIdentifier(String identifier); + + boolean existsByName(String name); + + void deleteByIdentifier(String identifier); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/WebhookMappingLinkRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/WebhookMappingLinkRepository.java new file mode 100644 index 00000000..a2f02803 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/WebhookMappingLinkRepository.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; + +public interface WebhookMappingLinkRepository + extends + JpaRepository { + + /// Deletes all mappings associated with a webhook connector. + void deleteByWebhookId(UUID webhookId); + + /// Retrieves all mappings associated with a webhook connector. + List findByWebhookId(UUID webhookId); + + List findByWebhookIdIn(List webhookIds); + + boolean existsByEntityMappingId(UUID entityMappingId); + + List findByEntityMappingId(UUID entityMappingId); +} 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 00000000..18ccf7b8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +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 00000000..6fb81499 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +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 00000000..345fed07 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +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 00000000..3f01fcd1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java @@ -0,0 +1,14 @@ +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 00000000..cc8934df --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +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 00000000..acf03c68 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// Basic Authentication security strategy for webhooks. +/// +/// Validates Basic Auth credentials at both creation time (configuration validation) +/// and runtime (request authentication). +@Component +@NoArgsConstructor +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); + } +} 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 00000000..268f5c01 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// 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 +@NoArgsConstructor +public class HmacSha256SecurityValidator implements WebhookSecurityStrategy { + + @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); + } + +} 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 00000000..47ad1665 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; + +@Component +public class HmacSignatureValidator { + + public String computeHexSha256(byte[] payload, String secret) { + try { + if (secret == null || secret.isBlank()) { + throw new WebhookAuthenticationException("Unable to compute HMAC signature"); + } + 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 e) { + throw new WebhookAuthenticationException("Unable to compute HMAC signature", e); + } + } + + 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 00000000..237a2714 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// 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 +@NoArgsConstructor +public class JwtBearerSecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(String securityType) { + return "JWT_BEARER".equalsIgnoreCase(securityType); + } + + @Override + public void validateConfiguration(Map config) { + String jwksUriValue = WebhookSecurityConfigurationUtils.required(config, "jwks_uri", "jwksUri"); + if (jwksUriValue.isBlank()) { + throw new WebhookSecurityConfigurationException("Invalid jwks_uri for JWT_BEARER security"); + } + + if (WebhookSecurityConfigurationUtils.isEnvironmentReference(jwksUriValue)) { + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(jwksUriValue); + } + } +} 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 00000000..f237b904 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java @@ -0,0 +1,28 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +/// Static Token security strategy for webhooks. +/// +/// Validates static token configuration at creation time and authenticates incoming +@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); + } + +} 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 00000000..70d8086d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +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; + +/// 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 00000000..81b39cc1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java @@ -0,0 +1,122 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.StringUtils; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; + +/// 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 static final Pattern BRACED_ENV_REFERENCE = Pattern.compile("^\\$\\{([A-Z0-9_]+)}$"); + private static final Pattern ENV_ALIAS = Pattern.compile("^[A-Z0-9_]+$"); + + private WebhookSecurityConfigurationUtils() { + } + + /// 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 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) { + String normalizedAlias = normalizeEnvironmentAlias(alias); + if (!ENV_ALIAS.matcher(normalizedAlias).matches()) { + throw new WebhookSecurityConfigurationException( + "Invalid 'secret_alias'. Use UPPER_SNAKE_CASE or an environment reference (${MY_SECRET} or env:MY_SECRET)"); + } + } + + /// Determines whether a configuration value references an environment variable. + /// + /// Supported formats: `${MY_VAR}` and `env:MY_VAR`. + /// + /// @param value configuration value + /// @return true when value is an environment variable reference + public static boolean isEnvironmentReference(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String trimmed = value.trim(); + return trimmed.startsWith("env:") || BRACED_ENV_REFERENCE.matcher(trimmed).matches(); + } + + private static String normalizeEnvironmentAlias(String aliasOrReference) { + if (!StringUtils.hasText(aliasOrReference)) { + return aliasOrReference; + } + + String trimmed = aliasOrReference.trim(); + if (trimmed.startsWith("env:")) { + return trimmed.substring("env:".length()).trim(); + } + + Matcher matcher = BRACED_ENV_REFERENCE.matcher(trimmed); + if (matcher.matches()) { + return matcher.group(1); + } + + return trimmed; + } + + 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; + } + + // Priority 2: Environment suffix support (_env or Env) + // Example: if key is "secret_alias", we also check for "secret_alias_env" or + // "secret_aliasEnv" + String envValue = config.get(key + "_env"); + if (!StringUtils.hasText(envValue)) { + envValue = config.get(key + "Env"); + } + + if (StringUtils.hasText(envValue)) { + return "env:" + envValue; + } + } + + 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/resources/db/local/R__1_Insert_sample_data.sql b/src/main/resources/db/local/R__1_Insert_sample_data.sql index acdcdec8..a31af1c5 100644 --- a/src/main/resources/db/local/R__1_Insert_sample_data.sql +++ b/src/main/resources/db/local/R__1_Insert_sample_data.sql @@ -117,7 +117,7 @@ INSERT INTO entity_template (id, identifier, name, description) VALUES ('550e8400-e29b-41d4-a716-446655440078', 'cache-service', 'Cache Service', 'Template for caching services'), ('550e8400-e29b-41d4-a716-446655440079', 'monitoring-service', 'Monitoring Service', 'Template for monitoring and observability services'); --- Link web-service template (comprehensive web API) +-- Link web-service entityTemplateIdentifier (comprehensive web API) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440020'), -- applicationName ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440021'), -- ownerEmail @@ -139,7 +139,7 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440059'), -- monitoring ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440061'); -- secrets --- Link microservice template (lightweight service) +-- Link microservice entityTemplateIdentifier (lightweight service) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES ('550e8400-e29b-41d4-a716-446655440071', '550e8400-e29b-41d4-a716-446655440020'), -- applicationName ('550e8400-e29b-41d4-a716-446655440071', '550e8400-e29b-41d4-a716-446655440021'), -- ownerEmail 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 00000000..acfd17ff --- /dev/null +++ b/src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql @@ -0,0 +1,18 @@ +-- Purpose: introduce webhook connector and relational mapping model to entity entityTemplateIdentifier and dynamic mapping for flexible webhook configuration and management +CREATE TABLE webhook_connector +( + id UUID PRIMARY KEY, + identifier VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + security JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +--create index for better performance +CREATE UNIQUE INDEX idx_webhook_connector_identifier ON webhook_connector (identifier); + +--create index for better performance on security field which is JSONB type, using GIN index for efficient querying +CREATE INDEX idx_webhook_security_type ON webhook_connector USING GIN (security); diff --git a/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql b/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql new file mode 100644 index 00000000..1fe03f4b --- /dev/null +++ b/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql @@ -0,0 +1,16 @@ +-- This migration creates the 'entity_dynamic_mapping' table to store dynamic mappings of entities based on templates and filters. + +CREATE TABLE entity_dynamic_mapping +( + id UUID PRIMARY KEY, + identifier VARCHAR(255) UNIQUE NOT NULL, + template_id UUID NOT NULL, + filter TEXT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + entity_identifier TEXT NOT NULL, + entity_name TEXT NOT NULL, + properties JSONB NOT NULL DEFAULT '{}'::jsonb, + relations JSONB NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT fk_mapping_to_template FOREIGN KEY (template_id) REFERENCES entity_template (id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V4_3__create_webhook_mapping_link_table.sql b/src/main/resources/db/migration/V4_3__create_webhook_mapping_link_table.sql new file mode 100644 index 00000000..a97d3f1f --- /dev/null +++ b/src/main/resources/db/migration/V4_3__create_webhook_mapping_link_table.sql @@ -0,0 +1,21 @@ +--- Create the webhook_mapping_link table to link webhook connectors with entity templates and dynamic mappings. +CREATE TABLE webhook_mapping_link +( + webhook_id UUID NOT NULL, + entity_mapping_id UUID NOT NULL, + jslt_filter TEXT, + PRIMARY KEY (webhook_id, entity_mapping_id), + CONSTRAINT fk_webhook_connector FOREIGN KEY (webhook_id) REFERENCES webhook_connector (id) ON DELETE CASCADE, + CONSTRAINT fk_entity_mapping FOREIGN KEY (entity_mapping_id) REFERENCES entity_dynamic_mapping (id) ON DELETE CASCADE + +); + +CREATE INDEX idx_webhook_template_mapping_webhook ON webhook_mapping_link (webhook_id); + + +CREATE INDEX idx_webhook_template_mapping_entity_mapping ON webhook_mapping_link (entity_mapping_id); + +COMMENT ON TABLE webhook_mapping_link IS 'Links webhook connectors to dynamic mappings used for inbound webhook ingestion.'; +COMMENT ON COLUMN webhook_mapping_link.webhook_id IS 'FK to webhook_connector.id.'; +COMMENT ON COLUMN webhook_mapping_link.entity_mapping_id IS 'FK to entity_dynamic_mapping.id.'; +COMMENT ON COLUMN webhook_mapping_link.jslt_filter IS 'JSLT filter expression used during ingestion.'; diff --git a/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java b/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java new file mode 100644 index 00000000..fbe2c8be --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java @@ -0,0 +1,41 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +/** + * Unit tests for {@link WebhookConnector} invariants. + */ +@DisplayName("WebhookConnector Tests") +class WebhookConnectorTest { + + @Test + @DisplayName("Should default mappings to empty list and disable connector when mappings are null") + void shouldDefaultMappingsToEmptyListAndDisableWhenMappingsAreNull() { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", false, null, + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + + assertThat(connector.mappings()).isEmpty(); + assertThat(connector.enabled()).isFalse(); + } + + @Test + @DisplayName("Should disable connector when mappings are empty") + void shouldDisableWhenMappingsAreEmpty() { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", false, List.of(), + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + + assertThat(connector.mappings()).isEmpty(); + assertThat(connector.enabled()).isFalse(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java new file mode 100644 index 00000000..1fc179e3 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java @@ -0,0 +1,334 @@ +package com.decathlon.idp_core.domain.service.webhook; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +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.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.DynamicMappingService; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService; + +@DisplayName("DynamicMappingService Tests") +@ExtendWith(MockitoExtension.class) +class DynamicMappingServiceTest { + + @Mock + private EntityDynamicMappingPort entityDynamicMappingPort; + + @Mock + private WebhookMappingLinkPort webhookTemplateMappingPort; + + @Mock + private EntityDynamicMappingValidationService entityDynamicMappingValidationService; + + private DynamicMappingService service; + + private static final String MAPPING_IDENTIFIER = "github_deployment_status mapping"; + + @BeforeEach + void setUp() { + service = new DynamicMappingService(entityDynamicMappingPort, webhookTemplateMappingPort, + entityDynamicMappingValidationService); + } + + @Nested + @DisplayName("createEntityDynamicMapping") + class CreateEntityDynamicMappingTests { + + @Test + @DisplayName("Should validate uniqueness, validate mapping then save") + void shouldValidateThenSave() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(false); + when(entityDynamicMappingPort.save(mapping)).thenReturn(mapping); + + EntityDynamicMapping result = service.createEntityDynamicMapping(mapping); + + assertThat(result).isEqualTo(mapping); + verify(entityDynamicMappingValidationService).validateMapping(mapping); + verify(entityDynamicMappingPort).save(mapping); + } + + @Test + @DisplayName("Should throw conflict and not save when identifier already exists") + void shouldThrowWhenIdentifierAlreadyExists() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + + assertThatThrownBy(() -> service.createEntityDynamicMapping(mapping)) + .isInstanceOf(EntityDynamicMappingAlreadyExistsException.class) + .hasMessageContaining(MAPPING_IDENTIFIER); + + verify(entityDynamicMappingValidationService, never()).validateMapping(any()); + verify(entityDynamicMappingPort, never()).save(any()); + } + } + + @Nested + @DisplayName("getEntityDynamicMapping") + class GetEntityDynamicMappingTests { + + @Test + @DisplayName("Should return mapping when it exists") + void shouldReturnMappingWhenExists() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + + EntityDynamicMapping result = service.getEntityDynamicMapping(MAPPING_IDENTIFIER); + + assertThat(result).isEqualTo(mapping); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingNotFoundException when not found") + void shouldThrowWhenMappingNotFound() { + when(entityDynamicMappingPort.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEntityDynamicMapping("unknown")) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("unknown"); + } + } + + @Nested + @DisplayName("getAllEntityDynamicMapping") + class GetAllEntityDynamicMappingTests { + + @Test + @DisplayName("Should return paginated mappings from repository") + void shouldReturnPaginatedMappings() { + var pageable = PageRequest.of(0, 10); + var page = new PageImpl<>(List.of(buildMapping()), pageable, 1); + when(entityDynamicMappingPort.findAll(pageable)).thenReturn(page); + + var result = service.getAllEntityDynamicMapping(pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("Should return empty page when no mappings exist") + void shouldReturnEmptyPage() { + var pageable = PageRequest.of(0, 10); + when(entityDynamicMappingPort.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + var result = service.getAllEntityDynamicMapping(pageable); + + assertThat(result.getContent()).isEmpty(); + } + } + + @Nested + @DisplayName("updateEntityDynamicMapping") + class UpdateEntityDynamicMappingTests { + + @Test + @DisplayName("Should preserve id and identifier from existing mapping") + void shouldPreserveIdAndIdentifier() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = new EntityDynamicMapping(null, "ignored-id", + "new-entityTemplateIdentifier", ".newFilter", "New Name", "New Desc", ".newId", + ".newTitle", Map.of("prop", ".val"), Map.of()); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + EntityDynamicMapping result = service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, + incoming); + + assertThat(result.id()).isEqualTo(existing.id()); + assertThat(result.identifier()).isEqualTo(existing.identifier()); + } + + @Test + @DisplayName("Should apply updated fields from incoming mapping") + void shouldApplyIncomingFields() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = new EntityDynamicMapping(null, "ignored", + "new-entityTemplateIdentifier", ".newFilter", "New Name", "New Desc", ".newId", + ".newTitle", Map.of("k", ".v"), Map.of("rel", ".r")); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + EntityDynamicMapping result = service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, + incoming); + + assertThat(result.entityTemplateIdentifier()).isEqualTo("new-entityTemplateIdentifier"); + assertThat(result.filter()).isEqualTo(".newFilter"); + assertThat(result.name()).isEqualTo("New Name"); + assertThat(result.description()).isEqualTo("New Desc"); + assertThat(result.properties()).containsKey("k"); + } + + @Test + @DisplayName("Should validate mapping before saving") + void shouldValidateMappingBeforeSaving() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = buildMapping(); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, incoming); + + verify(entityDynamicMappingValidationService).validateMapping(incoming); + } + + @Test + @DisplayName("Should throw when mapping to update does not exist") + void shouldThrowWhenMappingNotFound() { + EntityDynamicMapping incoming = buildMapping(); + when(entityDynamicMappingPort.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateEntityDynamicMapping("unknown", incoming)) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("unknown"); + + verify(entityDynamicMappingPort, never()).save(any()); + } + + @Test + @DisplayName("Should save the merged mapping with correct fields") + void shouldSaveMergedMapping() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = new EntityDynamicMapping(null, "ignored", + "updated-entityTemplateIdentifier", ".updated", "Updated Name", "Updated Desc", ".uid", + ".utitle", Map.of(), Map.of()); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, incoming); + + var captor = ArgumentCaptor.forClass(EntityDynamicMapping.class); + verify(entityDynamicMappingPort).save(captor.capture()); + var saved = captor.getValue(); + + assertThat(saved.id()).isEqualTo(existing.id()); + assertThat(saved.identifier()).isEqualTo(existing.identifier()); + assertThat(saved.entityTemplateIdentifier()).isEqualTo("updated-entityTemplateIdentifier"); + assertThat(saved.filter()).isEqualTo(".updated"); + } + } + + @Nested + @DisplayName("deleteEntityDynamicMapping") + class DeleteEntityDynamicMappingTests { + + @Test + @DisplayName("Should delete when mapping exists and is not in use") + void shouldDeleteWhenMappingExistsAndNotInUse() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + when(webhookTemplateMappingPort.existsByEntityMappingId(mapping.id())).thenReturn(false); + + service.deleteEntityDynamicMapping(MAPPING_IDENTIFIER); + + verify(entityDynamicMappingPort).deleteByIdentifier(MAPPING_IDENTIFIER); + } + + @Test + @DisplayName("Should throw when mapping does not exist") + void shouldThrowWhenMappingNotFound() { + when(entityDynamicMappingPort.existsByIdentifier("unknown")).thenReturn(false); + + assertThatThrownBy(() -> service.deleteEntityDynamicMapping("unknown")) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("unknown"); + + verify(entityDynamicMappingPort, never()).deleteByIdentifier(any()); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingAlreadyInUseException when mapping is used by a webhook") + void shouldThrowWhenMappingIsInUse() { + EntityDynamicMapping mapping = buildMapping(); + WebhookConnector webhook = buildWebhookConnector("my-webhook"); + WebhookTemplateMapping templateMapping = new WebhookTemplateMapping(UUID.randomUUID(), + webhook, null, null, null); + + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + when(webhookTemplateMappingPort.existsByEntityMappingId(mapping.id())).thenReturn(true); + when(webhookTemplateMappingPort.findByEntityMappingId(mapping.id())) + .thenReturn(List.of(templateMapping)); + + assertThatThrownBy(() -> service.deleteEntityDynamicMapping(MAPPING_IDENTIFIER)) + .isInstanceOf(EntityDynamicMappingAlreadyInUseException.class) + .hasMessageContaining("my-webhook"); + + verify(entityDynamicMappingPort, never()).deleteByIdentifier(any()); + } + + @Test + @DisplayName("Should include all referencing webhook identifiers in exception") + void shouldIncludeAllReferencingWebhooksInException() { + EntityDynamicMapping mapping = buildMapping(); + WebhookTemplateMapping ref1 = new WebhookTemplateMapping(UUID.randomUUID(), + buildWebhookConnector("webhook-a"), null, null, null); + WebhookTemplateMapping ref2 = new WebhookTemplateMapping(UUID.randomUUID(), + buildWebhookConnector("webhook-b"), null, null, null); + + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + when(webhookTemplateMappingPort.existsByEntityMappingId(mapping.id())).thenReturn(true); + when(webhookTemplateMappingPort.findByEntityMappingId(mapping.id())) + .thenReturn(List.of(ref1, ref2)); + + assertThatThrownBy(() -> service.deleteEntityDynamicMapping(MAPPING_IDENTIFIER)) + .isInstanceOf(EntityDynamicMappingAlreadyInUseException.class) + .hasMessageContaining("webhook-a").hasMessageContaining("webhook-b"); + } + } + + private EntityDynamicMapping buildMapping() { + return new EntityDynamicMapping(UUID.randomUUID(), MAPPING_IDENTIFIER, + "github_deployment_status", ".deployment_status != null", "github deployment status name", + "github deployment status description", ".id", ".name", Map.of(), Map.of()); + } + + private WebhookConnector buildWebhookConnector(String identifier) { + return new WebhookConnector(UUID.randomUUID(), identifier, identifier + " name", "desc", false, + List.of(), new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + } +} 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 00000000..8fc37a63 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java @@ -0,0 +1,429 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +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 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_dynamic_mapping.EntityDynamicMappingValidationService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +/** + * 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; + + @Mock + private PropertyValidationService propertyValidationService; + + private EntityDynamicMappingValidationService service; + + @BeforeEach + void setUp() { + service = new EntityDynamicMappingValidationService(entityTemplateService, + entityDynamicMapperValidator, entityTemplateValidationService, propertyValidationService); + } + + private EntityDynamicMapping buildMapping(String templateIdentifier, + String entityDynamicMappingIdentifier, Map properties, + Map relations) { + return new EntityDynamicMapping(null, entityDynamicMappingIdentifier, templateIdentifier, + ".eventType == \"DEPLOYED\"", "name", "description", ".id", ".name", properties, relations); + } + + private EntityTemplate buildEntityTemplate(List properties, + List relations) { + return new EntityTemplate(UUID.randomUUID(), "deployment", "Deployment", + "A deployment entityTemplateIdentifier", 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", "deployment_mapping", + Map.of("environment", ".env"), Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should pass with empty properties mapping and empty entityTemplateIdentifier properties") + void shouldPassWithEmptyPropertiesAndEmptyTemplateProperties() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + + 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", "deployment_mapping", + Map.of("env", ".env"), null); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + } + + @Test + @DisplayName("Should pass with empty relations in mapping and no required relations in entityTemplateIdentifier") + void shouldPassWithEmptyRelationsAndNoRequiredRelations() { + RelationDefinition relation = buildRelation("service", false); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + + 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", "deployment_mapping", + Map.of("env", ".env"), Map.of()); + + PropertyDefinition property2 = buildProperty("version", false); + EntityTemplate template2 = buildEntityTemplate(List.of(property2), List.of()); + EntityDynamicMapping mapping2 = new EntityDynamicMapping(null, "service_mapping", "service", + ".type == \"SERVICE\"", "service mapping", "service mapping description", ".id", ".name", + Map.of("version", ".ver"), Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template1); + when(entityTemplateService.getEntityTemplateByIdentifier("service")).thenReturn(template2); + + 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 entityTemplateIdentifier") + void shouldPassWithValidRelations() { + RelationDefinition relation = buildRelation("owner", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of("owner", ".owner")); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - entityTemplateIdentifier existence") + class ValidateTemplateExistenceTests { + + @Test + @DisplayName("Should throw EntityTemplateNotFoundException when entityTemplateIdentifier does not exist") + void shouldThrowWhenTemplateDoesNotExist() { + EntityDynamicMapping mapping = buildMapping("unknown-entityTemplateIdentifier", + "unknown_mapping", Map.of(), Map.of()); + List mappings = List.of(mapping); + + // Existence is now enforced by getEntityTemplateByIdentifier, which throws + // EntityTemplateNotFoundException when the template is missing. + doThrow(new EntityTemplateNotFoundException("identifier", "unknown-entityTemplateIdentifier")) + .when(entityTemplateService) + .getEntityTemplateByIdentifier("unknown-entityTemplateIdentifier"); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(EntityTemplateNotFoundException.class); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - properties validation") + class ValidatePropertiesTests { + + @Test + @DisplayName("Should throw PropertyNameNotFoundEntityTemplatePropertiesException when mapping has properties but entityTemplateIdentifier has none") + void shouldThrowWhenMappingHasPropertiesButTemplateHasNone() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".env"), Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new PropertyNameNotFoundEntityTemplatePropertiesException( + "Property name env not found in entity template properties")) + .when(propertyValidationService).validateAgainstTemplate(eq(template), anyList()); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(PropertyNameNotFoundEntityTemplatePropertiesException.class) + .hasMessageContaining("env"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should throw PropertyNameNotFoundEntityTemplatePropertiesException when property not in entityTemplateIdentifier") + void shouldThrowWhenPropertyNotFoundInTemplate() { + PropertyDefinition property = buildProperty("environment", false); + EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("unknown-prop", ".x"), Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new PropertyNameNotFoundEntityTemplatePropertiesException( + "Property name unknown-prop not found in entity template properties")) + .when(propertyValidationService).validateAgainstTemplate(eq(template), anyList()); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .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", "deployment_mapping", Map.of(), + Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .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", "deployment_mapping", + Map.of("env", ".environment"), Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + 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", "deployment_mapping", Map.of(), + Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(WebhookTemplateHasNoPropertiesException.class).hasMessageContaining("env") + .hasMessageContaining("version"); + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - relations validation") + class ValidateRelationsTests { + + @Test + @DisplayName("Should throw RelationNameNotFoundEntityTemplateRelationsException when relation not in entityTemplateIdentifier") + void shouldThrowWhenRelationNotFoundInTemplate() { + RelationDefinition relation = buildRelation("owner", false); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of("unknown-relation", ".x")); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new RelationNameNotFoundEntityTemplateRelationsException( + "Relation name unknown-relation not found in entity entityTemplateIdentifier relations")) + .when(entityTemplateValidationService).validateRelationNameAlreadyExistInTemplate( + Map.of("unknown-relation", ".x"), template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .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", "deployment_mapping", Map.of(), + Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .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", "deployment_mapping", Map.of(), + Map.of("owner", ".ownerId")); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + 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", "deployment_mapping", Map.of(), + Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .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", "deployment_mapping", Map.of(), + null); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + } + + @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", "deployment_mapping", Map.of(), + Map.of()); + + 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-entityTemplateIdentifier", + "deployment_mapping", Map.of(), Map.of()); + var mappings = List.of(mapping); + + doThrow(new EntityTemplateNotFoundException("identifier", "bad-entityTemplateIdentifier")) + .when(entityTemplateService) + .getEntityTemplateByIdentifier("bad-entityTemplateIdentifier"); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .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 00000000..4c424872 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java @@ -0,0 +1,437 @@ +package com.decathlon.idp_core.domain.service.webhook; + +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.*; +import static org.mockito.Mockito.doThrow; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +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 com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +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.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; + +@DisplayName("WebhookConnectorService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookConnectorServiceTest { + + @Mock + private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + + @Mock + private WebhookConnectorValidationService webhookConnectorValidationService; + + @Mock + private EntityDynamicMappingPort entityDynamicMappingPort; + + private WebhookConnectorService service; + + @BeforeEach + void setUp() { + service = new WebhookConnectorService(webhookConnectorRepositoryPort, + webhookConnectorValidationService, entityDynamicMappingPort); + } + + @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", + false); + WebhookConnector saved = buildWebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", false); + 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); + doThrow(new RuntimeException("validation error")).when(webhookConnectorValidationService) + .validateWebhookConnectorForCreation(toCreate); + + assertThatThrownBy(() -> service.createWebhookConnector(toCreate)) + .hasMessageContaining("validation error"); + + verify(webhookConnectorRepositoryPort, never()).save(any()); + } + + @Test + @DisplayName("Should force enabled to false when mappings are empty") + void shouldDisableConnectorWhenMappingsAreEmpty() { + // User tries to create an enabled connector with no mappings + WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", + true); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.createWebhookConnector(toCreate); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + // Even though toCreate had enabled=true, it should be saved with enabled=false + assertThat(saved.enabled()).isFalse(); + assertThat(saved.mappings()).isEmpty(); + } + + @Test + @DisplayName("Should keep enabled as true when mappings are present") + void shouldKeepEnabledWhenMappingsPresent() { + EntityDynamicMapping mapping = new EntityDynamicMapping(UUID.randomUUID(), + "deployment-mapping", "deployment", "true", "deployment name", "deployment description", + ".id", ".name", Map.of(), Map.of()); + WebhookConnector toCreate = buildWebhookConnectorWithMappings(null, "github-dora", + "GitHub DORA", "desc", true, List.of(mapping)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.createWebhookConnector(toCreate); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + // With mappings present, enabled should remain true + assertThat(saved.enabled()).isTrue(); + assertThat(saved.mappings()).hasSize(1); + } + } + + @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 name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, "ignored-from-body", "New name", + "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 name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "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.name()).isEqualTo("New name"); + 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 name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "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 name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "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.name()).isEqualTo("New name"); + 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 name", "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()); + } + + @Test + @DisplayName("Should force enabled to false when update removes all mappings") + void shouldDisableConnectorWhenUpdatingWithEmptyMappings() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", true); + // User tries to update with enabled=true but empty mappings + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "New desc", + true); + + 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(); + + // Should be forced to false because mappings are empty + assertThat(saved.enabled()).isFalse(); + assertThat(saved.mappings()).isEmpty(); + } + + @Test + @DisplayName("Should keep enabled value when update has mappings") + void shouldKeepEnabledWhenUpdateHasMappings() { + EntityDynamicMapping mapping = new EntityDynamicMapping(UUID.randomUUID(), + "deployment-mapping", "deployment", "true", "deployment name", "deployment description", + ".id", ".name", Map.of(), Map.of()); + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", false); + WebhookConnector incoming = buildWebhookConnectorWithMappings(null, IDENTIFIER, "New name", + "New desc", true, List.of(mapping)); + + 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(); + + // Should remain true because mappings are present + assertThat(saved.enabled()).isTrue(); + assertThat(saved.mappings()).hasSize(1); + } + } + + @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() { + 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) { + 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(), security); + } + + private WebhookConnector buildWebhookConnectorWithMappings(UUID id, String identifier, + String title, String description, boolean enabled, List mappings) { + 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, mappings, security); + } + + @Nested + @DisplayName("resolveAndValidateMappings") + class ResolveAndValidateMappingsTests { + + @Test + @DisplayName("Should return empty list when identifiers are null or empty") + void shouldReturnEmptyWhenNoIdentifiers() { + assertThat(service.resolveAndValidateMappings(null)).isEmpty(); + assertThat(service.resolveAndValidateMappings(List.of())).isEmpty(); + verifyNoInteractions(entityDynamicMappingPort); + } + + @Test + @DisplayName("Should resolve each identifier to its existing mapping") + void shouldResolveExistingMappings() { + EntityDynamicMapping mapping = buildMapping("deployment-mapping"); + when(entityDynamicMappingPort.findByIdentifier("deployment-mapping")) + .thenReturn(Optional.of(mapping)); + + List result = service + .resolveAndValidateMappings(List.of("deployment-mapping")); + + assertThat(result).containsExactly(mapping); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingNotFoundException when a mapping is missing") + void shouldThrowWhenMappingMissing() { + when(entityDynamicMappingPort.findByIdentifier("missing-mapping")) + .thenReturn(Optional.empty()); + + List mappings = List.of("missing-mapping"); + + assertThatThrownBy(() -> service.resolveAndValidateMappings(mappings)) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("missing-mapping"); + } + + private EntityDynamicMapping buildMapping(String identifier) { + return new EntityDynamicMapping(UUID.randomUUID(), identifier, "deployment", "true", + "deployment name", "deployment description", ".id", ".name", Map.of(), Map.of()); + } + } +} 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 00000000..28442542 --- /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 static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +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 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.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService; +import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; + +@DisplayName("WebhookConnectorValidationService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookConnectorValidationServiceTest { + + @Mock + private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + + @Mock + private WebhookMappingLinkPort webhookTemplateMappingPort; + + @Mock + private WebhookSecurityValidationService webhookSecurityValidationService; + + @Mock + private EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + + private WebhookConnectorValidationService service; + + @BeforeEach + void setUp() { + service = new WebhookConnectorValidationService(webhookConnectorRepositoryPort, + webhookTemplateMappingPort, webhookConnectorMappingValidationService, + webhookSecurityValidationService); + } + + @Nested + @DisplayName("validateWebhookConnectorForCreation") + class ValidateWebhookConnectorForCreationTests { + + @Test + @DisplayName("Should validate uniqueness 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, webhookSecurityValidationService); + order.verify(webhookConnectorRepositoryPort).existsByIdentifier("github-dora"); + order.verify(webhookConnectorRepositoryPort).existsByTitle("GitHub DORA"); + 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(webhookSecurityValidationService, never()).validateForCreation(any()); + } + + @Test + @DisplayName("Should throw when name already exists and skip 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(webhookSecurityValidationService, never()).validateForCreation(any()); + } + } + + @Nested + @DisplayName("validateWebhookConnectorForUpdate") + class ValidateWebhookConnectorForUpdateTests { + + @Test + @DisplayName("Should validate name uniqueness when name changes") + void shouldValidateTitleUniquenessWhenTitleChanges() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Old name"); + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "New name"); + when(webhookConnectorRepositoryPort.existsByTitle("New name")).thenReturn(false); + + service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate); + + verify(webhookConnectorRepositoryPort).existsByTitle("New name"); + verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security()); + } + + @Test + @DisplayName("Should skip name uniqueness check when name is unchanged") + void shouldSkipTitleUniquenessWhenTitleIsUnchanged() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Same name"); + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Same name"); + + service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate); + + verify(webhookConnectorRepositoryPort, never()).existsByTitle(any()); + verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security()); + } + + @Test + @DisplayName("Should throw when changed name already exists and stop validation chain") + void shouldThrowWhenChangedTitleAlreadyExists() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Old name"); + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Taken name"); + when(webhookConnectorRepositoryPort.existsByTitle("Taken name")).thenReturn(true); + + assertThatThrownBy( + () -> service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate)) + .isInstanceOf(WebhookConnectorTitleAlreadyExistsException.class) + .hasMessageContaining("Taken name"); + + verify(webhookSecurityValidationService, never()).validateForCreation(any()); + } + + @Test + @DisplayName("Should validate mappings when connector has mappings") + void shouldValidateMappingsWhenPresent() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Title"); + WebhookConnector connectorToUpdate = buildWebhookConnectorWithMappings("github-dora", + "Title"); + + service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate); + + verify(webhookConnectorMappingValidationService) + .validateWebhookMapping(connectorToUpdate.mappings()); + verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security()); + } + + @Test + @DisplayName("Should skip mapping validation when connector has no mappings") + void shouldSkipMappingValidationWhenNoMappings() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Title"); + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Title"); + + service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate); + + verify(webhookConnectorMappingValidationService, never()).validateWebhookMapping(any()); + } + } + + @Nested + @DisplayName("validateTitleUniqueness") + class ValidateTitleUniquenessTests { + + @Test + @DisplayName("Should pass when name is unique") + void shouldPassWhenTitleIsUnique() { + when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(false); + + assertThatCode(() -> service.validateTitleUniqueness("GitHub DORA")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw when name 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) { + 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(), + security); + } + + private WebhookConnector buildWebhookConnectorWithMappings(String identifier, String title) { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, + Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); + EntityDynamicMapping mapping = new EntityDynamicMapping(UUID.randomUUID(), "my-mapping", + "web-service", ".filter", "name", "desc", ".id", ".name", Map.of(), Map.of()); + 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 00000000..1320d43a --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java @@ -0,0 +1,124 @@ +package com.decathlon.idp_core.domain.service.webhook.security; + +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; + +import java.util.List; +import java.util.Map; + +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 com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +@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() { + Map config = Map.of("k", "v"); + assertThatThrownBy(() -> new WebhookSecurity(null, config)) + .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("Webhook security config 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/EntityDynamicMappingControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java new file mode 100644 index 00000000..4f1e5a0a --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java @@ -0,0 +1,433 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsString; +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.*; + +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.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +import lombok.extern.slf4j.Slf4j; + +/// Integration tests for EntityDynamicMappingController, covering all CRUD operations on entity dynamic mappings. +/// +/// Covers the full HTTP contract (status codes, response shape, validation errors) +/// for all CRUD operations on entity dynamic mappings. +@DisplayName("EntityDynamicMappingController Integration Tests") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", + "/db/test/R__4_insert_webhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Slf4j +class EntityDynamicMappingControllerTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + private static final String MAPPING_PATH = "/api/v1/entity_dynamic_mappings"; + + /// Builds a valid entity dynamic mapping creation payload. + private String buildCreatePayload(String mappingIdentifier) { + return """ + { + "identifier": "%s", + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "name":"microservice name", + "description":"description", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """.formatted(mappingIdentifier); + } + + /// Builds a valid entity dynamic mapping update payload. + private String buildUpdatePayload() { + return """ + { + "entity_template_identifier": "microservice", + "filter": ".action == \\"released\\"", + "name":"microservice name updated", + "description":"updated description", + "entity": { + "identifier": ".release.tag_name", + "name": ".release.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".release.author.email", + "environment": "\\"PROD\\"", + "version": ".release.tag_name", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """; + } + + @Nested + @DisplayName("GET /api/v1/entity-dynamic-mappings - Get mappings paginated") + @Order(1) + class GetMappingsPaginatedTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getMappings_401_without_user_token() throws Exception { + mockMvc.perform(get(MAPPING_PATH).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with page containing mappings from test data") + void getMappings_200_with_data() throws Exception { + mockMvc.perform(get(MAPPING_PATH).accept(APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("microservice-mapping")); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with custom pagination") + void getMappings_200_with_custom_pagination() throws Exception { + mockMvc + .perform(get(MAPPING_PATH).param("page", "0").param("size", "5") + .param("sort", "identifier,asc").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(0)); + } + } + + @Nested + @DisplayName("POST /api/v1/entity-dynamic-mappings - Create mapping") + @Order(2) + class PostMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void postMapping_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("new-mapping"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should create mapping and return 201") + void postMapping_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildCreatePayload("new-test-mapping"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("new-test-mapping")) + .andExpect(jsonPath("$.entity_template_identifier").value("microservice")) + .andExpect(jsonPath("$.filter").value(".action == \"pushed\"")) + .andExpect(jsonPath("$.entity.identifier").value(".repository.full_name")); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when identifier already exists") + void postMapping_409_identifier_already_exists() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildCreatePayload("microservice-mapping"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString("microservice-mapping"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when mappingIdentifier is missing") + void postMapping_400_identifier_missing() throws Exception { + var payload = """ + { + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("mapping identifier"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when entityTemplateIdentifier is missing") + void postMapping_400_template_missing() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(containsString("entityTemplateIdentifier"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when entityTemplateIdentifier does not exist") + void postMapping_404_template_not_found() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "entity_template_identifier": "non-existent-entityTemplateIdentifier", + "filter": ".action == \\"pushed\\"", + "name":"test mapping", + "description":"descrption", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description") + .value(containsString("non-existent-entityTemplateIdentifier"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when required entityTemplateIdentifier properties are missing") + void postMapping_400_missing_required_properties() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "name":"test mapping name", + "description":"descrption", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name" + }, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("missing required"))); + } + } + + @Nested + @DisplayName("GET /api/v1/entity-dynamic-mappings/{identifier} - Get by identifier") + @Order(3) + class GetMappingByIdentifierTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getMapping_401_without_user_token() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/microservice-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with mapping details from test data") + void getMapping_200() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/microservice-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("microservice-mapping")) + .andExpect(jsonPath("$.entity_template_identifier").value("microservice")) + .andExpect(jsonPath("$.filter").value(".action == \"pushed\"")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void getMapping_404_not_found() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/non-existent-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + } + + @Nested + @DisplayName("PUT /api/v1/entity-dynamic-mappings/{identifier} - Update mapping") + @Order(4) + class PutMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void putMapping_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/microservice-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void putMapping_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/non-existent-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should update mapping and return 200") + void putMapping_200() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("mapping-to-update"))) + .andExpect(status().isCreated()); + + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/mapping-to-update") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("mapping-to-update")) + .andExpect(jsonPath("$.filter").value(".action == \"released\"")) + .andExpect(jsonPath("$.entity.identifier").value(".release.tag_name")); + + mockMvc.perform(get(MAPPING_PATH + "/mapping-to-update").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.filter").value(".action == \"released\"")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when entityTemplateIdentifier is missing in update") + void putMapping_400_template_missing() throws Exception { + var payload = """ + { + "filter": ".action == \\"released\\"", + "entity": { + "identifier": ".release.tag_name", + "name": ".release.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/microservice-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")); + } + } + + @Nested + @DisplayName("DELETE /api/v1/entity-dynamic-mappings/{identifier} - Delete mapping") + @Order(5) + class DeleteMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void deleteMapping_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/microservice-mapping") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should delete mapping and return 204") + void deleteMapping_204() throws Exception { + // First create a mapping to delete + mockMvc.perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("mapping-to-delete"))) + .andExpect(status().isCreated()); + + // Delete the mapping + mockMvc.perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/mapping-to-delete") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + // Verify the mapping no longer exists + mockMvc.perform(get(MAPPING_PATH + "/mapping-to-delete").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void deleteMapping_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/non-existent-mapping") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when mapping is in use by a webhook") + void deleteMapping_409_in_use() throws Exception { + // The microservice-mapping is used by github-dora-connector from test data + mockMvc + .perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/microservice-mapping") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString("in use"))); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index 8abc1006..133b1a51 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; 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.test.web.servlet.MockMvc; import com.decathlon.idp_core.AbstractIntegrationTest; @@ -31,6 +32,11 @@ /// - Filter "monitors": only aβ†’b returned; c is unreachable via "monitors" edges /// - 404 for unknown entity /// - 401 without authentication +// R__3 is idempotent (ON CONFLICT DO NOTHING) so it is safe to re-run it +// before this test class even when Flyway already applied it at startup. +// This guarantees the graph entities are always present regardless of which +// other test class ran before this one and may have deleted them. +@Sql(scripts = "/db/test/R__3_Insert_graph_entities_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") public class EntityGraphControllerTest extends AbstractIntegrationTest { 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 00000000..dc416581 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -0,0 +1,1099 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.*; +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.*; + +import java.util.Map; +import java.util.UUID; + +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.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.decathlon.idp_core.AbstractIntegrationTest; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.InboundWebhookEntityMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.webhook.model.*; + +import lombok.extern.slf4j.Slf4j; + +/// Integration tests for InboundWebhookManagementController, covering all CRUD operations on inbound webhook connectors. +/// +/// 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(scripts = {"/db/test/R__1_Insert_test_data.sql", + "/db/test/R__4_insert_webhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Slf4j +class InboundWebhookManagementControllerTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private WebhookMappingLinkPersistenceMapper webhookTemplateMappingPersistenceMapper; + + private static final String WEBHOOK_PATH = "/api/v1/inbound_webhooks"; + private static final String ENTITY_DYNAMIC_MAPPING_PATH = "/api/v1/entity_dynamic_mappings"; + private static final String JSON_PATH = "integration_test/json/webhook/v1/"; + + /// Creates an entity dynamic mapping via API required for webhook connector + /// creation. + private void createEntityDynamicMapping(String mappingIdentifier) throws Exception { + var payload = """ + { + "identifier": "%s", + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "name":"test mapping name", + "description":"descrption", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """.formatted(mappingIdentifier); + + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_DYNAMIC_MAPPING_PATH) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()); + } + + private void createWebhookConnector(String identifier, String title) throws Exception { + // Create a unique entity dynamic mapping for this webhook connector + String uniqueMappingIdentifier = identifier + "-mapping"; + createEntityDynamicMapping(uniqueMappingIdentifier); + + var payload = """ + { + "identifier": "%s", + "name": "%s", + "description": "test connector", + "enabled": true, + "mapping_identifiers": ["%s"], + "security": { + "type": "NONE", + "config": {} + } + } + """.formatted(identifier, title, uniqueMappingIdentifier); + + 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, String mappingIdentifier) { + return """ + { + "identifier": "ignored-by-put", + "name": "%s", + "description": "updated description", + "enabled": %s, + "mapping_identifiers": ["%s"], + "security": { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Token", + "secret_alias": "MY_TOKEN" + } + } + } + """.formatted(title, enabled, mappingIdentifier); + } + + @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 page containing connectors from test data") + void getWebhooks_200_with_data() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).accept(APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(3)) + .andExpect(jsonPath("$.content[0].identifier").value("github-dora-connector")) + .andExpect(jsonPath("$.content[1].identifier").value("public-connector")) + .andExpect(jsonPath("$.content[2].identifier").value("token-connector")); + } + } + + @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-test")) + .andExpect(jsonPath("$.name").value("GitHub DORA Connector test")) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")); + + assertWebhookTemplateMapping("github-dora-connector-test"); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when identifier already exists") + void postWebhook_409_identifier_already_exists() throws Exception { + 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 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 404 when mapping identifier does not exist") + void postWebhook_404_mapping_not_found() throws Exception { + var payload = """ + { + "identifier": "webhook-with-unknown-mapping", + "name": "Webhook With Unknown Mapping", + "description": "Should fail due to non-existent mapping", + "enabled": true, + "mapping_identifiers": ["non-existent-mapping"], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").value(containsString("non-existent-mapping"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when security config is missing required fields (HMAC_SHA256)") + void postWebhook_400_missing_security_config_fields() throws Exception { + var payload = """ + { + "identifier": "missing-security-fields", + "name": "Missing Security Fields", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + @Test + @WithMockUser + @DisplayName("Should create connector with NONE security when security field is omitted") + void postWebhook_201_security_defaults_to_none_when_omitted() throws Exception { + var payload = """ + { + "identifier": "no-security-webhook", + "name": "No Security Webhook", + "enabled": true, + "mapping_identifiers": [] + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @Test + @WithMockUser + @DisplayName("Should create connector with STATIC_TOKEN security and return 201") + void postWebhook_201_static_token_security() throws Exception { + var payload = """ + { + "identifier": "static-token-webhook", + "name": "Static Token Webhook", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Auth-Token", + "secret_alias": "TOKEN_SECRET" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("static-token-webhook")) + .andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")); + } + + @Test + @WithMockUser + @DisplayName("Should create connector with BASIC_AUTH security and return 201") + void postWebhook_201_basic_auth_security() throws Exception { + var payload = """ + { + "identifier": "basic-auth-webhook", + "name": "Basic Auth Webhook", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "BASIC_AUTH", + "config": { + "username": "admin", + "secret_alias": "BASIC_PASS" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("basic-auth-webhook")) + .andExpect(jsonPath("$.security.type").value("BASIC_AUTH")); + } + + @Test + @WithMockUser + @DisplayName("Should force enabled to false when no mappings provided") + void postWebhook_201_enabled_forced_false_when_no_mapping() throws Exception { + var payload = """ + { + "identifier": "no-mapping-webhook", + "name": "No Mapping Webhook", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("no-mapping-webhook")) + // Even though enabled=true was sent, it must be forced to false (no mappings) + .andExpect(jsonPath("$.enabled").value(false)); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when name exceeds 255 characters") + void postWebhook_400_title_too_long() throws Exception { + var tooLongTitle = "A".repeat(256); + var payload = """ + { + "identifier": "long-name-webhook", + "name": "%s", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """.formatted(tooLongTitle); + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when name is blank") + void postWebhook_400_title_blank() throws Exception { + var payload = """ + { + "identifier": "blank-name-webhook", + "name": " ", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when security config is missing required fields (BASIC_AUTH)") + void postWebhook_400_basic_auth_missing_fields() throws Exception { + var payload = """ + { + "identifier": "basic-auth-missing-fields", + "name": "Basic Auth Missing Fields", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "BASIC_AUTH", + "config": { + "username": "admin" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + } + + @Nested + @DisplayName("GET /api/v1/inbound_webhooks/{identifier} - Get by identifier") + @Order(3) + class GetWebhookByIdentifierTests { + + @Test + @WithMockUser + @DisplayName("Should return 200 with connector details from test data") + void getWebhook_200() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("github-dora-connector")) + .andExpect(jsonPath("$.name").value("GitHub Connector")) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")) + .andExpect(jsonPath("$.mappings[0].entity_template_identifier").value("microservice")); + } + + @Test + @WithMockUser + @DisplayName("Should return full mapping shape including entity fields") + void getWebhook_200_full_mapping_shape() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.mappings").isArray()) + .andExpect(jsonPath("$.mappings[0].identifier").exists()) + .andExpect(jsonPath("$.mappings[0].entity_template_identifier").value("microservice")) + .andExpect(jsonPath("$.mappings[0].filter").value(".action == \"pushed\"")) + .andExpect(jsonPath("$.mappings[0].entity.identifier").exists()) + .andExpect(jsonPath("$.mappings[0].entity.name").exists()) + .andExpect(jsonPath("$.mappings[0].entity.properties").exists()) + .andExpect(jsonPath("$.mappings[0].entity.relations").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return security type and config in response") + void getWebhook_200_security_shape() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.security").exists()) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")) + .andExpect(jsonPath("$.security.config").exists()) + .andExpect(jsonPath("$.security.config.header_name").value("X-Hub-Signature-256")); + } + + @Test + @WithMockUser + @DisplayName("Should return enabled and description fields") + void getWebhook_200_base_fields() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/public-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("public-connector")) + .andExpect(jsonPath("$.name").value("Public Connector")) + .andExpect(jsonPath("$.description").exists()) + .andExpect(jsonPath("$.enabled").isBoolean()); + } + + @Test + @WithMockUser + @DisplayName("Should return STATIC_TOKEN security type") + void getWebhook_200_static_token_security() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/token-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")) + .andExpect(jsonPath("$.security.config.header_name").value("X-Auth-Token")); + } + + @Test + @WithMockUser + @DisplayName("Should return NONE security type with empty config") + void getWebhook_200_none_security() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/public-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @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, + "connector-put-200-mapping"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("connector-put-200")) + .andExpect(jsonPath("$.name").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("$.name").value("Connector Put Updated Title")) + .andExpect(jsonPath("$.enabled").value(false)); + + assertWebhookTemplateMapping("connector-put-200"); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when updating name 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, "connector-put-409-b-mapping"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Webhook Connector already exist with the same name:Connector A Title"))); + } + + @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, "microservice-mapping"))) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should force enabled to false when updating with empty mappings") + void putWebhook_200_enabled_forced_false_when_no_mapping() throws Exception { + createWebhookConnector("connector-put-no-mapping", "Connector No Mapping Title"); + + var payload = """ + { + "identifier": "ignored-by-put", + "name": "Connector No Mapping Title", + "description": "updated without mappings", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-no-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("connector-put-no-mapping")) + // enabled=true sent but no mappings β†’ must be forced to false + .andExpect(jsonPath("$.enabled").value(false)); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when PUT security config is missing required fields") + void putWebhook_400_missing_security_config_fields() throws Exception { + createWebhookConnector("connector-put-bad-security", "Connector Bad Security"); + + var payload = """ + { + "identifier": "ignored-by-put", + "name": "Connector Bad Security", + "enabled": true, + "mapping_identifiers": ["connector-put-bad-security-mapping"], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Sig" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-bad-security") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + } + + @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()); + + // Verify the webhook no longer exists + mockMvc.perform(get(WEBHOOK_PATH + "/connector-delete-204").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")); + } + + @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()); + } + } + + /// Asserts that the webhook connector has at least one mapping via API. + private void assertWebhookTemplateMapping(String identifier) throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/" + identifier).accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.mappings").isArray()) + .andExpect(jsonPath("$.mappings").isNotEmpty()) + .andExpect(jsonPath("$.mappings[0].filter").value(".action == \"pushed\"")); + } + + @Nested + @DisplayName("Security config records") + @Order(6) + class SecurityConfigRecordTests { + + @Test + @DisplayName("BasicAuthConfig should expose BASIC_AUTH type and values") + void basicAuthConfig_shouldExposeTypeAndValues() { + SecurityConfig config = new BasicAuthConfig("admin", "BASIC_SECRET"); + + var basicAuthConfig = assertInstanceOf(BasicAuthConfig.class, config); + assertEquals(WebhookSecurityType.BASIC_AUTH, config.type()); + assertEquals("admin", basicAuthConfig.username()); + assertEquals("BASIC_SECRET", basicAuthConfig.secretAlias()); + } + + @Test + @DisplayName("HmacConfig should expose HMAC_SHA256 type and default optional values") + void hmacConfig_shouldExposeTypeAndDefaultOptionalValues() { + SecurityConfig config = new HmacConfig("X-Hub-Signature-256", "HMAC_SECRET", null, null); + + var hmacConfig = assertInstanceOf(HmacConfig.class, config); + assertEquals(WebhookSecurityType.HMAC_SHA256, config.type()); + assertEquals("X-Hub-Signature-256", hmacConfig.headerName()); + assertEquals("HMAC_SECRET", hmacConfig.secretAlias()); + assertEquals("", hmacConfig.prefix()); + assertEquals(HmacConfig.DEFAULT_ENCODING, hmacConfig.encoding()); + } + + @Test + @DisplayName("StaticTokenConfig should expose STATIC_TOKEN type and values") + void staticTokenConfig_shouldExposeTypeAndValues() { + SecurityConfig config = new StaticTokenConfig("X-Auth-Token", "TOKEN_SECRET"); + + var staticTokenConfig = assertInstanceOf(StaticTokenConfig.class, config); + assertEquals(WebhookSecurityType.STATIC_TOKEN, config.type()); + assertEquals("X-Auth-Token", staticTokenConfig.headerName()); + assertEquals("TOKEN_SECRET", staticTokenConfig.secretAlias()); + } + + @Test + @DisplayName("JwtBearerConfig should expose JWT_BEARER type and jwksUri") + void jwtBearerConfig_shouldExposeTypeAndValues() { + SecurityConfig config = new JwtBearerConfig("https://auth.example.com/.well-known/jwks.json"); + + var jwtBearerConfig = assertInstanceOf(JwtBearerConfig.class, config); + assertEquals(WebhookSecurityType.JWT_BEARER, config.type()); + assertEquals("https://auth.example.com/.well-known/jwks.json", jwtBearerConfig.jwksUri()); + } + } + + @Nested + @DisplayName("Webhook support components coverage") + @Order(7) + class WebhookSupportComponentsCoverageTests { + + @Test + @DisplayName("InboundWebhookEntityMappingDtoOut should defensively copy maps") + void inboundWebhookEntityMappingDtoOut_shouldDefensivelyCopyMaps() { + var properties = Map.of("applicationName", ".repository.name"); + var relations = Map.of("owner", ".sender.login"); + + var dto = new InboundWebhookEntityMappingDtoOut(".repository.full_name", ".repository.name", + properties, relations); + + assertEquals(".repository.full_name", dto.identifier()); + assertEquals(".repository.name", dto.title()); + assertEquals(properties, dto.properties()); + assertEquals(relations, dto.relations()); + var copiedProperties = dto.properties(); + var copiedRelations = dto.relations(); + assertThrows(UnsupportedOperationException.class, + () -> copiedProperties.put("environment", "DEV")); + assertThrows(UnsupportedOperationException.class, + () -> copiedRelations.put("team", ".team.slug")); + } + + @Test + @DisplayName("WebhookConnectorConfigurationException should expose its message") + void webhookConnectorConfigurationException_shouldExposeMessage() { + var exception = new WebhookConnectorConfigurationException( + "Webhook connector name is mandatory"); + + assertInstanceOf(RuntimeException.class, exception); + assertEquals("Webhook connector name is mandatory", exception.getMessage()); + } + + @Test + @DisplayName("WebhookTemplateMappingPersistenceMapper should build link entity from explicit ids") + void webhookTemplateMappingPersistenceMapper_shouldBuildLinkEntityFromExplicitIds() { + UUID webhookId = UUID.fromString("770e8400-e29b-41d4-a716-446655440001"); + UUID entityMappingId = UUID.fromString("880e8400-e29b-41d4-a716-446655440001"); + String jsltFilter = ".action == \"pushed\""; + + WebhookMappingLinkJpaEntity jpa = webhookTemplateMappingPersistenceMapper.toJpa(webhookId, + entityMappingId, jsltFilter); + + assertEquals(webhookId, jpa.getWebhookId()); + assertEquals(entityMappingId, jpa.getEntityMappingId()); + assertEquals(jsltFilter, jpa.getJsltFilter()); + } + } + + @Nested + @DisplayName("Security config validation - all types") + @Order(8) + class SecurityConfigTests { + + @Test + @WithMockUser + @DisplayName("NONE β€” Should create connector with empty config") + void security_none_empty_config_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-none", "None Security", "NONE", "{}"))) + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @Test + @WithMockUser + @DisplayName("NONE β€” Should create connector without config field") + void security_none_no_config_201() throws Exception { + var payload = """ + { + "identifier": "sec-none-no-config", + "name": "None No Config", + "enabled": false, + "mapping_identifiers": [], + "security": { "type": "NONE", "config":{} } + } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 β€” Should create with valid header_name and secret_alias") + void security_hmac_valid_201() throws Exception { + var config = """ + { "header_name": "X-Hub-Signature-256", "secret_alias": "MY_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-valid", "HMAC Valid", "HMAC_SHA256", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")) + .andExpect(jsonPath("$.security.config.header_name").value("X-Hub-Signature-256")); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 β€” Should return 400 when header_name is missing") + void security_hmac_missing_header_name_400() throws Exception { + var config = """ + { "secret_alias": "MY_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-no-header", "HMAC No Header", "HMAC_SHA256", + config))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("header_name"))); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 β€” Should return 400 when secret_alias is missing") + void security_hmac_missing_secret_alias_400() throws Exception { + var config = """ + { "header_name": "X-Hub-Signature-256" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-no-secret", "HMAC No Secret", "HMAC_SHA256", + config))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 β€” Should return 400 when secret_alias has invalid format (lowercase)") + void security_hmac_invalid_secret_alias_format_400() throws Exception { + var config = """ + { "header_name": "X-Sig", "secret_alias": "invalid-lowercase" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-bad-alias", "HMAC Bad Alias", "HMAC_SHA256", + config))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("UPPER_SNAKE_CASE"))); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 β€” Should accept secret_alias as environment reference ${MY_SECRET}") + void security_hmac_env_reference_201() throws Exception { + var config = """ + { "header_name": "X-Sig", "secret_alias": "${MY_SECRET}" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-hmac-env-ref", "HMAC Env Ref", "HMAC_SHA256", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")); + } + + // ----------------------------------------------------------------------- + // STATIC_TOKEN + // ----------------------------------------------------------------------- + + @Test + @WithMockUser + @DisplayName("STATIC_TOKEN β€” Should create with valid header_name and secret_alias") + void security_static_token_valid_201() throws Exception { + var config = """ + { "header_name": "X-Auth-Token", "secret_alias": "TOKEN_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-token-valid", "Token Valid", "STATIC_TOKEN", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")) + .andExpect(jsonPath("$.security.config.header_name").value("X-Auth-Token")); + } + + @Test + @WithMockUser + @DisplayName("STATIC_TOKEN β€” Should return 400 when header_name is missing") + void security_static_token_missing_header_400() throws Exception { + var config = """ + { "secret_alias": "TOKEN_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-token-no-header", "Token No Header", + "STATIC_TOKEN", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("header_name"))); + } + + @Test + @WithMockUser + @DisplayName("STATIC_TOKEN β€” Should return 400 when secret_alias is missing") + void security_static_token_missing_secret_400() throws Exception { + var config = """ + { "header_name": "X-Auth-Token" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-token-no-secret", "Token No Secret", + "STATIC_TOKEN", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + // ----------------------------------------------------------------------- + // BASIC_AUTH + // ----------------------------------------------------------------------- + + @Test + @WithMockUser + @DisplayName("BASIC_AUTH β€” Should create with valid username and secret_alias") + void security_basic_auth_valid_201() throws Exception { + var config = """ + { "username": "admin", "secret_alias": "BASIC_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-basic-valid", "Basic Valid", "BASIC_AUTH", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("BASIC_AUTH")); + } + + @Test + @WithMockUser + @DisplayName("BASIC_AUTH β€” Should return 400 when username is missing") + void security_basic_auth_missing_username_400() throws Exception { + var config = """ + { "secret_alias": "BASIC_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-basic-no-user", "Basic No User", "BASIC_AUTH", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("username"))); + } + + @Test + @WithMockUser + @DisplayName("BASIC_AUTH β€” Should return 400 when secret_alias is missing") + void security_basic_auth_missing_secret_400() throws Exception { + var config = """ + { "username": "admin" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-basic-no-secret", "Basic No Secret", "BASIC_AUTH", + config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + // ----------------------------------------------------------------------- + // JWT_BEARER + // ----------------------------------------------------------------------- + + @Test + @WithMockUser + @DisplayName("JWT_BEARER β€” Should create with valid jwks_uri") + void security_jwt_bearer_valid_201() throws Exception { + var config = """ + { "jwks_uri": "https://auth.example.com/.well-known/jwks.json" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-jwt-valid", "JWT Valid", "JWT_BEARER", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("JWT_BEARER")); + } + + @Test + @WithMockUser + @DisplayName("JWT_BEARER β€” Should create with jwks_uri as environment reference") + void security_jwt_bearer_env_reference_201() throws Exception { + var config = """ + { "jwks_uri": "${JWKS_URI}" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-jwt-env", "JWT Env", "JWT_BEARER", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("JWT_BEARER")); + } + + @Test + @WithMockUser + @DisplayName("JWT_BEARER β€” Should return 400 when jwks_uri is missing") + void security_jwt_bearer_missing_jwks_uri_400() throws Exception { + var config = """ + {} + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-jwt-no-uri", "JWT No URI", "JWT_BEARER", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("jwks_uri"))); + } + } + + /// Builds a minimal POST payload with a given security type and config JSON + /// string. + private String buildSecurityPayload(String identifier, String title, String securityType, + String configJson) { + return """ + { + "identifier": "%s", + "name": "%s", + "enabled": false, + "mapping_identifiers": [], + "security": { + "type": "%s", + "config": %s + } + } + """.formatted(identifier, title, securityType, configJson); + } +} 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 8dff1a38..2a92a836 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 @@ -1,8 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -29,11 +27,14 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; -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.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.domain.exception.webhook.WebhookTemplateHasNoPropertiesException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; /// Comprehensive unit tests for [ApiExceptionHandler]. @@ -160,94 +161,227 @@ void shouldHandleEntityValidationException() { assertEquals(exception.getMessage(), body.getErrorDescription()); } - /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNameAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and description - @Test - @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateNameAlreadyExistsException() { - // Given - String name = "Duplicate Name"; - EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException( - name); - - // When - ResponseEntity response = exceptionHandler - .handleEntityTemplateNameAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityNotFoundException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the entity-specific context message - @Test - @DisplayName("Should handle EntityNotFoundException with 404 status") - void shouldHandleEntityNotFoundException() { - // Given - EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); - - // When - ResponseEntity response = exceptionHandler - .handleEntityNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - } - - @Nested - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - /// Tests the handling of [ConstraintViolationException] with a single - /// validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation( - "Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", - violations); - - // When - ResponseEntity response = exceptionHandler - .handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); + @Nested + @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 entityTemplateIdentifier 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 entityTemplateIdentifier 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 entityTemplateIdentifier 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:** + /// - ConstraintViolationException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Single violation message is correctly extracted and returned + /// - Error response format matches expected structure + @Test + @DisplayName("Should handle ConstraintViolationException with single violation") + void shouldHandleConstraintViolationExceptionSingleViolation() { + // Given + ConstraintViolation violation = createMockConstraintViolation( + "Field must not be null"); + Set> violations = Set.of(violation); + ConstraintViolationException exception = new ConstraintViolationException( + "Validation failed", violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Field must not be null", body.getErrorDescription()); + } + + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException( + "Validation failed", violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); + } + + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, + "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } } /// Tests the handling of [ConstraintViolationException] with multiple 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 00000000..6b181845 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java @@ -0,0 +1,89 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +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.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.DynamicMappingMapper; + +@DisplayName("InboundWebhookMapper Tests") +class InboundWebhookMapperTest { + + private final InboundWebhookMapper mapper = new InboundWebhookMapper(new DynamicMappingMapper()); + + private static EntityDynamicMapping resolvedMapping() { + return new EntityDynamicMapping(UUID.randomUUID(), "deployment-mapping", "deployment", + ".eventType == \"DEPLOYED\"", "deployment mapping", "deployment mapping description", ".id", + ".name", Map.of("environment", ".env"), Map.of("service", ".service")); + } + + @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("deployment-mapping"), + 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, + List.of(resolvedMapping())); + + assertThat(domain.id()).isNull(); + assertThat(domain.identifier()).isEqualTo("identifier_from_path"); + assertThat(domain.name()).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("deployment-mapping"), + new InboundWebhookSecurityContractDtoIn("CUSTOM_UNKNOWN_TYPE", + Map.of("customKey", "customValue"))); + + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> mapper.toDomain(request, List.of(resolvedMapping()))) + .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("deployment-mapping"), + new InboundWebhookSecurityContractDtoIn("NONE", Map.of())); + + var domain = mapper.toDomain(request, List.of(resolvedMapping())); + + 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("deployment-mapping"), null); + + var domain = mapper.toDomain(request, List.of(resolvedMapping())); + + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); + assertThat(domain.security().config()).isEmpty(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java new file mode 100644 index 00000000..c36f57f3 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java @@ -0,0 +1,181 @@ +package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +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 com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +@DisplayName("JsltEntityMappingValidator") +class JsltEntityMappingValidatorTest { + + private JsltEntityMappingValidator validator; + + @BeforeEach + void setUp() { + validator = new JsltEntityMappingValidator(); + } + + // --------------------------------------------------------------------------- + // validate + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("validate") + class ValidateTests { + + @Test + @DisplayName("Should pass when all JSLT expressions are valid") + void shouldPassWhenAllExpressionsValid() { + var mapping = buildMapping(".action == \"pushed\"", ".repository.full_name", + ".repository.name", Map.of("applicationName", ".repository.name"), + Map.of("owner", ".sender.login")); + + assertThatCode(() -> validator.validate(mapping)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should pass when properties and relations are empty (false branch)") + void shouldPassWhenPropertiesAndRelationsEmpty() { + var mapping = buildMapping(".action", ".repository.full_name", ".repository.name", Map.of(), + Map.of()); + + assertThatCode(() -> validator.validate(mapping)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw when the filter expression is an invalid JSLT") + void shouldThrowWhenFilterExpressionInvalid() { + var mapping = buildMapping("[", ".repository.full_name", ".repository.name", Map.of(), + Map.of()); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("Validation failed with").hasMessageContaining("filter"); + } + + @Test + @DisplayName("Should report a required-field error when a property expression is blank") + void shouldReportRequiredErrorWhenPropertyExpressionBlank() { + var properties = new HashMap(); + properties.put("applicationName", " "); + + var mapping = buildMapping(".action", ".repository.full_name", ".repository.name", properties, + Map.of()); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("properties.applicationName") + .hasMessageContaining("is required and must contain a JSLT expression"); + } + + @Test + @DisplayName("Should report an error when a relation expression is invalid JSLT") + void shouldReportErrorWhenRelationExpressionInvalid() { + var mapping = buildMapping(".action", ".repository.full_name", ".repository.name", Map.of(), + Map.of("owner", "[")); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("relations.owner"); + } + + @Test + @DisplayName("Should aggregate multiple errors into a single exception message") + void shouldAggregateMultipleErrors() { + var properties = new HashMap(); + properties.put("applicationName", " "); + + var mapping = buildMapping("[", ".repository.full_name", ".repository.name", properties, + Map.of("owner", "[")); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("Validation failed with 3 errors"); + } + } + + // --------------------------------------------------------------------------- + // formatJsltErrorMessage (private β€” exercised through reflection) + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("formatJsltErrorMessage") + class FormatJsltErrorMessageTests { + + @Test + @DisplayName("Should return generic message when raw message is null") + void shouldReturnGenericMessageWhenNull() { + assertThat(formatJsltErrorMessage(null)).isEqualTo("JSLT syntax error."); + } + + @Test + @DisplayName("Should return generic message when raw message is blank") + void shouldReturnGenericMessageWhenBlank() { + assertThat(formatJsltErrorMessage(" ")).isEqualTo("JSLT syntax error."); + } + + @Test + @DisplayName("Should strip the 'Parse error:' prefix and normalize whitespace") + void shouldStripParseErrorPrefixAndNormalize() { + assertThat(formatJsltErrorMessage("Parse error: something went wrong")) + .isEqualTo("something went wrong"); + } + + @Test + @DisplayName("Should format message with line, column and token when all present") + void shouldFormatWithLineColumnAndToken() { + var raw = "Parse error: at line 3, column 7 Encountered \"}\" but expected something"; + + assertThat(formatJsltErrorMessage(raw)) + .isEqualTo("JSLT syntax error at line 3, column 7 (unexpected token: })."); + } + + @Test + @DisplayName("Should format message with line and column only when token absent") + void shouldFormatWithLineColumnOnly() { + var raw = "Unexpected failure at line 5, column 2 in the expression"; + + assertThat(formatJsltErrorMessage(raw)).isEqualTo("JSLT syntax error at line 5, column 2."); + } + + @Test + @DisplayName("Should fall back to normalized message when no location is present") + void shouldFallBackToNormalizedMessage() { + assertThat(formatJsltErrorMessage("Totally unexpected message")) + .isEqualTo("Totally unexpected message"); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private EntityDynamicMapping buildMapping(String filter, String entityIdentifier, + String entityTitle, Map properties, Map relations) { + return new EntityDynamicMapping(UUID.randomUUID(), "my-mapping", "microservice", filter, + "My Mapping", "description", entityIdentifier, entityTitle, properties, relations); + } + + /// Invokes the private `formatJsltErrorMessage` method through reflection so + /// every formatting branch can be exercised deterministically. + private String formatJsltErrorMessage(String rawMessage) { + try { + Method method = JsltEntityMappingValidator.class.getDeclaredMethod("formatJsltErrorMessage", + String.class); + method.setAccessible(true); + return (String) method.invoke(validator, rawMessage); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to invoke formatJsltErrorMessage", e); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptorTest.java new file mode 100644 index 00000000..215b1eb3 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptorTest.java @@ -0,0 +1,278 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +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.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +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.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; + +@DisplayName("EntityDynamicMappingAdaptor Tests") +@ExtendWith(MockitoExtension.class) +class EntityDynamicMappingAdaptorTest { + + @Mock + private JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + + @Mock + private EntityDynamicMappingPersistenceMapper entityDynamicMappingPersistenceMapper; + + @Mock + private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + + private EntityDynamicMappingAdaptor adaptor; + + @BeforeEach + void setUp() { + adaptor = new EntityDynamicMappingAdaptor(jpaEntityDynamicMappingRepository, + entityDynamicMappingPersistenceMapper, entityTemplateRepositoryPort); + } + + // --------------------------------------------------------------------------- + // existsByIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("existsByIdentifier") + class ExistsByIdentifierTests { + + @Test + @DisplayName("Should return true when mapping exists") + void shouldReturnTrueWhenExists() { + when(jpaEntityDynamicMappingRepository.existsByIdentifier("my-mapping")).thenReturn(true); + assertThat(adaptor.existsByIdentifier("my-mapping")).isTrue(); + } + + @Test + @DisplayName("Should return false when mapping does not exist") + void shouldReturnFalseWhenNotExists() { + when(jpaEntityDynamicMappingRepository.existsByIdentifier("unknown")).thenReturn(false); + assertThat(adaptor.existsByIdentifier("unknown")).isFalse(); + } + } + + // --------------------------------------------------------------------------- + // existsByTemplateIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("existsByTemplateIdentifier") + class ExistsByTemplateIdentifierTests { + + @Test + @DisplayName("Should return true when mappings exist for template") + void shouldReturnTrueWhenExists() { + when(jpaEntityDynamicMappingRepository.existsByTemplateIdentifier("web-service")) + .thenReturn(true); + assertThat(adaptor.existsByTemplateIdentifier("web-service")).isTrue(); + } + + @Test + @DisplayName("Should return false when no mappings for template") + void shouldReturnFalseWhenNotExists() { + when(jpaEntityDynamicMappingRepository.existsByTemplateIdentifier("unknown")) + .thenReturn(false); + assertThat(adaptor.existsByTemplateIdentifier("unknown")).isFalse(); + } + } + + // --------------------------------------------------------------------------- + // findByIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findByIdentifier") + class FindByIdentifierTests { + + @Test + @DisplayName("Should return mapped domain when entity found") + void shouldReturnDomainWhenFound() { + var jpa = buildJpaEntity("my-mapping"); + var domain = buildDomainMapping("my-mapping"); + when(jpaEntityDynamicMappingRepository.findByIdentifier("my-mapping")) + .thenReturn(Optional.of(jpa)); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa)).thenReturn(domain); + + Optional result = adaptor.findByIdentifier("my-mapping"); + + assertThat(result).isPresent().contains(domain); + } + + @Test + @DisplayName("Should return empty optional when entity not found") + void shouldReturnEmptyWhenNotFound() { + when(jpaEntityDynamicMappingRepository.findByIdentifier("unknown")) + .thenReturn(Optional.empty()); + + Optional result = adaptor.findByIdentifier("unknown"); + + assertThat(result).isEmpty(); + } + } + + // --------------------------------------------------------------------------- + // findByTemplateIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findByTemplateIdentifier") + class FindByTemplateIdentifierTests { + + @Test + @DisplayName("Should return all domain mappings for a given template identifier") + void shouldReturnMappingsForTemplate() { + var jpa1 = buildJpaEntity("mapping-1"); + var jpa2 = buildJpaEntity("mapping-2"); + var domain1 = buildDomainMapping("mapping-1"); + var domain2 = buildDomainMapping("mapping-2"); + + when(jpaEntityDynamicMappingRepository.findByTemplateIdentifier("web-service")) + .thenReturn(List.of(jpa1, jpa2)); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa1)).thenReturn(domain1); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa2)).thenReturn(domain2); + + List result = adaptor.findByTemplateIdentifier("web-service"); + + assertThat(result).hasSize(2).containsExactly(domain1, domain2); + } + + @Test + @DisplayName("Should return empty list when no mappings for template") + void shouldReturnEmptyListWhenNone() { + when(jpaEntityDynamicMappingRepository.findByTemplateIdentifier("unknown")) + .thenReturn(List.of()); + + List result = adaptor.findByTemplateIdentifier("unknown"); + + assertThat(result).isEmpty(); + } + } + + // --------------------------------------------------------------------------- + // save + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("save") + class SaveTests { + + @Test + @DisplayName("Should convert to JPA, save, then convert back to domain") + void shouldSaveAndReturnDomain() { + EntityDynamicMapping domain = buildDomainMapping("my-mapping"); + EntityDynamicMappingJpaEntity jpa = buildJpaEntity("my-mapping"); + EntityDynamicMappingJpaEntity savedJpa = buildJpaEntity("my-mapping"); + UUID templateId = UUID.randomUUID(); + + // The adaptor resolves the template business identifier to its technical UUID + // (fail-fast) before persisting the foreign key. + when(entityTemplateRepositoryPort.findByIdentifier("web-service")) + .thenReturn(Optional.of(new EntityTemplate(templateId, "web-service", "Web Service", null, + List.of(), List.of()))); + when(entityDynamicMappingPersistenceMapper.toJpa(domain)).thenReturn(jpa); + when(jpaEntityDynamicMappingRepository.save(jpa)).thenReturn(savedJpa); + + EntityDynamicMapping result = adaptor.save(domain); + + assertThat(result.id()).isEqualTo(savedJpa.getId()); + assertThat(result.identifier()).isEqualTo(domain.identifier()); + assertThat(result.entityTemplateIdentifier()).isEqualTo(domain.entityTemplateIdentifier()); + assertThat(result.filter()).isEqualTo(domain.filter()); + assertThat(result.name()).isEqualTo(domain.name()); + assertThat(jpa.getTemplateId()).isEqualTo(templateId); + verify(jpaEntityDynamicMappingRepository).save(jpa); + } + } + + // --------------------------------------------------------------------------- + // findAll (paginated) + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findAll") + class FindAllTests { + + @Test + @DisplayName("Should return paginated domain mappings") + void shouldReturnPaginatedMappings() { + var pageable = PageRequest.of(0, 10); + var jpa = buildJpaEntity("my-mapping"); + var domain = buildDomainMapping("my-mapping"); + var jpaPage = new PageImpl<>(List.of(jpa), pageable, 1); + + when(jpaEntityDynamicMappingRepository.findAll(pageable)).thenReturn(jpaPage); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa)).thenReturn(domain); + + var result = adaptor.findAll(pageable); + + assertThat(result.getContent()).hasSize(1).containsExactly(domain); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("Should return empty page when no mappings exist") + void shouldReturnEmptyPage() { + var pageable = PageRequest.of(0, 10); + when(jpaEntityDynamicMappingRepository.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + var result = adaptor.findAll(pageable); + + assertThat(result.getContent()).isEmpty(); + } + } + + // --------------------------------------------------------------------------- + // deleteByIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("deleteByIdentifier") + class DeleteByIdentifierTests { + + @Test + @DisplayName("Should delegate delete to JPA repository") + void shouldDelegateDeleteToRepository() { + adaptor.deleteByIdentifier("my-mapping"); + verify(jpaEntityDynamicMappingRepository).deleteByIdentifier("my-mapping"); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private EntityDynamicMappingJpaEntity buildJpaEntity(String identifier) { + // Constructor field order (see @AllArgsConstructor on + // EntityDynamicMappingJpaEntity): + // id, identifier, templateId (UUID), template (nav, null in tests), filter, + // name, + // description, entityIdentifier, entityName, properties, relations + return new EntityDynamicMappingJpaEntity(UUID.randomUUID(), identifier, UUID.randomUUID(), null, + ".filter", "name", "desc", ".id", ".title", "{}", "{}"); + } + + private EntityDynamicMapping buildDomainMapping(String identifier) { + return new EntityDynamicMapping(UUID.randomUUID(), identifier, "web-service", ".filter", "name", + "desc", ".id", ".title", Map.of(), Map.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptorTest.java new file mode 100644 index 00000000..915a95c9 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptorTest.java @@ -0,0 +1,141 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +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 com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository; + +@DisplayName("WebhookTemplateMappingAdaptor Tests") +@ExtendWith(MockitoExtension.class) +class WebhookTemplateMappingAdaptorTest { + + @Mock + private WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository; + + @Mock + private WebhookMappingLinkPersistenceMapper webhookTemplateMappingPersistenceMapper; + + private WebhookTemplateMappingAdaptor adaptor; + + @BeforeEach + void setUp() { + adaptor = new WebhookTemplateMappingAdaptor(jpaWebhookTemplateMappingRepository, + webhookTemplateMappingPersistenceMapper); + } + + // --------------------------------------------------------------------------- + // existsByEntityMappingId + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("existsByEntityMappingId") + class ExistsByEntityMappingIdTests { + + @Test + @DisplayName("Should return true when mapping exists for entity mapping id") + void shouldReturnTrueWhenExists() { + UUID mappingId = UUID.randomUUID(); + when(jpaWebhookTemplateMappingRepository.existsByEntityMappingId(mappingId)).thenReturn(true); + assertThat(adaptor.existsByEntityMappingId(mappingId)).isTrue(); + } + + @Test + @DisplayName("Should return false when no mapping exists for entity mapping id") + void shouldReturnFalseWhenNotExists() { + UUID mappingId = UUID.randomUUID(); + when(jpaWebhookTemplateMappingRepository.existsByEntityMappingId(mappingId)) + .thenReturn(false); + assertThat(adaptor.existsByEntityMappingId(mappingId)).isFalse(); + } + } + + // --------------------------------------------------------------------------- + // findByEntityMappingId + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findByEntityMappingId") + class FindByEntityMappingIdTests { + + @Test + @DisplayName("Should return mapped domain objects for a given entity mapping id") + void shouldReturnMappedDomainObjects() { + UUID entityMappingId = UUID.randomUUID(); + var jpa = buildJpaEntity(); + var domain = buildDomain("my-webhook"); + + when(jpaWebhookTemplateMappingRepository.findByEntityMappingId(entityMappingId)) + .thenReturn(List.of(jpa)); + when(webhookTemplateMappingPersistenceMapper.toDomain(jpa)).thenReturn(domain); + + var result = adaptor.findByEntityMappingId(entityMappingId); + + assertThat(result).hasSize(1).containsExactly(domain); + } + + @Test + @DisplayName("Should return empty list when no mappings for entity mapping id") + void shouldReturnEmptyListWhenNone() { + UUID entityMappingId = UUID.randomUUID(); + when(jpaWebhookTemplateMappingRepository.findByEntityMappingId(entityMappingId)) + .thenReturn(List.of()); + + var result = adaptor.findByEntityMappingId(entityMappingId); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should return multiple domain objects for the same entity mapping id") + void shouldReturnMultipleMappingsForSameMappingId() { + UUID entityMappingId = UUID.randomUUID(); + WebhookMappingLinkJpaEntity jpa1 = buildJpaEntity(); + WebhookMappingLinkJpaEntity jpa2 = buildJpaEntity(); + WebhookTemplateMapping domain1 = buildDomain("webhook-a"); + WebhookTemplateMapping domain2 = buildDomain("webhook-b"); + + when(jpaWebhookTemplateMappingRepository.findByEntityMappingId(entityMappingId)) + .thenReturn(List.of(jpa1, jpa2)); + when(webhookTemplateMappingPersistenceMapper.toDomain(jpa1)).thenReturn(domain1); + when(webhookTemplateMappingPersistenceMapper.toDomain(jpa2)).thenReturn(domain2); + + List result = adaptor.findByEntityMappingId(entityMappingId); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder(domain1, domain2); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private WebhookMappingLinkJpaEntity buildJpaEntity() { + return WebhookMappingLinkJpaEntity.builder().webhookId(UUID.randomUUID()) + .entityMappingId(UUID.randomUUID()).jsltFilter(".action == \"push\"").build(); + } + + private WebhookTemplateMapping buildDomain(String webhookIdentifier) { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), webhookIdentifier, + webhookIdentifier + " Title", "desc", true, List.of(), + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + return new WebhookTemplateMapping(UUID.randomUUID(), connector, null, null, null); + } +} 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 00000000..4a3bab7e --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@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(); + } + +} 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 00000000..42b631f7 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java @@ -0,0 +1,74 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; + +@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() { + byte[] bodyBytes = BODY.getBytes(StandardCharsets.UTF_8); + assertThatThrownBy(() -> validator.computeHexSha256(bodyBytes, "")) + .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 00000000..2bdc26bf --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java @@ -0,0 +1,42 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +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.junit.jupiter.MockitoExtension; + +@DisplayName("JwtBearerSecurityValidator Tests") +@ExtendWith(MockitoExtension.class) +class JwtBearerSecurityValidatorTest { + + private final JwtBearerSecurityValidator validator = new JwtBearerSecurityValidator(); + + @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("validateConfiguration β€” missing config keys") + class MissingConfigKeys { + + @Test + @DisplayName("Should throw when jwks_uri is missing from config") + void shouldThrowWhenJwksUriMissing() { + Map config = Map.of("other_key", "value"); + + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("jwks_uri"); + } + } +} 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 00000000..0560ce8d --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java @@ -0,0 +1,54 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@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("validateConfiguration β€” missing config keys") + class MissingConfigKeys { + + @Test + @DisplayName("Should throw when header_name is missing from config") + void shouldThrowWhenHeaderNameMissing() { + Map config = Map.of("secret_alias", "MY_ALIAS"); + + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("header_name"); + } + + @Test + @DisplayName("Should throw when secret_alias is missing from config") + void shouldThrowWhenSecretAliasMissing() { + Map config = Map.of("header_name", "X-Token"); + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("secret_alias"); + } + } +} 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 00000000..de3be905 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java @@ -0,0 +1,86 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +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 com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.BasicAuthSecurityValidator; + +@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 00000000..b54e7f19 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java @@ -0,0 +1,95 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +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 com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.HmacSha256SecurityValidator; + +@DisplayName("Security Validator: HMAC SHA-256 Creation") +class HmacSha256WebhookSecurityCreationValidatorTest { + + private HmacSha256SecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new HmacSha256SecurityValidator(); + } + + @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(); + } + + @Test + @DisplayName("validateConfiguration() -> Passes when secret_alias is provided as an environment reference") + void shouldPassWhenSecretAliasUsesEnvironmentReference() { + Map config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", + "${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 00000000..d667370c --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java @@ -0,0 +1,71 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +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.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.JwtBearerSecurityValidator; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Security Validator: JWT Bearer Creation") +class JwtBearerWebhookSecurityCreationValidatorTest { + + private final JwtBearerSecurityValidator validator = new JwtBearerSecurityValidator(); + + @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 00000000..ae9c3599 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java @@ -0,0 +1,86 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +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 com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.StaticTokenSecurityValidator; + +@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/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index 4d0abf2e..82b68323 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -1,5 +1,10 @@ -- Sample data for IDP Core domain models - Enhanced with 10 templates +-- Clear existing data (for repeatable migrations) +-- Order matters: respect foreign key constraints +DELETE FROM webhook_mapping_link; +DELETE FROM entity_dynamic_mapping; +DELETE FROM webhook_connector; -- Clear existing data (for repeatable migrations). -- Deletion order respects FK constraints: child tables first, then parents. DELETE FROM entity_properties; diff --git a/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql b/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql index ec996fba..bd93c9c8 100644 --- a/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql +++ b/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql @@ -11,67 +11,60 @@ -- 2. Relation name filtering excludes the correct edges/nodes at every depth -- 3. "uses" filter returns: a β†’ b β†’ c (2 edges, 3 nodes) -- 4. "monitors" filter returns: a β†’ b (1 edge, 2 nodes; c not reachable) +-- +-- All statements use ON CONFLICT DO NOTHING to make this script idempotent: +-- safe to run via Flyway on startup AND via @Sql before the graph test class. -- ----------------------------------------------------------------------- -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES +INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service') +ON CONFLICT DO NOTHING; -- Relations owned by graph-svc-a: "uses" β†’ b, "monitors" β†’ b -INSERT INTO relation (id, name, target_template_identifier) -VALUES +INSERT INTO relation (id, name, target_template_identifier) VALUES ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service') +ON CONFLICT DO NOTHING; -- Relation owned by graph-svc-b: "uses" β†’ c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); +INSERT INTO relation (id, name, target_template_identifier) VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service') +ON CONFLICT DO NOTHING; -- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) -VALUES +INSERT INTO relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) VALUES ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[uses]-> b ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003'); -- b -[uses]-> c + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003') -- b -[uses]-> c +ON CONFLICT DO NOTHING; -- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES +INSERT INTO entity_relations (entity_id, relation_id) VALUES ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001') -- b owns "uses" relation +ON CONFLICT DO NOTHING; -- ----------------------------------------------------------------------- -- Property data for graph test entities (used by the property-filter tests). --- --- Each graph entity gets two properties: "tier" and "version". --- This lets us verify: --- 1. No filter β†’ both properties appear in node data --- 2. Filter "tier" β†’ only tier present, version absent --- 3. Filter "tier"+"version" β†’ both present --- 4. Filter "non-existent" β†’ data field omitted entirely (NON_EMPTY) -- ----------------------------------------------------------------------- -INSERT INTO property (id, name, value) -VALUES - -- graph-svc-a +INSERT INTO property (id, name, value) VALUES ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), - -- graph-svc-b ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), - -- graph-svc-c ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), - ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0') +ON CONFLICT DO NOTHING; -INSERT INTO entity_properties (entity_id, property_id) -VALUES +INSERT INTO entity_properties (entity_id, property_id) VALUES ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006') -- c.version +ON CONFLICT DO NOTHING; diff --git a/src/test/resources/db/test/R__4_insert_webhook_test_data.sql b/src/test/resources/db/test/R__4_insert_webhook_test_data.sql new file mode 100644 index 00000000..c367e2b5 --- /dev/null +++ b/src/test/resources/db/test/R__4_insert_webhook_test_data.sql @@ -0,0 +1,72 @@ +-- Test data for Webhook Connectors +-- Purpose: provide pre-configured webhook connectors for integration testing + +-- Clear existing data +DELETE +FROM webhook_mapping_link; +DELETE +FROM webhook_connector; +DELETE +FROM entity_dynamic_mapping; + +-- Webhook Connector 1: GitHub Connector (HMAC_SHA256) +INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440001', + 'github-dora-connector', + 'GitHub Connector', + 'Receives events from GitHub with HMAC validation', + true, + '{ + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_SECRET", + "prefix": "sha256=" + } + }'::jsonb); + +-- Webhook Connector 2: Simple Token Connector (STATIC_TOKEN) +INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440002', + 'token-connector', + 'Token Connector', + 'Simple connector with static token', + true, + '{ + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Auth-Token", + "secret_alias": "WEBHOOK_TOKEN" + } + }'::jsonb); + +-- Webhook Connector 3: Public Connector (NONE) +INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440003', + 'public-connector', + 'Public Connector', + 'Open connector for testing', + true, + '{ + "type": "NONE", + "config": {} + }'::jsonb); + +-- Dynamic Mapping for GitHub Connector +INSERT INTO entity_dynamic_mapping (id, identifier, template_id, filter, name, description, entity_identifier, entity_name, properties, relations) +VALUES ('880e8400-e29b-41d4-a716-446655440001', + 'microservice-mapping', + '550e8400-e29b-41d4-a716-446655440071', + '.action == "pushed"', + 'Microservice Mapping', + 'Mapping for microservice entities based on GitHub push events', + '.repository.full_name', + '.repository.name', + '{"applicationName": ".repository.name", "programmingLanguage": ".repository.language"}', + '{}'); + +-- Webhook Template Mapping for GitHub Connector +INSERT INTO webhook_mapping_link (webhook_id, entity_mapping_id, jslt_filter) VALUES ( + '770e8400-e29b-41d4-a716-446655440001', + '880e8400-e29b-41d4-a716-446655440001', + '.action == "pushed"'); 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 00000000..07936182 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json @@ -0,0 +1,15 @@ +{ + "identifier": "github-dora-connector-test", + "name": "GitHub DORA Connector test", + "description": "Collects deployment events from GitHub", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "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 00000000..5b76571f --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json @@ -0,0 +1,11 @@ +{ + "identifier": " ", + "name": "Blank Identifier", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "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 00000000..cb9edf93 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json @@ -0,0 +1,10 @@ +{ + "name": "Missing Identifier", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "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 00000000..b13bc729 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json @@ -0,0 +1,11 @@ +{ + "identifier": "invalid-jslt-connector", + "name": "Invalid JSLT Connector", + "description": "Should fail due to invalid JSLT filter", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "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 00000000..49318bc9 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json @@ -0,0 +1,11 @@ +{ + "identifier": "invalid-security-type-connector", + "name": "Invalid Security Type", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "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 00000000..f4565fa1 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json @@ -0,0 +1,11 @@ +{ + "identifier": "no-mappings-connector", + "name": "No Mappings", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": [], + "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 00000000..c4a86924 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json @@ -0,0 +1,15 @@ +{ + "identifier": "github-dora-connector", + "name": "GitHub DORA Connector", + "description": "Collects deployment events from GitHub", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "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 00000000..b2af44e7 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json @@ -0,0 +1,14 @@ +{ + "identifier": "connector-to-update", + "name": "Updated Title", + "description": "Updated description", + "enabled": false, + "mapping_identifiers": ["microservice-mapping"], + "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 00000000..54429972 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json @@ -0,0 +1,11 @@ +{ + "identifier": "connector-to-update", + "name": "GitHub DORA Connector", + "description": "Duplicate title", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "NONE", + "config": {} + } +}