From 3a4e46a0b0ae60bd1bc871008a88e11e0877748f Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 11 Jun 2026 11:55:56 +0200 Subject: [PATCH 1/4] feat(core): audit implementation --- docs/src/concepts/audit.md | 404 ++++++++++++++++++ docs/src/concepts/index.md | 10 +- docs/src/static/swagger.yaml | 100 +++++ pom.xml | 9 + .../domain/model/entity/EntityAuditInfo.java | 29 ++ .../domain/port/audit/EntityAuditPort.java | 24 ++ .../service/entity/EntityAuditService.java | 50 +++ .../api/auth/UnifiedUserProvider.java | 65 +++ .../api/auth/UserIdentityProvider.java | 6 + .../auth/mock/MockSecurityConfiguration.java | 143 +++++++ .../MockSecurityConfigurationException.java | 29 ++ .../configuration/SecurityConfiguration.java | 4 +- .../api/configuration/SwaggerDescription.java | 5 + .../api/controller/AuditController.java | 99 +++++ .../out/entity/audit/EntityAuditDtoOut.java | 37 ++ .../entity/audit/EntitySnapshotDtoOut.java | 29 ++ .../entity/EntityAuditDtoOutMapper.java | 37 ++ .../PostgresEntityAuditAdapter.java | 101 +++++ .../model/audit/CustomRevinfoRecord.java | 65 +++ .../model/audit/CustomRevisionEntity.java | 53 +++ .../model/audit/CustomRevisionListener.java | 48 +++ .../audit/UserIdentityProviderHolder.java | 32 ++ .../model/entity/EntityJpaEntity.java | 3 + .../model/entity/PropertyJpaEntity.java | 3 + .../model/entity/PropertyRulesJpaEntity.java | 3 + .../model/entity/RelationJpaEntity.java | 3 + .../EntityTemplateJpaEntity.java | 3 + .../PropertyDefinitionJpaEntity.java | 3 + .../RelationDefinitionJpaEntity.java | 3 + .../repository/JpaEntityRepository.java | 4 +- .../JpaEntityTemplateRepository.java | 6 +- .../repository/JpaRelationRepository.java | 6 +- src/main/resources/application-local.yml | 17 +- src/main/resources/application.yml | 1 + .../V4_1__create_envers_audit_schema.sql | 196 +++++++++ .../idp_core/AbstractIntegrationTest.java | 11 + .../api/controller/AuditControllerTest.java | 142 ++++++ .../EntityTemplateControllerTest.java | 6 +- .../audit/CustomRevisionListenerTest.java | 189 ++++++++ .../db/test/R__1_Insert_test_data.sql | 10 +- .../test/R__2_Insert_entities_test_data.sql | 8 +- .../audit/v1/getAudit_200_history_create.json | 10 + .../audit/v1/getAudit_200_history_update.json | 9 + 43 files changed, 2000 insertions(+), 15 deletions(-) create mode 100644 docs/src/concepts/audit.md create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityAuditInfo.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/audit/EntityAuditPort.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfiguration.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/exception/MockSecurityConfigurationException.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntityAuditDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntitySnapshotDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityAuditDtoOutMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapter.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevinfoRecord.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionEntity.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListener.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java create mode 100644 src/main/resources/db/migration/V4_1__create_envers_audit_schema.sql create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java create mode 100644 src/test/resources/integration_test/json/audit/v1/getAudit_200_history_create.json create mode 100644 src/test/resources/integration_test/json/audit/v1/getAudit_200_history_update.json diff --git a/docs/src/concepts/audit.md b/docs/src/concepts/audit.md new file mode 100644 index 00000000..2d554914 --- /dev/null +++ b/docs/src/concepts/audit.md @@ -0,0 +1,404 @@ +--- +title: Audit +description: Track changes over time with comprehensive audit history and revision tracking +--- + +IDP-Core provides comprehensive audit tracking for all changes, enabling compliance, debugging, and historical analysis. +The audit mechanism uses Hibernate to maintain a complete revision history of every modification. + +## Overview + +Audit history captures every change to entity, entity template, properties throughout its lifecycle, including +creation, updates, and deletion. This information is essential for: + +- **Compliance and Regulatory Requirements** - Maintain immutable audit trails for regulatory compliance +- **Change Tracking and Audit mechanism** - Know who changed what and when +- **Debugging and Root Cause Analysis** - Trace issues back to their origins +- **Historical Reconstruction** - Restore object state at any point in time + +Example audit flow for entity lifecycle: + +```mermaid +flowchart LR + subgraph Entity["Entity Lifecycle"] + direction TB + C["CREATE"] + U["UPDATE"] + D["DELETE"] + C -->|user: alice| U + U -->|user: bob| U + U -->|user: charlie| D + end + + subgraph Audit["Audit Trail"] + direction TB + R1["Revision 1: CREATED"] + R2["Revision 2: UPDATED"] + R3["Revision 3: UPDATED"] + R4["Revision 4: DELETED"] + R1 -.->|timestamp, user| R2 + R2 -.->|timestamp, user| R3 + R3 -.->|timestamp, user| R4 + end + + Entity -->|tracks changes| Audit +``` + +--- + +## How Audit Works + +### Automatic Tracking + +When you create, update, or delete any object, IDP-Core automatically records: + +- **Revision Number** - Sequential identifier for each change +- **Timestamp** - When the change occurred +- **Revision Type** - The operation performed (CREATED, UPDATED, or DELETED) +- **Modified By** - The user who made the change +- **Entity Snapshot** - The object state at that moment + +The audit system is transparent—no special configuration is needed. Every operation is tracked automatically using +Hibernate . + +### Storage + +Audit data is stored separately from current entity data in dedicated audit tables: + +- `entity_jpa_entity_aud` - Audit history of entity changes +- `envers_transaction_log` - Revision metadata including user information +- `revinfo` - Additional revision information + +This separation ensures: + +- **No impact on queries** - Current data queries perform as normal +- **Immutable audit trail** - Historical data cannot be modified +- **Flexible retention** - Audit data can be managed independently + +--- + +## Retrieving Audit History + +### API Endpoint + +Retrieve the complete audit history for an entity: + +```text +GET /api/v1/audit/entities/{templateIdentifier}/{entityIdentifier} +``` + +### Path Parameters + +| Parameter | Type | Description | +|----------------------|--------|---------------------------------------| +| `templateIdentifier` | String | The template identifier of the entity | +| `entityIdentifier` | String | The unique identifier of the entity | + +### Response + +The response is an array of audit entries, ordered by revision number (newest first): + +```json +[ + { + "revision_number": 3, + "revision_date": "2026-06-10T14:35:22.500Z", + "revision_type": "DELETED", + "modified_by": "alice@example.com", + "snapshot": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "template_identifier": "web-service", + "identifier": "my-service", + "name": "My Web Service" + } + }, + { + "revision_number": 2, + "revision_date": "2026-06-10T14:30:15.300Z", + "revision_type": "UPDATED", + "modified_by": "bob@example.com", + "snapshot": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "template_identifier": "web-service", + "identifier": "my-service", + "name": "My Web Service Updated" + } + }, + { + "revision_number": 1, + "revision_date": "2026-06-10T14:20:00.000Z", + "revision_type": "CREATED", + "modified_by": "charlie@example.com", + "snapshot": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "template_identifier": "web-service", + "identifier": "my-service", + "name": "My Web Service" + } + } +] +``` + +### Response Fields + +| Field | Type | Description | +|--------------------------------|---------|---------------------------------------------------------| +| `revision_number` | Number | Unique sequential identifier of the revision | +| `revision_date` | Instant | ISO 8601 timestamp when the revision was created | +| `revision_type` | String | Type of operation: CREATED, UPDATED, or DELETED | +| `modified_by` | String | User identifier or email who performed the modification | +| `snapshot` | Object | Entity state at the time of this revision | +| `snapshot.id` | UUID | Unique identifier of the entity | +| `snapshot.template_identifier` | String | Template identifier | +| `snapshot.identifier` | String | Entity identifier (business key) | +| `snapshot.name` | String | Entity name | + +### Response Codes + +| Code | Description | +|-------|-----------------------------------------| +| `200` | Audit history retrieved successfully | +| `400` | Invalid template or entity identifier | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template or entity not found | +| `500` | Unexpected server error | + +### Example Requests + +=== "Retrieve Entity Audit History" + +```bash +curl -X GET http://localhost:8084/api/v1/audit/entities/web-service/my-service \ + -H "Authorization: Bearer " +``` + +=== "Using cURL with filters" + +```bash +# Get audit history for a specific entity +curl -s http://localhost:8084/api/v1/audit/entities/web-service/my-service | jq '.' +``` + +--- + +## Audit History Features + +### Complete Lifecycle Tracking + +The audit system tracks all stages of an entity's lifecycle: + +#### Entity Creation + +When you create an entity, a CREATED revision is recorded: + +```json +{ + "revision_type": "CREATED", + "modified_by": "user@example.com", + "revision_date": "2026-06-10T14:20:00.000Z", + "snapshot": { + "identifier": "my-service", + "name": "My Web Service" + } +} +``` + +#### Entity Updates + +Each update to an entity generates an UPDATED revision: + +```json +{ + "revision_type": "UPDATED", + "modified_by": "another-user@example.com", + "revision_date": "2026-06-10T14:30:15.300Z", + "snapshot": { + "identifier": "my-service", + "name": "My Web Service Updated" + } +} +``` + +#### Entity Deletion + +When an entity is deleted, a DELETED revision is recorded: + +```json +{ + "revision_type": "DELETED", + "modified_by": "admin@example.com", + "revision_date": "2026-06-10T14:35:22.500Z", + "snapshot": { + "identifier": "my-service", + "name": "My Web Service Updated" + } +} +``` + +Information: Even after deletion, the audit history remains accessible. This allows you to retrieve the complete lifecycle of any +entity, including deleted ones. + +### User Attribution + +Every change in the audit trail is associated with the user who performed it. The `modified_by` field contains: + +- **Standard Users** - The authenticated user's identifier or email +- **System Operations** - The value "system" for internal operations + +This enables accountability and helps trace who made specific changes. + +### Timestamp Precision + +Each revision includes an ISO 8601 timestamp (`revision_date`) with millisecond precision, making it possible to: + +- Correlate changes with other system events +- Establish exact chronological order of modifications +- Support regulatory compliance requirements + +### Entity Snapshot + +Each revision includes a snapshot of the entity's state at that moment, containing: + +- `id` - The unique database identifier +- `template_identifier` - Which template the entity instantiates +- `identifier` - The business identifier (user-facing key) +- `name` - The entity name + +> [!WARNING] +> The snapshot contains only core entity metadata. For complete property and relation state at a revision, you may need +> to reconstruct from the historical data stored in audit tables. + +--- + +## Audit and Entity Deletion + +The audit system preserves audit history even after entity deletion. + +### Retrieving History for Deleted Entities + +You can retrieve the complete audit history for a deleted entity by calling the audit endpoint with its original +identifiers: + +```bash +curl -X GET http://localhost:8084/api/v1/audit/entities/web-service/deleted-entity +``` + +The audit will include the DELETED revision and all previous CREATED and UPDATED revisions. + +### Why This Matters + +Maintaining audit trails for deleted entities is crucial for: + +- **Compliance** - Regulatory requirements often mandate keeping deletion records +- **Debugging** - Understanding what data existed and when +- **Recovery** - Reconstructing entities if needed for investigation or recovery + +--- + +## Technical Implementation + +### Hibernate + +The audit mechanism uses **Hibernate **, an open source tool that provides: + +- Automatic change tracking via JPA events +- Revision metadata management +- Efficient historical data storage +- Transaction-level consistency + +### Custom Revision Entity + +IDP-Core uses a custom revision entity (`CustomRevisionEntity`) that tracks: + +- **Revision Number** - Sequential identifier +- **Timestamp** - Change timestamp +- **Authentication ID** - User information from the Spring Security context + +The custom revision listener automatically populates the `auth_id` field from the currently authenticated user. + +### Audit Tables + +Each audited entity generates an audit table with the suffix `_aud`. For example: + +- Entity table: `entity_jpa_entity` +- Audit table: `entity_jpa_entity_aud` + +Audit tables store historical versions of every column with additional hibernate columns: + +- `REV` - Revision number +- `REVTYPE` - Revision type (0=ADD, 1=MOD, 2=DEL) + +### Performance Considerations + +The audit system is designed for efficiency: + +- **Minimal Query Impact** - Current entity queries are not affected by audit tracking +- **Optimized Storage** - Audit tables use efficient columnar storage +- **Index Support** - Audit queries include proper indexes for performance +- **Optional Cleanup** - Old audit data can be archived or purged based on retention policies + +--- + +## Use Cases + +### Compliance Auditing + +Track all changes to entities for compliance with regulations like GDPR, SOC 2, or industry standards: + +```bash +# Retrieve full history for audit purposes +curl -X GET \ + http://localhost:8084/api/v1/audit/entities/service-catalog/critical-service +``` + +### Debugging Changes + +Identify when a specific entity changed and who made the modification: + +```bash +# Get audit history to understand the sequence of changes +curl -X GET \ + http://localhost:8084/api/v1/audit/entities/web-service/production-api | jq '.[] | {revision_type, modified_by, revision_date}' +``` + +Output: + +```json +{ + "revision_type": "UPDATED", + "modified_by": "alice@example.com", + "revision_date": "2026-06-10T14:35:22.500Z" +} +{ + "revision_type": "CREATED", + "modified_by": "bob@example.com", + "revision_date": "2026-06-10T14:20:00.000Z" +} +``` + +### Change Notification + +Use audit endpoints in workflows to: + +- Notify team members of entity changes +- Trigger automation based on specific revision types +- Generate change reports + +### Historical Analysis + +Analyze how entities evolved over time: + +```bash +# Get the complete evolution of an entity +curl -s http://localhost:8084/api/v1/audit/entities/web-service/my-service | \ + jq 'reverse | .[] | {rev: .revision_number, type: .revision_type, date: .revision_date, user: .modified_by}' +``` + +--- + +## Next Steps + +- **[Entities](entities.md)** - Entity structure and lifecycle +- **[Properties](properties.md)** - Property types and validation +- **[Relations](relations.md)** - Entity relationships diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md index 1653c079..7ff6f3c2 100644 --- a/docs/src/concepts/index.md +++ b/docs/src/concepts/index.md @@ -1,9 +1,9 @@ --- title: Core Concepts -description: Understand the fundamental concepts of IDP-Core - Entity Templates, Entities, Properties, Relations +description: Understand the fundamental concepts of IDP-Core - Entity Templates, Entities, Properties, Relations, and Audit tracking --- -IDP-Core sits at the center of a flexible, runtime-configurable data model. This section explains the fundamental concepts you need to understand. +IDP-Core sits at the center of a flexible, runtime-configurable data model. This section explains the fundamental concepts you need to understand, including entity management and comprehensive audit tracking. ## Overview @@ -55,6 +55,12 @@ graph TB Query entities by attributes, property values, and relations using the filter DSL. +- 📜 **[Audit](audit.md)** + + --- + + Track all changes over time with comprehensive revision history and user attribution. + --- diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index c856af84..b5769943 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -13,6 +13,8 @@ tags: description: Operations related to entity management - name: Entities Templates Management description: Operations related to entity template management + - name: Audit + description: Operations related to audit history paths: /api/v1/entity-templates/{identifier}: get: @@ -461,6 +463,59 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/audit/entities/{templateIdentifier}/{entityIdentifier}: + get: + tags: + - Entities Audit + summary: Get entity audit history + description: Retrieve the complete audit history for a specific entity, + including all revisions with timestamps and modification types + operationId: getEntityAuditHistory + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + responses: + "200": + description: Successfully retrieved entity audit history + content: + "*/*": + schema: + type: array + items: + $ref: "#/components/schemas/EntityAuditDtoOut" + "400": + description: Invalid template or entity identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Unauthorized - Missing or invalid token + "403": + description: Insufficient rights + "404": + description: Template not found with the provided identifier or Entity not found + with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Unexpected server-side failure + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" components: schemas: EntityTemplateUpdateDtoIn: @@ -949,6 +1004,51 @@ components: format: int32 empty: type: boolean + EntityAuditDtoOut: + type: object + description: Audit information for an entity revision + properties: + revision_number: + type: number + description: Unique revision number in the audit log + example: 42 + revision_date: + type: string + format: date-time + description: Timestamp when the revision was created + example: 2026-06-08T14:37:27.743Z + revision_type: + type: string + description: Type of operation performed (CREATED, UPDATED, DELETED) + example: UPDATED + modified_by: + type: string + description: Identifier of the user who performed the modification + example: user@example.com + snapshot: + $ref: "#/components/schemas/EntitySnapshotDtoOut" + description: Snapshot of the entity state at this revision + EntitySnapshotDtoOut: + type: object + description: Snapshot of entity state + properties: + id: + type: string + format: uuid + description: Unique identifier + example: 550e8400-e29b-41d4-a716-446655440000 + template_identifier: + type: string + description: Template identifier + example: web-service + name: + type: string + description: Entity name + example: My Service + identifier: + type: string + description: Entity identifier + example: my-service-api securitySchemes: clientId: type: oauth2 diff --git a/pom.xml b/pom.xml index 691826e5..a2bc1be6 100644 --- a/pom.xml +++ b/pom.xml @@ -230,6 +230,15 @@ spring-boot-starter-actuator + + + org.hibernate.orm + hibernate-envers + + + org.springframework.data + spring-data-envers + diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityAuditInfo.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityAuditInfo.java new file mode 100644 index 00000000..499f493f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityAuditInfo.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.time.Instant; +import java.util.UUID; + +/// Domain model representing audit information for an [Entity] revision. +/// +/// **Business purpose:** Tracks when and who modified an entity throughout its lifecycle. +/// This information is essential for: +/// - Compliance and regulatory requirements +/// - Change tracking and auditability +/// - Debugging and root cause analysis +/// - Historical reconstruction of entity state +/// +/// **Ubiquitous language:** An EntityAuditInfo represents a single point-in-time +/// snapshot of entity modification metadata, capturing the revision number, timestamp, +/// and the user responsible for the change. +/// +/// @param revisionNumber unique identifier of the revision in the audit log +/// @param revisionDate timestamp when the revision was created +/// @param revisionType type of operation performed (ADD, MOD, DEL) +/// @param modifiedBy identifier of the user who performed the modification +/// @param snapshot optional snapshot of the entity's state at the time of revision +public record EntityAuditInfo(Number revisionNumber, Instant revisionDate, String revisionType, + String modifiedBy, EntitySnapshot snapshot) { + // Inner record for the snapshot + public record EntitySnapshot(UUID id, String templateIdentifier, String name, String identifier) { + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/audit/EntityAuditPort.java b/src/main/java/com/decathlon/idp_core/domain/port/audit/EntityAuditPort.java new file mode 100644 index 00000000..bed038b0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/audit/EntityAuditPort.java @@ -0,0 +1,24 @@ +package com.decathlon.idp_core.domain.port.audit; + +import java.util.List; + +import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; + +/// Port interface for retrieving entity audit information. +/// +/// **Port contract:** Defines operations for accessing historical revision data +/// of entities. Implementations should interact with the audit storage system +/// (e.g., Hibernate Envers) to provide audit trail information. +/// +/// **Hexagonal architecture:** This is a **driven port** (outbound), implemented +/// by infrastructure adapters and used by domain services to access audit data. +public interface EntityAuditPort { + + /// Retrieves all audit revisions for a specific entity. + /// + /// @param templateIdentifier the template identifier of the entity + /// @param entityIdentifier the unique identifier of the entity + /// @return list of audit information ordered by revision number (newest first) + List getEntityAuditHistory(String templateIdentifier, String entityIdentifier); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java new file mode 100644 index 00000000..a5cdd360 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityAuditService.java @@ -0,0 +1,50 @@ +package com.decathlon.idp_core.domain.service.entity; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; +import com.decathlon.idp_core.domain.port.audit.EntityAuditPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; + +import lombok.RequiredArgsConstructor; + +/// Domain service for retrieving entity audit information. +/// +/// **Business purpose:** Provides access to the audit trail of entities, +/// enabling compliance, debugging, and historical analysis. This service +/// orchestrates audit data retrieval while ensuring template existence +/// validation. +/// +/// **Key responsibilities:** +/// - Retrieve audit history for entities including deleted ones +/// - Validate template existence before returning audit data +/// - Transform technical audit data into business-meaningful information +@Service +@RequiredArgsConstructor +public class EntityAuditService { + + private final EntityAuditPort entityAuditPort; + private final EntityTemplateService entityTemplateService; + + /// Retrieves the complete audit history for a specific entity. + /// + /// **Business rule:** The template must exist to retrieve entity audit history. + /// This method allows retrieving audit history for deleted entities as well, + /// since the audit trail is stored independently. + /// + /// @param templateIdentifier the template identifier of the entity + /// @param entityIdentifier the unique identifier of the entity + /// @return list of audit information ordered by revision number (newest first) + /// @throws + /// com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException + /// if the template does not exist + @Transactional(readOnly = true) + public List getEntityAuditHistory(String templateIdentifier, + String entityIdentifier) { + entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier); + return entityAuditPort.getEntityAuditHistory(templateIdentifier, entityIdentifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java new file mode 100644 index 00000000..875b63b0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java @@ -0,0 +1,65 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import java.util.Optional; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +/// UnifiedUserProvider is a Spring component that implements the UserIdentityProvider interface to provide a consistent way +/// to retrieve the authenticated user's identity across different authentication mechanisms (JWT, OAuth2, OpenID). +/// It checks the current security context for the authentication type and extracts the user ID accordingly: +/// - For JWT authentication, it retrieves the subject (sub) claim from the JWT token. +/// - For OAuth2 authentication, it first checks if the user is an OIDC user to +/// retrieve the subject, otherwise it looks for a "sub" or "id" attribute in the OAuth2 user attributes, falling back to the authentication name if neither is found. +/// - For basic authentication, it simply returns the authentication name. +@Component +public class UnifiedUserProvider implements UserIdentityProvider { + + @Override + public String getAuthId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return "UNKNOWN"; + } + + // Jwt Case + if (authentication instanceof JwtAuthenticationToken jwtToken) { + return jwtToken.getToken().getSubject(); + } + + // OAuth2 and OpenId case + if (authentication.getPrincipal()instanceof OAuth2User oauth2Token) { + + if (oauth2Token instanceof OidcUser oidcUser) { + return oidcUser.getSubject(); + } + + return Optional.ofNullable(oauth2Token.getAttribute("sub")).map(Object::toString) + .orElseGet(() -> Optional.ofNullable(oauth2Token.getAttribute("id")).map(Object::toString) + .orElse(authentication.getName())); + } + + // Basic Auth case + return authentication.getName(); + } + + @Override + public String getName() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // Guard against unauthenticated/null context to prevent NullPointerExceptions + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return "UNKNOWN"; + } + + return authentication.getName(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java new file mode 100644 index 00000000..3ec29a3b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java @@ -0,0 +1,6 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +public interface UserIdentityProvider { + String getAuthId(); + String getName(); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfiguration.java new file mode 100644 index 00000000..e0b8fd41 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfiguration.java @@ -0,0 +1,143 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.mock; + +import java.io.IOException; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import jakarta.annotation.Nonnull; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.decathlon.idp_core.infrastructure.adapters.api.auth.mock.exception.MockSecurityConfigurationException; + +/// Local mock security configuration that mirrors OAuth2/JWT behavior for local development. +/// +/// **Purpose:** Allows local development without a real OAuth2/JWT provider. +/// Enabled only when `app.security.mock-enabled=true`. +/// +/// **Mock JWT Token Details:** +/// - Subject (sub): "local-developer" +/// - Client ID (client_id): "client-credentials" +/// - Scopes: auth, read, write +/// - Issued at: Current time +/// - Expires in: 1 hour +/// - Additional claims: Mock user information +/// +@Configuration +@EnableWebSecurity +@ConditionalOnProperty(name = "app.security.mock-enabled", havingValue = "true") +public class MockSecurityConfiguration { + + /// Filter that injects mock JWT authentication into SecurityContext. + /// + /// **Behavior:** + /// - Creates a JwtAuthenticationToken from the mock JWT token + /// - Sets it in the SecurityContextHolder for the current request + /// - Allows downstream code to access authentication details normally + /// + /// **Why a filter:** Ensures authentication is set early in the request cycle, + /// making it available to all downstream components (controllers, services, + /// etc.) + static class MockJwtAuthenticationFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(@Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response, @Nonnull FilterChain filterChain) + throws ServletException, IOException { + // Create mock JWT and authentication token + Jwt mockJwt = createMockJwt(); + Collection authorities = createMockAuthorities(); + Authentication authentication = new JwtAuthenticationToken(mockJwt, authorities); + + // Set in SecurityContext for this request + SecurityContextHolder.getContext().setAuthentication(authentication); + + try { + filterChain.doFilter(request, response); + } finally { + // Clean up SecurityContext after request + SecurityContextHolder.clearContext(); + } + } + /// Creates a mock JWT token with standard OAuth2 claims for local development. + /// + /// **Mock token details:** + /// - sub: "local-developer" - The subject/principal + /// - client_id: "client-credentials" - OAuth2 client identifier + /// - scope: "auth read write" - Space-separated scopes + /// - iat: current time - Issued at timestamp + /// - exp: current time + 3600 - Expires in 1 hour + /// + /// @return Mock JWT token ready for authentication + private Jwt createMockJwt() { + Instant now = Instant.now(); + Instant expiresAt = now.plusSeconds(3600); + + Map headers = Map.of("alg", "RS256", "typ", "JWT"); + + Map claims = Map.of("sub", "local-developer", "client_id", "client-id", + "scope", "auth read write", "iat", now.getEpochSecond(), "exp", + expiresAt.getEpochSecond(), "user_id", "dev-user-001", "email", "developer@local.dev"); + + return new Jwt("mock-token-value", now, expiresAt, headers, claims); + } + + /// Creates mock authorities for the authenticated principal. + /// + /// **Mock authorities:** + /// - ROLE_USER: Standard user role + /// - ROLE_API_CLIENT: API client role for programmatic access + /// + /// @return Collection of granted authorities + private Collection createMockAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER"), + new SimpleGrantedAuthority("ROLE_API_CLIENT")); + } + + } + + /// Security filter chain for local mocking with JWT-like behavior. + /// + /// **Configuration:** + /// - Session: Stateless (CSRF protection not needed for token-based + /// authentication) + /// - Authorization: All requests permitted (mock authentication injected by + /// filter) + /// - Custom filter: Adds MockJwtAuthenticationFilter before + /// AnonymousAuthenticationFilter + /// + /// @param http HttpSecurity to configure + /// @return Configured security filter chain + @Bean + public SecurityFilterChain securityFilterChainMock(HttpSecurity http) { + try { + http.sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authMocked -> authMocked.anyRequest().permitAll()) + .addFilterBefore(new MockJwtAuthenticationFilter(), + org.springframework.security.web.authentication.AnonymousAuthenticationFilter.class); + } catch (Exception e) { + throw new MockSecurityConfigurationException("Failed to configure mock security filter chain", + e); + } + return http.build(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/exception/MockSecurityConfigurationException.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/exception/MockSecurityConfigurationException.java new file mode 100644 index 00000000..a34a88bb --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/exception/MockSecurityConfigurationException.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.mock.exception; + +/// Infrastructure exception for mock security configuration failures. +/// +/// **Purpose:** Raised when the mock security filter chain configuration fails during +/// initialization. This typically indicates issues with Spring Security bean setup or +/// filter chain assembly in the mock authentication environment. +/// +/// **Why this exception exists:** +/// - Provides specific, meaningful error context for security configuration failures +/// - Distinguishes infrastructure setup errors from generic failures +/// - Improves debugging by clearly indicating the mock security layer as the source +/// - Follows infrastructure layer pattern of throwing specific exceptions for +/// technical concerns +/// +/// **When to throw:** +/// - When HttpSecurity configuration operations fail in mock security setup +/// - During MockJwtAuthenticationFilter chain initialization +/// +public class MockSecurityConfigurationException extends RuntimeException { + + /// Constructs a new exception with a message and cause. + /// + /// @param message descriptive message about the configuration failure + /// @param cause the underlying exception that caused this failure + public MockSecurityConfigurationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 8a492d21..c2c6c671 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -4,6 +4,7 @@ import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,12 +25,13 @@ /// /// **Infrastructure specifics:** /// - CORS origins externalized via `spring.web.cors.allowed-origins` in `application.yml` -/// - JWT resource server auto-configured with Spring Security OAuth2 +/// - JWT resource server autoconfigured with Spring Security OAuth2 /// - Security filter chain processes authentication before reaching controllers @Configuration @EnableWebSecurity @EnableConfigurationProperties(CorsProperties.class) +@ConditionalOnProperty(name = "app.security.mock-enabled", havingValue = "false", matchIfMissing = true) public class SecurityConfiguration { private final CorsProperties corsProperties; 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 117d88a8..c9462a8e 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 @@ -71,6 +71,11 @@ public class SwaggerDescription { 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."; + /// Entity Audit API endpoint constants + public static final String ENDPOINT_GET_ENTITY_AUDIT_SUMMARY = "Get entity audit history"; + public static final String ENDPOINT_GET_ENTITY_AUDIT_DESCRIPTION = "Retrieve the complete audit history for a specific entity, including all revisions with timestamps and modification types"; + public static final String RESPONSE_ENTITY_AUDIT_SUCCESS = "Successfully retrieved entity audit history"; + /// 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)"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java new file mode 100644 index 00000000..2a859901 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java @@ -0,0 +1,99 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_AUDIT_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_AUDIT_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_AUDIT_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; +import static org.springframework.http.HttpStatus.OK; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; + +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; +import com.decathlon.idp_core.domain.service.entity.EntityAuditService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.EntityAuditDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityAuditDtoOutMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +/// REST API adapter providing audit endpoints. +/// +/// **Infrastructure specifics:** +/// - Exposes HTTP endpoints for retrieving audit history of any objects +/// - Handles REST API request/response mapping between DTOs and domain models +/// - Integrates with OpenAPI/Swagger for API documentation +/// - Maps domain exceptions to appropriate HTTP status codes +/// +/// **Separation of concerns:** This controller is dedicated solely to audit operations, +/// keeping the other controller focused on CRUD operations. This follows the Single +/// Responsibility Principle. +@RestController +@RequestMapping("/api/v1/audit/") +@Tag(name = "Audit", description = "Operations related to audit history") +@Validated +@RequiredArgsConstructor +public class AuditController { + + private final EntityAuditService entityAuditService; + private final EntityAuditDtoOutMapper entityAuditDtoOutMapper; + + /// Retrieves the complete audit history for a specific entity. + /// + /// **API contract:** Returns a list of all revisions for the entity, ordered by + /// revision number (newest first). Each revision includes the timestamp, type + /// of + /// operation (CREATED, UPDATED, DELETED), and the user who performed the + /// change. + /// + /// @param templateIdentifier the template identifier of the entity + /// @param entityIdentifier the unique identifier of the entity + /// @return list of audit information DTOs for HTTP response + @Operation(summary = ENDPOINT_GET_ENTITY_AUDIT_SUMMARY, description = ENDPOINT_GET_ENTITY_AUDIT_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_AUDIT_SUCCESS, content = { + @Content(array = @ArraySchema(schema = @Schema(implementation = EntityAuditDtoOut.class)))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid template or entity identifier", content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER + + " or " + RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @GetMapping("entities/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public List getEntityAuditHistory( + @NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier) { + + List auditHistory = entityAuditService + .getEntityAuditHistory(templateIdentifier, entityIdentifier); + return entityAuditDtoOutMapper.fromEntityAuditInfoList(auditHistory); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntityAuditDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntityAuditDtoOut.java new file mode 100644 index 00000000..b1a5c45d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntityAuditDtoOut.java @@ -0,0 +1,37 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit; + +import java.time.Instant; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/// Output DTO for entity audit information exposed via REST API. +/// +/// **Infrastructure responsibility:** Serializes audit data for HTTP responses +/// using JSON with snake_case naming convention. +@Data +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "Audit information for an entity revision") +public class EntityAuditDtoOut { + + @Schema(description = "Unique revision number in the audit log", example = "42") + private Number revisionNumber; + + @Schema(description = "Timestamp when the revision was created", example = "2026-06-08T14:37:27.743Z") + private Instant revisionDate; + + @Schema(description = "Type of operation performed (CREATED, UPDATED, DELETED)", example = "UPDATED") + private String revisionType; + + @Schema(description = "Identifier of the user who performed the modification", example = "user@example.com") + private String modifiedBy; + + @Schema(description = "Snapshot of the entity state at this revision") + private EntitySnapshotDtoOut snapshot; + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntitySnapshotDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntitySnapshotDtoOut.java new file mode 100644 index 00000000..270605ec --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntitySnapshotDtoOut.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit; + +import java.util.UUID; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/// Nested DTO for entity snapshot. +@Data +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "Snapshot of entity state") +public class EntitySnapshotDtoOut { + @Schema(description = "Unique identifier", example = "550e8400-e29b-41d4-a716-446655440000") + private UUID id; + + @Schema(description = "Template identifier", example = "web-service") + private String templateIdentifier; + + @Schema(description = "Entity name", example = "My Service") + private String name; + + @Schema(description = "Entity identifier", example = "my-service-api") + private String identifier; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityAuditDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityAuditDtoOutMapper.java new file mode 100644 index 00000000..6124125f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityAuditDtoOutMapper.java @@ -0,0 +1,37 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.EntityAuditDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.EntitySnapshotDtoOut; + +@Component +public class EntityAuditDtoOutMapper { + + public EntityAuditDtoOut fromEntityAuditInfo(EntityAuditInfo auditInfo) { + if (auditInfo == null) { + return null; + } + + EntitySnapshotDtoOut snapshotDto = null; + if (auditInfo.snapshot() != null) { + snapshotDto = EntitySnapshotDtoOut.builder().id(auditInfo.snapshot().id()) + .templateIdentifier(auditInfo.snapshot().templateIdentifier()) + .name(auditInfo.snapshot().name()).identifier(auditInfo.snapshot().identifier()).build(); + } + + return EntityAuditDtoOut.builder().revisionNumber(auditInfo.revisionNumber()) + .revisionDate(auditInfo.revisionDate()).revisionType(auditInfo.revisionType()) + .modifiedBy(auditInfo.modifiedBy()).snapshot(snapshotDto).build(); + } + + public List fromEntityAuditInfoList(List auditInfoList) { + if (auditInfoList == null) { + return List.of(); + } + return auditInfoList.stream().map(this::fromEntityAuditInfo).toList(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapter.java new file mode 100644 index 00000000..6a5c7467 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapter.java @@ -0,0 +1,101 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.hibernate.envers.AuditReader; +import org.hibernate.envers.AuditReaderFactory; +import org.hibernate.envers.RevisionType; +import org.hibernate.envers.query.AuditEntity; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; +import com.decathlon.idp_core.domain.port.audit.EntityAuditPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit.CustomRevisionEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PostgresEntityAuditAdapter implements EntityAuditPort { + + private final EntityManager entityManager; + private final JpaEntityRepository jpaEntityRepository; + + @Override + public List getEntityAuditHistory(String templateIdentifier, + String entityIdentifier) { + UUID entityId = getEntityId(templateIdentifier, entityIdentifier); + + AuditReader auditReader = AuditReaderFactory.get(entityManager); + + @SuppressWarnings("unchecked") + List revisions = auditReader.createQuery() + .forRevisionsOfEntity(EntityJpaEntity.class, false, true).add(AuditEntity.id().eq(entityId)) + .addOrder(AuditEntity.revisionNumber().desc()).getResultList(); + + return revisions.stream().map(this::mapToEntityAuditInfo).toList(); + } + + private UUID getEntityId(String templateIdentifier, String entityIdentifier) { + + var entity = jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier); + if (entity.isPresent()) { + return entity.get().getId(); + } + + AuditReader auditReader = AuditReaderFactory.get(entityManager); + + @SuppressWarnings("unchecked") + List revisions = auditReader.createQuery() + .forRevisionsOfEntity(EntityJpaEntity.class, false, true).getResultList(); + + for (Object[] revision : revisions) { + EntityJpaEntity auditedEntity = (EntityJpaEntity) revision[0]; + if (auditedEntity != null && templateIdentifier.equals(auditedEntity.getTemplateIdentifier()) + && entityIdentifier.equals(auditedEntity.getIdentifier())) { + return auditedEntity.getId(); + } + } + + throw new IllegalArgumentException("Entity not found in current or audit data: " + + templateIdentifier + "/" + entityIdentifier); + } + + private EntityAuditInfo mapToEntityAuditInfo(Object[] revision) { + // Cast to your specific entities + EntityJpaEntity entitySnapshot = (EntityJpaEntity) revision[0]; + CustomRevisionEntity revisionEntity = (CustomRevisionEntity) revision[1]; + RevisionType revisionType = (RevisionType) revision[2]; + + // Extract Metadata + Number revisionNumber = revisionEntity.getRev(); + Instant revisionDate = Instant.ofEpochMilli(revisionEntity.getTimestamp()); + String revisionTypeStr = mapRevisionType(revisionType); + String modifiedBy = revisionEntity.getAuthId() != null ? revisionEntity.getAuthId() : "system"; + + // Map JPA Snapshot to Domain Snapshot + EntityAuditInfo.EntitySnapshot snapshot = null; + if (entitySnapshot != null) { + snapshot = new EntityAuditInfo.EntitySnapshot(entitySnapshot.getId(), + entitySnapshot.getTemplateIdentifier(), entitySnapshot.getName(), + entitySnapshot.getIdentifier()); + } + + return new EntityAuditInfo(revisionNumber, revisionDate, revisionTypeStr, modifiedBy, snapshot); + } + + private String mapRevisionType(RevisionType revisionType) { + return switch (revisionType) { + case ADD -> "CREATED"; + case MOD -> "UPDATED"; + case DEL -> "DELETED"; + }; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevinfoRecord.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevinfoRecord.java new file mode 100644 index 00000000..d9a4ac09 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevinfoRecord.java @@ -0,0 +1,65 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import java.util.Objects; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +@Embeddable +public class CustomRevinfoRecord { + + @Column(name = "id", nullable = false) + private UUID id; + + @Column(name = "audit_table", nullable = false) + private String auditTable; + + @Column(name = "revtstmp", nullable = false) + private long revtstmp; + + @Column(name = "auth_id") + private String authId; + + public CustomRevinfoRecord() { + } + + public CustomRevinfoRecord(UUID id, String auditTable, long revtstmp, String authId) { + this.id = id; + this.auditTable = auditTable; + this.revtstmp = revtstmp; + this.authId = authId; + } + + // Getters + public UUID getId() { + return id; + } + + public String getAuditTable() { + return auditTable; + } + + public long getRevtstmp() { + return revtstmp; + } + + public String getAuthId() { + return authId; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + CustomRevinfoRecord that = (CustomRevinfoRecord) o; + return Objects.equals(id, that.id) && Objects.equals(auditTable, that.auditTable); + } + + @Override + public int hashCode() { + return Objects.hash(id, auditTable); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionEntity.java new file mode 100644 index 00000000..c82f1ce1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionEntity.java @@ -0,0 +1,53 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import jakarta.persistence.*; + +import org.hibernate.envers.RevisionEntity; +import org.hibernate.envers.RevisionNumber; +import org.hibernate.envers.RevisionTimestamp; + +@Entity +@Table(name = "envers_transaction_log") +@RevisionEntity(CustomRevisionListener.class) +public class CustomRevisionEntity { + + @Id + @SequenceGenerator(name = "envers_seq_gen", sequenceName = "envers_seq", allocationSize = 50) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "envers_seq_gen") + @RevisionNumber + @Column(name = "rev") + private long rev; + + @RevisionTimestamp + @Column(name = "revtstmp") + private long timestamp; + + @Column(name = "auth_id") + private String authId; + + @ElementCollection + @CollectionTable(name = "revinfo", joinColumns = @JoinColumn(name = "rev")) + private Set customRecords = new HashSet<>(); + + public void addCustomRecord(UUID entityId, String auditTable) { + this.customRecords + .add(new CustomRevinfoRecord(entityId, auditTable, this.timestamp, this.authId)); + } + + public long getRev() { + return rev; + } + public long getTimestamp() { + return timestamp; + } + public String getAuthId() { + return authId; + } + public void setAuthId(String authId) { + this.authId = authId; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListener.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListener.java new file mode 100644 index 00000000..66157fe0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListener.java @@ -0,0 +1,48 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import java.util.UUID; + +import org.hibernate.envers.EntityTrackingRevisionListener; +import org.hibernate.envers.RevisionType; + +/** + * Custom revision listener for Hibernate Envers to capture user identity and + * affected entities. + *

+ * This listener is instantiated by Hibernate (not Spring), so it uses + * {@link UserIdentityProviderHolder} to access the Spring-managed + * {@link com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider}. + *

+ */ +public class CustomRevisionListener implements EntityTrackingRevisionListener { + + private static final String UNKNOWN_AUTH_ID = "Unknown"; + + @Override + public void newRevision(Object revisionEntity) { + var customRevisionEntity = (CustomRevisionEntity) revisionEntity; + var userIdentityProvider = UserIdentityProviderHolder.getUserIdentityProvider(); + + String authId = userIdentityProvider.getAuthId(); + if (authId == null || authId.isBlank()) { + authId = UNKNOWN_AUTH_ID; + } + + customRevisionEntity.setAuthId(authId); + } + + @Override + public void entityChanged(final Class entityClass, final String entityName, final Object entityId, + final RevisionType revisionType, final Object revisionEntity) { + var customRevisionEntity = (CustomRevisionEntity) revisionEntity; + + String auditTableName = entityClass.getSimpleName().replaceAll("([a-z])([A-Z]+)", "$1_$2") + .toLowerCase() + "_aud"; + + if (entityId instanceof UUID uuid) { + customRevisionEntity.addCustomRecord(uuid, auditTableName); + } else if (entityId instanceof String str) { + customRevisionEntity.addCustomRecord(UUID.fromString(str), auditTableName); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java new file mode 100644 index 00000000..23b3cc75 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; + +@Component +public class UserIdentityProviderHolder { + + private static UserIdentityProvider userIdentityProvider; + + //// Constructor to inject the UserIdentityProvider from Spring context + //// This will be called by Spring when the application context is initialized + //// The static field will be set with the Spring-managed bean, allowing to + //// Hibernate to access it + //// when it creates instances of CustomRevisionListener + /// Note: This approach relies on the fact that Spring will initialize the + //// context and set the provider + /// before any Hibernate Envers auditing occurs. If Hibernate tries to access + //// the provider before Spring sets it, an exception will be thrown. + public UserIdentityProviderHolder(UserIdentityProvider provider) { + UserIdentityProviderHolder.userIdentityProvider = provider; + } + + public static UserIdentityProvider getUserIdentityProvider() { + if (userIdentityProvider == null) { + throw new IllegalStateException( + "UserIdentityProviderHolder not initialized. Spring context may not be loaded."); + } + return userIdentityProvider; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 72aea570..0af430f5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -15,6 +15,8 @@ import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import org.hibernate.envers.Audited; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -24,6 +26,7 @@ @Data @Table(name = "entity", uniqueConstraints = { @UniqueConstraint(columnNames = {"identifier", "template_identifier"})}) +@Audited @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java index 961ac6d6..b6929ed5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java @@ -9,6 +9,8 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,6 +19,7 @@ @Entity @Data @Table(name = "property") +@Audited @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java index 4e0663a7..3b49fc84 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java @@ -10,6 +10,8 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + import com.decathlon.idp_core.domain.model.enums.PropertyFormat; import lombok.AllArgsConstructor; @@ -20,6 +22,7 @@ @Entity @Data @Table(name = "property_rules") +@Audited @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java index d135f1f2..950c5068 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java @@ -14,6 +14,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,6 +24,7 @@ @Entity @Data @Table(name = "relation") +@Audited @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java index 9588fc23..0e601e04 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java @@ -17,6 +17,8 @@ import jakarta.persistence.OrderBy; import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -30,6 +32,7 @@ @ToString @EqualsAndHashCode @Table(name = "entity_template") +@Audited @NoArgsConstructor @AllArgsConstructor public class EntityTemplateJpaEntity { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java index c11cfbb3..a787dfd5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java @@ -11,6 +11,8 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyRulesJpaEntity; @@ -24,6 +26,7 @@ @Data @EqualsAndHashCode(onlyExplicitlyIncluded = true) @Table(name = "property_definition") +@Audited @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java index 6310fb2e..fddd3131 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java @@ -8,6 +8,8 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -18,6 +20,7 @@ @Data @EqualsAndHashCode(onlyExplicitlyIncluded = true) @Table(name = "relation_definition") +@Audited @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 200b1c3f..4ac5c554 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.history.RevisionRepository; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -21,7 +22,8 @@ public interface JpaEntityRepository extends JpaRepository, - JpaSpecificationExecutor { + JpaSpecificationExecutor, + RevisionRepository { @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") List findByIdentifierIn(List identifiers); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java index 21b4218e..dee5f266 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java @@ -7,13 +7,17 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.history.RevisionRepository; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.EntityTemplateJpaEntity; @Repository -public interface JpaEntityTemplateRepository extends JpaRepository { +public interface JpaEntityTemplateRepository + extends + JpaRepository, + RevisionRepository { @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", "relationsDefinitions"}) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 13129fc3..08ce1522 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.history.RevisionRepository; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -12,7 +13,10 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; @Repository -public interface JpaRelationRepository extends JpaRepository { +public interface JpaRelationRepository + extends + JpaRepository, + RevisionRepository { @Query(""" SELECT tei AS targetEntityIdentifier, r.name AS relationName, e.identifier AS sourceEntityIdentifier, e.name AS sourceEntityName diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5b32be0b..a8d471b5 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,4 +1,3 @@ ---- server: port: 8084 spring: @@ -20,6 +19,22 @@ spring: hibernate: ddl-auto: none # Disable JPA schema auto-generation, use Flyway instead show-sql: false + security: + type: oauth2 + oauth2: + client: + registration: + idp-core: + client-id: local-client-id + client-secret: local-client-secret + provider: + idp-core: + token-uri: http://localhost:8080/auth/token + resourceserver: + jwt: + jwk-set-uri: http://localhost:8080/auth/.well-known/jwks.json app: + security: + mock-enabled: true full-refresh-at-startup: true idp-core-prefix-url: http://localhost:8084 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fac3c277..cecc5bc5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -85,6 +85,7 @@ spring: # works together with batch_size to reduce round-trips. order_inserts: true order_updates: true + envers.store_data_at_delete: true jdbc: # Number of statements grouped into one JDBC batch. # significantly reduces DB round-trips on bulk writes. diff --git a/src/main/resources/db/migration/V4_1__create_envers_audit_schema.sql b/src/main/resources/db/migration/V4_1__create_envers_audit_schema.sql new file mode 100644 index 00000000..7066bd20 --- /dev/null +++ b/src/main/resources/db/migration/V4_1__create_envers_audit_schema.sql @@ -0,0 +1,196 @@ +--- This migration creates the audit tables for Envers. It is designed to be compatible with the default Envers configuration, which uses a single revision table (envers_transaction_log) and a revinfo table that references it. The audit tables for entities, properties, relations, and templates are created with foreign keys referencing the revision number in the transaction log. +-- The revision number is generated using a sequence (envers_seq) that starts at 1 and increments by 50, which is a common configuration for Envers to allow for batch processing of revisions. +-- The revtype column in the audit tables indicates the type of revision (0 for addition, 1 for modification, 2 for deletion), which is standard in Envers to track the nature of changes. +-- Indexes are created on the revision number and other relevant columns to optimize query performance when retrieving audit data. +-- Note: The actual structure of the audit tables may need to be adjusted based on the specific entities and properties used in the application, but this provides a general framework for implementing Envers auditing in a PostgreSQL database. + +CREATE SEQUENCE envers_seq START WITH 1 INCREMENT BY 50; + +CREATE TABLE envers_transaction_log +( + rev BIGINT PRIMARY KEY DEFAULT nextval('envers_seq'), + revtstmp BIGINT NOT NULL, + auth_id VARCHAR(255) +); + +CREATE TABLE revinfo +( + id uuid NOT NULL, + rev BIGINT NOT NULL, + audit_table VARCHAR(255) NOT NULL, + revtstmp BIGINT NOT NULL, + auth_id VARCHAR(255), + primary key (id, rev, audit_table), + CONSTRAINT fk_custom_revinfo_envers FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_revinfo_timestamp ON revinfo (revtstmp); +CREATE INDEX idx_revinfo_auth_id ON revinfo (auth_id); +CREATE INDEX idx_revinfo_search ON revinfo (id, rev, audit_table); + +CREATE TABLE entity_aud +( + id UUID NOT NULL, + rev BIGINT NOT NULL, + revtype SMALLINT, + template_identifier VARCHAR(255), + name VARCHAR(255), + identifier VARCHAR(255), + PRIMARY KEY (id, rev), + CONSTRAINT fk_entity_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_entity_aud_rev ON entity_aud (rev); + +CREATE TABLE property_aud +( + id UUID NOT NULL, + rev BIGINT NOT NULL, + revtype SMALLINT, + name VARCHAR(255), + value VARCHAR(255), + PRIMARY KEY (id, rev), + CONSTRAINT fk_property_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_property_aud_rev ON property_aud (rev); + +CREATE TABLE relation_aud +( + id UUID NOT NULL, + rev BIGINT NOT NULL, + revtype SMALLINT, + name VARCHAR(255), + target_template_identifier VARCHAR(255), + PRIMARY KEY (id, rev), + CONSTRAINT fk_relation_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_relation_aud_rev ON relation_aud (rev); + +CREATE TABLE entity_template_aud +( + id UUID NOT NULL, + rev BIGINT NOT NULL, + revtype SMALLINT, + identifier VARCHAR(255), + name VARCHAR(255), + description TEXT, + PRIMARY KEY (id, rev), + CONSTRAINT fk_entity_template_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_entity_template_aud_rev ON entity_template_aud (rev); + +CREATE TABLE property_definition_aud +( + id UUID NOT NULL, + rev BIGINT NOT NULL, + revtype SMALLINT, + name VARCHAR(255), + description TEXT, + type VARCHAR(50), + required BOOLEAN, + rules_id UUID, + PRIMARY KEY (id, rev), + CONSTRAINT fk_property_definition_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_property_definition_aud_rev ON property_definition_aud (rev); + +CREATE TABLE property_rules_aud +( + id UUID NOT NULL, + rev BIGINT NOT NULL, + revtype SMALLINT, + format VARCHAR(50), + enum_values TEXT[], + regex VARCHAR(500), + max_length INTEGER, + min_length INTEGER, + max_value INTEGER, + min_value INTEGER, + PRIMARY KEY (id, rev), + CONSTRAINT fk_property_rules_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_property_rules_aud_rev ON property_rules_aud (rev); + +CREATE TABLE relation_definition_aud +( + id UUID NOT NULL, + rev BIGINT NOT NULL, + revtype SMALLINT, + name VARCHAR(255), + target_template_identifier VARCHAR(255), + required BOOLEAN, + to_many BOOLEAN, + PRIMARY KEY (id, rev), + CONSTRAINT fk_relation_definition_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_relation_definition_aud_rev ON relation_definition_aud (rev); + +CREATE TABLE entity_properties_aud +( + rev BIGINT NOT NULL, + revtype SMALLINT, + entity_id UUID NOT NULL, + property_id UUID NOT NULL, + PRIMARY KEY (rev, entity_id, property_id), + CONSTRAINT fk_entity_properties_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_entity_properties_aud_entity_id ON entity_properties_aud (entity_id); + +CREATE TABLE entity_relations_aud +( + rev BIGINT NOT NULL, + revtype SMALLINT, + entity_id UUID NOT NULL, + relation_id UUID NOT NULL, + PRIMARY KEY (rev, entity_id, relation_id), + CONSTRAINT fk_entity_relations_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_entity_relations_aud_entity_id ON entity_relations_aud (entity_id); + +CREATE TABLE relation_target_entities_aud +( + rev BIGINT NOT NULL, + revtype SMALLINT, + relation_id UUID NOT NULL, + target_entity_identifier VARCHAR(255) NOT NULL, + PRIMARY KEY (rev, relation_id, target_entity_identifier), + CONSTRAINT fk_relation_target_entities_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_relation_target_entities_aud_relation_id ON relation_target_entities_aud (relation_id); + +CREATE TABLE entity_template_properties_definitions_aud +( + rev BIGINT NOT NULL, + revtype SMALLINT, + entity_template_id UUID NOT NULL, + properties_definitions_id UUID NOT NULL, + PRIMARY KEY (rev, entity_template_id, properties_definitions_id), + CONSTRAINT fk_entity_template_properties_definitions_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) + ON DELETE CASCADE +); + +CREATE INDEX idx_entity_template_properties_definitions_aud_template_id + ON entity_template_properties_definitions_aud (entity_template_id); + +CREATE TABLE entity_template_relations_definitions_aud +( + rev BIGINT NOT NULL, + revtype SMALLINT, + entity_template_id UUID NOT NULL, + relations_definitions_id UUID NOT NULL, + PRIMARY KEY (rev, entity_template_id, relations_definitions_id), + CONSTRAINT fk_entity_template_relations_definitions_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) + ON DELETE CASCADE +); + +CREATE INDEX idx_entity_template_relations_definitions_aud_template_id + ON entity_template_relations_definitions_aud (entity_template_id); diff --git a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java index 00356b45..3fd1a16e 100644 --- a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java +++ b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java @@ -3,6 +3,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.hamcrest.Matchers.containsString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.mockserver.integration.ClientAndServer.startClientAndServer; import static org.mockserver.model.HttpRequest.request; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; @@ -51,6 +52,7 @@ import org.testcontainers.shaded.org.apache.commons.io.IOUtils; import org.testcontainers.shaded.org.apache.commons.lang3.tuple.Pair; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.SerializationFeature; @@ -247,6 +249,15 @@ JwtDecoder jwtDecoder() { return mock(JwtDecoder.class); } + @Bean + @Primary + UserIdentityProvider userIdentityProvider() { + var provider = mock(UserIdentityProvider.class); + when(provider.getAuthId()).thenReturn("test-user"); + when(provider.getName()).thenReturn("Test User"); + return provider; + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java new file mode 100644 index 00000000..7e63968e --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java @@ -0,0 +1,142 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +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.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +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.web.servlet.MockMvc; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +@DisplayName("Audit Controller Integration Tests") +class AuditControllerTest extends AbstractIntegrationTest { + + private static final String AUDIT_JSON_FILES_TEST_PATH = "integration_test/json/audit/v1/"; + + @Autowired + private MockMvc mockMvc; + + private static final String AUDIT_BASE_PATH = "/api/v1/audit/entities"; + private static final String ENTITY_BASE_PATH = "/api/v1/entities"; + + @Test + @WithMockUser + @DisplayName("Should return audit history for existing entity") + void getAuditHistory_shouldReturnEmptyAuditHistory_whenEntityExistsBeforeAudit() + throws Exception { + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api-1"; + + // When requesting audit history + mockMvc + .perform(get(AUDIT_BASE_PATH + "/{templateIdentifier}/{entityIdentifier}", + templateIdentifier, entityIdentifier).with(csrf())) + .andExpect(status().isOk()).andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when entity does not exist") + void getAuditHistory_shouldReturn404_whenEntityDoesNotExist() throws Exception { + String templateIdentifier = "non-existing-template"; + String entityIdentifier = "non-existing-entity"; + + mockMvc.perform(get(AUDIT_BASE_PATH + "/{templateIdentifier}/{entityIdentifier}", + templateIdentifier, entityIdentifier).with(csrf())).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Should return 401 without authentication") + void getAuditHistory_shouldReturn401_withoutAuthentication() throws Exception { + String templateIdentifier = "web-audited"; + String entityIdentifier = "web-api-1"; + + mockMvc.perform(get(AUDIT_BASE_PATH + "/{templateIdentifier}/{entityIdentifier}", + templateIdentifier, entityIdentifier)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "test-user") + @DisplayName("Should track complete lifecycle (Create, Update, Delete) in audit history") + void auditHistory_shouldTrackFullLifecycle() throws Exception { + String templateIdentifier = "web-audited"; + String entityIdentifier = "audit-lifecycle-test"; + + generateAuditHistory(templateIdentifier, entityIdentifier, true); + // 4. VERIFY FULL AUDIT HISTORY + // Envers sorts by revision number descending, so index 0 is DELETED, 1 is + // UPDATED, 2 is CREATED. + mockMvc + .perform(get(AUDIT_BASE_PATH + "/{templateIdentifier}/{entityIdentifier}", + templateIdentifier, entityIdentifier).with(csrf())) + .andExpect(status().isOk()).andExpect(jsonPath("$.length()").value(3)) + + // Latest action (DELETED) + .andExpect(jsonPath("$[0].revision_type").value("DELETED")) + .andExpect(jsonPath("$[0].modified_by").value("test-user")) + + // Middle action (UPDATED) + .andExpect(jsonPath("$[1].revision_type").value("UPDATED")) + .andExpect(jsonPath("$[1].modified_by").value("test-user")) + .andExpect(jsonPath("$[1].snapshot.name").value("Audit Test Entity Updated")) + + // First action (CREATED) + .andExpect(jsonPath("$[2].revision_type").value("CREATED")) + .andExpect(jsonPath("$[2].modified_by").value("test-user")) + .andExpect(jsonPath("$[2].snapshot.name").value("Audit Test Entity")); + } + + @Test + @WithMockUser(username = "latest-tester") + @DisplayName("Should return the latest modification for an entity at first position") + void latestAudit_shouldReturnLatestChange() throws Exception { + + String templateIdentifier = "web-audited"; + String entityIdentifier = "audit-latest-test"; + + generateAuditHistory(templateIdentifier, entityIdentifier, false); + + mockMvc + .perform(get(AUDIT_BASE_PATH + "/{templateIdentifier}/{entityIdentifier}", + templateIdentifier, entityIdentifier).with(csrf())) + .andExpect(status().isOk()).andExpect(jsonPath("$[0].revision_type").value("UPDATED")) + .andExpect(jsonPath("$[0].modified_by").value("test-user")) + .andExpect(jsonPath("$[0].snapshot.name").value("Audit Test Entity Updated")); + } + + private void generateAuditHistory(final String templateIdentifier, final String entityIdentifier, + final Boolean deleted) throws Exception { + + String createPayload = getJsonTestFileContent( + AUDIT_JSON_FILES_TEST_PATH + "getAudit_200_history_create.json") + .formatted(entityIdentifier); + + mockMvc + .perform(post(ENTITY_BASE_PATH + "/{templateIdentifier}", templateIdentifier) + .contentType(APPLICATION_JSON).with(csrf()).content(createPayload)) + .andExpect(status().isCreated()); + + mockMvc + .perform(put(ENTITY_BASE_PATH + "/{templateIdentifier}/{entityIdentifier}", + templateIdentifier, entityIdentifier) + .contentType(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + AUDIT_JSON_FILES_TEST_PATH + "getAudit_200_history_update.json"))) + .andExpect(status().isOk()); + + if (deleted) { + mockMvc.perform(delete(ENTITY_BASE_PATH + "/{templateIdentifier}/{entityIdentifier}", + templateIdentifier, entityIdentifier).with(csrf())).andExpect(status().isNoContent()); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index d3c01815..875a2f49 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -84,9 +84,9 @@ void getTemplates_paginated_200() throws Exception { mockMvc.perform(get("/api/v1/entity-templates").accept(APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(12)) + .andExpect(jsonPath("$.content.length()").value(13)) .andExpect(jsonPath("$.content[1].identifier").value("batch-job")) - .andExpect(jsonPath("$.page.total_elements").value(12)) + .andExpect(jsonPath("$.page.total_elements").value(13)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)); @@ -118,7 +118,7 @@ void getTemplates_paginated_200_custom() throws Exception { .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content.length()").value(5)) .andExpect(jsonPath("$.content[0].identifier").value("frontend-app")) - .andExpect(jsonPath("$.page.total_elements").value(12)) + .andExpect(jsonPath("$.page.total_elements").value(13)) .andExpect(jsonPath("$.page.total_pages").value(3)) .andExpect(jsonPath("$.page.size").value(5)) .andExpect(jsonPath("$.page.number").value(1)); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java new file mode 100644 index 00000000..d6abe5e7 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java @@ -0,0 +1,189 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.hibernate.envers.RevisionType; +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.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; + +/** + * Unit tests for CustomRevisionListener. + * + * Tests verify that the listener correctly captures user identity and affected + * entities in audit records for Hibernate Envers. + */ +@DisplayName("CustomRevisionListener Tests") +@ExtendWith(MockitoExtension.class) +class CustomRevisionListenerTest { + + @Mock + private UserIdentityProvider userIdentityProvider; + + @Mock + private CustomRevisionEntity revisionEntity; + + private CustomRevisionListener listener; + + @BeforeEach + void setUp() { + listener = new CustomRevisionListener(); + } + + @Nested + @DisplayName("newRevision Tests") + class NewRevisionTests { + + @Test + @DisplayName("Should set authId when user identity is available") + void shouldSetAuthIdWhenUserIdentityIsAvailable() { + String expectedAuthId = "user@example.com"; + + try (MockedStatic holder = org.mockito.Mockito + .mockStatic(UserIdentityProviderHolder.class)) { + holder.when(UserIdentityProviderHolder::getUserIdentityProvider) + .thenReturn(userIdentityProvider); + when(userIdentityProvider.getAuthId()).thenReturn(expectedAuthId); + + listener.newRevision(revisionEntity); + + verify(revisionEntity).setAuthId(expectedAuthId); + } + } + + @Test + @DisplayName("Should set authId to 'Unknown' when getAuthId returns null") + void shouldSetAuthIdToUnknownWhenGetAuthIdReturnsNull() { + try (MockedStatic holder = org.mockito.Mockito + .mockStatic(UserIdentityProviderHolder.class)) { + holder.when(UserIdentityProviderHolder::getUserIdentityProvider) + .thenReturn(userIdentityProvider); + when(userIdentityProvider.getAuthId()).thenReturn(null); + + listener.newRevision(revisionEntity); + + verify(revisionEntity).setAuthId("Unknown"); + } + } + + @Test + @DisplayName("Should set authId to 'Unknown' when getAuthId returns blank string") + void shouldSetAuthIdToUnknownWhenGetAuthIdReturnsBlank() { + try (MockedStatic holder = org.mockito.Mockito + .mockStatic(UserIdentityProviderHolder.class)) { + holder.when(UserIdentityProviderHolder::getUserIdentityProvider) + .thenReturn(userIdentityProvider); + when(userIdentityProvider.getAuthId()).thenReturn(" "); + + listener.newRevision(revisionEntity); + + verify(revisionEntity).setAuthId("Unknown"); + } + } + + @Test + @DisplayName("Should set authId to 'Unknown' when getAuthId returns empty string") + void shouldSetAuthIdToUnknownWhenGetAuthIdReturnsEmpty() { + try (MockedStatic holder = org.mockito.Mockito + .mockStatic(UserIdentityProviderHolder.class)) { + holder.when(UserIdentityProviderHolder::getUserIdentityProvider) + .thenReturn(userIdentityProvider); + when(userIdentityProvider.getAuthId()).thenReturn(""); + + listener.newRevision(revisionEntity); + + verify(revisionEntity).setAuthId("Unknown"); + } + } + } + + @Nested + @DisplayName("entityChanged Tests") + class EntityChangedTests { + + @Test + @DisplayName("Should add custom record with UUID entityId") + void shouldAddCustomRecordWithUuidEntityId() { + UUID entityId = UUID.randomUUID(); + Class entityClass = TestEntity.class; + String entityName = "TestEntity"; + RevisionType revisionType = RevisionType.ADD; + + listener.entityChanged(entityClass, entityName, entityId, revisionType, revisionEntity); + + verify(revisionEntity).addCustomRecord(entityId, "test_entity_aud"); + } + + @Test + @DisplayName("Should add custom record with String UUID entityId") + void shouldAddCustomRecordWithStringUuidEntityId() { + UUID expectedUuid = UUID.randomUUID(); + String entityId = expectedUuid.toString(); + Class entityClass = AnotherTestEntity.class; + String entityName = "AnotherTestEntity"; + RevisionType revisionType = RevisionType.MOD; + + listener.entityChanged(entityClass, entityName, entityId, revisionType, revisionEntity); + + verify(revisionEntity).addCustomRecord(expectedUuid, "another_test_entity_aud"); + } + + @Test + @DisplayName("Should generate correct audit table name from CamelCase entity class") + void shouldGenerateCorrectAuditTableName() { + UUID entityId = UUID.randomUUID(); + Class entityClass = VeryLongEntityName.class; + String entityName = "VeryLongEntityName"; + RevisionType revisionType = RevisionType.DEL; + + listener.entityChanged(entityClass, entityName, entityId, revisionType, revisionEntity); + + verify(revisionEntity).addCustomRecord(entityId, "very_long_entity_name_aud"); + } + + @Test + @DisplayName("Should not add custom record when entityId is neither UUID nor String") + void shouldNotAddCustomRecordWhenEntityIdIsNeitherUuidNorString() { + Integer entityId = 42; + Class entityClass = TestEntity.class; + String entityName = "TestEntity"; + RevisionType revisionType = RevisionType.ADD; + + listener.entityChanged(entityClass, entityName, entityId, revisionType, revisionEntity); + + // Verify addCustomRecord is not called for non-UUID/non-String entityIds + verify(revisionEntity, never()).addCustomRecord( + org.mockito.ArgumentMatchers.any(java.util.UUID.class), + org.mockito.ArgumentMatchers.anyString()); + } + } + + /** + * Test entity class for testing audit table name generation. + */ + static class TestEntity { + } + + /** + * Another test entity class. + */ + static class AnotherTestEntity { + } + + /** + * Test entity with a longer name. + */ + static class VeryLongEntityName { + } +} 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 b44a75c0..579e4f42 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 @@ -120,7 +120,8 @@ 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'), ('550e8400-e29b-41d4-a716-446655440080', 'team', 'Team', 'Template for team entities'), -('550e8400-e29b-41d4-a716-446655440081', 'support', 'Support', 'Template for support entities with required team relation'); +('550e8400-e29b-41d4-a716-446655440081', 'support', 'Support', 'Template for support entities with required team relation'), +('550e8400-e29b-41d4-a716-446655440082', 'web-audited', 'Web audited', 'Template for validation of audit modifications'); -- Link web-service template (comprehensive web API) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES @@ -298,5 +299,12 @@ INSERT INTO entity_template_properties_definitions (entity_template_id, properti ('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440023'), -- version ('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440024'); -- teamName +-- Link web-audited template (for testing audit modifications) +INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES +('550e8400-e29b-41d4-a716-446655440082', '550e8400-e29b-41d4-a716-446655440020'), -- applicationName +('550e8400-e29b-41d4-a716-446655440082', '550e8400-e29b-41d4-a716-446655440021'), -- ownerEmail +('550e8400-e29b-41d4-a716-446655440082', '550e8400-e29b-41d4-a716-446655440022'), -- environment +('550e8400-e29b-41d4-a716-446655440082', '550e8400-e29b-41d4-a716-446655440023'); -- version + INSERT INTO entity_template_relations_definitions (entity_template_id, relations_definitions_id) VALUES ('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440066'); -- required_team diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 734f67b3..086befba 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,9 +1,6 @@ -- Insert sample entities into idp_core.entity INSERT INTO idp_core.entity (id, identifier, name, template_identifier) VALUES - ('550e8400-e29b-41d4-a716-446655440115', 'default-team', 'Default Team', 'team'), - ('550e8400-e29b-41d4-a716-446655440116', 'test-team-required', 'Test Team Required', 'team'), - ('550e8400-e29b-41d4-a716-446655440117', 'test-support-with-required-team', 'Test Support With Required Team', 'support'), ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), @@ -18,7 +15,10 @@ VALUES ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440115', 'default-team', 'Default Team', 'team'), + ('550e8400-e29b-41d4-a716-446655440116', 'test-team-required', 'Test Team Required', 'team'), + ('550e8400-e29b-41d4-a716-446655440117', 'test-support-with-required-team', 'Test Support With Required Team', 'support'); -- Properties for default-team entity INSERT INTO idp_core.property (id, name, value) diff --git a/src/test/resources/integration_test/json/audit/v1/getAudit_200_history_create.json b/src/test/resources/integration_test/json/audit/v1/getAudit_200_history_create.json new file mode 100644 index 00000000..71cce4e3 --- /dev/null +++ b/src/test/resources/integration_test/json/audit/v1/getAudit_200_history_create.json @@ -0,0 +1,10 @@ +{ + "name": "Audit Test Entity", + "identifier": "%s", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "environment": "DEV", + "version": "1.2.3" + } +} diff --git a/src/test/resources/integration_test/json/audit/v1/getAudit_200_history_update.json b/src/test/resources/integration_test/json/audit/v1/getAudit_200_history_update.json new file mode 100644 index 00000000..c7fe5ee9 --- /dev/null +++ b/src/test/resources/integration_test/json/audit/v1/getAudit_200_history_update.json @@ -0,0 +1,9 @@ +{ + "name": "Audit Test Entity Updated", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "environment": "PROD", + "version": "2.0.0" + } +} From bd048fea4ef81cba606fdefc46f729e8a62cac58 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 11 Jun 2026 15:17:37 +0200 Subject: [PATCH 2/4] feat(core): solving sonar issue --- .../audit/UserIdentityProviderHolder.java | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java index 23b3cc75..d4b5f230 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java @@ -1,5 +1,8 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; +import jakarta.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; @@ -9,19 +12,19 @@ public class UserIdentityProviderHolder { private static UserIdentityProvider userIdentityProvider; - //// Constructor to inject the UserIdentityProvider from Spring context - //// This will be called by Spring when the application context is initialized - //// The static field will be set with the Spring-managed bean, allowing to - //// Hibernate to access it - //// when it creates instances of CustomRevisionListener - /// Note: This approach relies on the fact that Spring will initialize the - //// context and set the provider - /// before any Hibernate Envers auditing occurs. If Hibernate tries to access - //// the provider before Spring sets it, an exception will be thrown. - public UserIdentityProviderHolder(UserIdentityProvider provider) { - UserIdentityProviderHolder.userIdentityProvider = provider; + private final UserIdentityProvider injectedProvider; + + @Autowired + UserIdentityProviderHolder(final UserIdentityProvider injectedProvider) { + this.injectedProvider = injectedProvider; } + /// This method is called by Hibernate Envers' CustomRevisionListener, which is + /// not managed by Spring, so we need a static accessor. + /// It will throw an exception if accessed before the Spring context is fully + /// initialized, which should not happen in normal operation. + /// This design allows us to bridge the gap between Spring-managed beans and + /// Hibernate's non-Spring-managed listeners. public static UserIdentityProvider getUserIdentityProvider() { if (userIdentityProvider == null) { throw new IllegalStateException( @@ -29,4 +32,11 @@ public static UserIdentityProvider getUserIdentityProvider() { } return userIdentityProvider; } + + @PostConstruct + public void init() { + synchronized (UserIdentityProviderHolder.class) { + userIdentityProvider = this.injectedProvider; + } + } } From 1b84e18657a547ca0b9b7ac1fcebad4d3a5f80b7 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 11 Jun 2026 16:05:41 +0200 Subject: [PATCH 3/4] feat(core): add test unitaire on mock and user provider --- .../api/auth/UnifiedUserProviderTest.java | 156 ++++++++++++++++++ .../mock/MockSecurityConfigurationTest.java | 116 +++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java new file mode 100644 index 00000000..7bb2ce18 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java @@ -0,0 +1,156 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +class UnifiedUserProviderTest { + + private UnifiedUserProvider unifiedUserProvider; + private SecurityContext securityContext; + + @BeforeEach + void setUp() { + unifiedUserProvider = new UnifiedUserProvider(); + securityContext = mock(SecurityContext.class); + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldReturnUnknownWhenAuthenticationIsNull() { + when(securityContext.getAuthentication()).thenReturn(null); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("UNKNOWN"); + assertThat(unifiedUserProvider.getName()).isEqualTo("UNKNOWN"); + } + + @Test + void shouldReturnUnknownWhenNotAuthenticated() { + Authentication auth = mock(Authentication.class); + when(auth.isAuthenticated()).thenReturn(false); + when(securityContext.getAuthentication()).thenReturn(auth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("UNKNOWN"); + assertThat(unifiedUserProvider.getName()).isEqualTo("UNKNOWN"); + } + + @Test + void shouldReturnUnknownWhenAnonymousUser() { + AnonymousAuthenticationToken anonymousAuth = new AnonymousAuthenticationToken("key", + "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + when(securityContext.getAuthentication()).thenReturn(anonymousAuth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("UNKNOWN"); + assertThat(unifiedUserProvider.getName()).isEqualTo("UNKNOWN"); + } + + @Test + void shouldReturnSubjectWhenJwtAuthentication() { + JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class); + Jwt jwt = mock(Jwt.class); + + when(jwtAuth.isAuthenticated()).thenReturn(true); + when(jwtAuth.getToken()).thenReturn(jwt); + when(jwt.getSubject()).thenReturn("jwt-user-id"); + when(securityContext.getAuthentication()).thenReturn(jwtAuth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("jwt-user-id"); + } + + @Test + void shouldReturnSubjectWhenOidcUser() { + Authentication auth = mock(Authentication.class); + OidcUser oidcUser = mock(OidcUser.class); + + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getPrincipal()).thenReturn(oidcUser); + when(oidcUser.getSubject()).thenReturn("oidc-user-id"); + when(securityContext.getAuthentication()).thenReturn(auth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("oidc-user-id"); + } + + @Test + void shouldReturnSubAttributeWhenOAuth2User() { + Authentication auth = mock(Authentication.class); + OAuth2User oauth2User = mock(OAuth2User.class); + + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getPrincipal()).thenReturn(oauth2User); + when(oauth2User.getAttribute("sub")).thenReturn("oauth2-sub-id"); + when(securityContext.getAuthentication()).thenReturn(auth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("oauth2-sub-id"); + } + + @Test + void shouldReturnIdAttributeWhenOAuth2UserHasNoSub() { + Authentication auth = mock(Authentication.class); + OAuth2User oauth2User = mock(OAuth2User.class); + + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getPrincipal()).thenReturn(oauth2User); + when(oauth2User.getAttribute("sub")).thenReturn(null); + when(oauth2User.getAttribute("id")).thenReturn("oauth2-id-attribute"); + when(securityContext.getAuthentication()).thenReturn(auth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("oauth2-id-attribute"); + } + + @Test + void shouldReturnFallbackNameWhenOAuth2UserHasNoSubOrId() { + Authentication auth = mock(Authentication.class); + OAuth2User oauth2User = mock(OAuth2User.class); + + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getName()).thenReturn("fallback-oauth2-name"); + when(auth.getPrincipal()).thenReturn(oauth2User); + when(oauth2User.getAttribute("sub")).thenReturn(null); + when(oauth2User.getAttribute("id")).thenReturn(null); + when(securityContext.getAuthentication()).thenReturn(auth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("fallback-oauth2-name"); + } + + @Test + void shouldReturnNameForBasicOrOtherAuthentication() { + Authentication auth = mock(Authentication.class); + + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getName()).thenReturn("basic-auth-user"); + when(auth.getPrincipal()).thenReturn("Standard String Principal"); // N'est ni OAuth2User ni + // OidcUser + when(securityContext.getAuthentication()).thenReturn(auth); + + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("basic-auth-user"); + } + + @Test + void shouldReturnNameWhenGetNameIsCalled() { + Authentication auth = mock(Authentication.class); + + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getName()).thenReturn("expected-user-name"); + when(securityContext.getAuthentication()).thenReturn(auth); + + assertThat(unifiedUserProvider.getName()).isEqualTo("expected-user-name"); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java new file mode 100644 index 00000000..8adbb310 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java @@ -0,0 +1,116 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.mock; + +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.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.SecurityFilterChain; + +import com.decathlon.idp_core.infrastructure.adapters.api.auth.mock.exception.MockSecurityConfigurationException; + +class MockJwtAuthenticationFilterTest { + + @Test + void shouldInjectMockJwtAuthenticationAndClearAfterwards() throws ServletException, IOException { + + // Given + MockSecurityConfiguration.MockJwtAuthenticationFilter filter = new MockSecurityConfiguration.MockJwtAuthenticationFilter(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = new FilterChain() { + @Override + public void doFilter(ServletRequest request, ServletResponse response) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + assertThat(auth).isNotNull(); + assertThat(auth).isInstanceOf(JwtAuthenticationToken.class); + + JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) auth; + + assertThat(jwtAuth.getToken().getClaimAsString("sub")).isEqualTo("local-developer"); + assertThat(jwtAuth.getToken().getClaimAsString("email")).isEqualTo("developer@local.dev"); + assertThat(jwtAuth.getToken().getClaimAsString("client_id")).isEqualTo("client-id"); + + assertThat(jwtAuth.getAuthorities()).extracting(authority -> authority.getAuthority()) + .containsExactlyInAnyOrder("ROLE_USER", "ROLE_API_CLIENT"); + } + }; + SecurityContextHolder.clearContext(); + + // When + filter.doFilter(request, response, filterChain); + + // Then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MockSecurityConfiguration.class)); + + @Test + void shouldLoadConfigurationWhenMockIsEnabled() { + contextRunner.withPropertyValues("app.security.mock-enabled=true").run(context -> { + assertThat(context).hasSingleBean(MockSecurityConfiguration.class); + assertThat(context).hasSingleBean(SecurityFilterChain.class); + assertThat(context.getBean("securityFilterChainMock")).isNotNull(); + }); + } + + @Test + void shouldNotLoadConfigurationWhenMockIsDisabled() { + contextRunner.withPropertyValues("app.security.mock-enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(MockSecurityConfiguration.class); + assertThat(context).doesNotHaveBean("securityFilterChainMock"); + }); + } + + @Test + void shouldNotLoadConfigurationWhenPropertyIsMissing() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(MockSecurityConfiguration.class); + assertThat(context).doesNotHaveBean("securityFilterChainMock"); + }); + } + + @Test + void shouldThrowExceptionWhenMockIsDisabled() { + contextRunner.withPropertyValues("app.security.mock-enabled=false"); + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(MockSecurityConfiguration.class); + assertThat(context).doesNotHaveBean("securityFilterChainMock"); + }); + } + + @Test + void shouldThrowMockSecurityConfigurationExceptionWhenHttpConfigFails() throws Exception { + // Given + MockSecurityConfiguration configuration = new MockSecurityConfiguration(); + HttpSecurity httpSecurityMock = mock(HttpSecurity.class); + RuntimeException simulatedError = new RuntimeException("Internal simulated Error"); + when(httpSecurityMock.sessionManagement(any())).thenThrow(simulatedError); + + // When & Then + assertThatThrownBy(() -> configuration.securityFilterChainMock(httpSecurityMock)) + .isInstanceOf(MockSecurityConfigurationException.class) + .hasMessage("Failed to configure mock security filter chain").hasCause(simulatedError); + } +} From 2f7115f6331733e72314b034d831113355505540 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 11 Jun 2026 16:18:19 +0200 Subject: [PATCH 4/4] feat(core): sonar issue --- .../api/auth/mock/MockSecurityConfigurationTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java index 8adbb310..d8c43080 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java @@ -20,6 +20,7 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.web.SecurityFilterChain; @@ -50,7 +51,7 @@ public void doFilter(ServletRequest request, ServletResponse response) { assertThat(jwtAuth.getToken().getClaimAsString("email")).isEqualTo("developer@local.dev"); assertThat(jwtAuth.getToken().getClaimAsString("client_id")).isEqualTo("client-id"); - assertThat(jwtAuth.getAuthorities()).extracting(authority -> authority.getAuthority()) + assertThat(jwtAuth.getAuthorities()).extracting(GrantedAuthority::getAuthority) .containsExactlyInAnyOrder("ROLE_USER", "ROLE_API_CLIENT"); } }; @@ -101,7 +102,7 @@ void shouldThrowExceptionWhenMockIsDisabled() { } @Test - void shouldThrowMockSecurityConfigurationExceptionWhenHttpConfigFails() throws Exception { + void shouldThrowMockSecurityConfigurationExceptionWhenHttpConfigFails() { // Given MockSecurityConfiguration configuration = new MockSecurityConfiguration(); HttpSecurity httpSecurityMock = mock(HttpSecurity.class);