diff --git a/.github/skills/implement-audit-logging/SKILL.md b/.github/skills/implement-audit-logging/SKILL.md new file mode 100644 index 00000000..fad307f8 --- /dev/null +++ b/.github/skills/implement-audit-logging/SKILL.md @@ -0,0 +1,78 @@ +--- +name: implement-audit-logging +description: 'Implement audit tracking and history endpoints for an existing domain object using Hibernate Envers.' +--- + +# Implement Audit Logging + +Implement Hibernate Envers audit logging and a history retrieval endpoint for an existing domain object within the Hexagonal Architecture. + +This skill leverages the native Envers entity tracking (`track_entities_changed_in_revision: true`) and relies on the global `envers_transaction_log` without creating custom tracking tables. + +## Inputs + +- **EntityName**: `${input:EntityName}`—The name of the domain object (for example `Property`, `EntityTemplate`). +- **IdentifierType**: `${input:IdentifierType}`—The primary identifier type of the object (for example `UUID`, `String`). + +## Input Validation + +If the entity name or identifier type cannot be determined from the conversation, ask the user before proceeding. Verify that the target JPA entity exists in the `infrastructure.adapters.persistence.model` package before attempting any modifications. + +## Requirements + +- **Architecture**: Strict adherence to Hexagonal Architecture (Domain, Ports, Adapters). +- **Audit Framework**: Hibernate Envers with native tracking. Do not manually map elements into `revinfo` or custom tracking tables. +- **DTO Naming**: Use Jackson `@JsonNaming(SnakeCaseStrategy.class)` for all API outputs. + +## Workflow + +### 1. JPA Entity Update + +Locate the target `[EntityName]JpaEntity` in `infrastructure/adapters/persistence/model/`. + +- Add the `org.hibernate.envers.Audited` annotation to the class. +- Apply `@NotAudited` only to lazy relationships or fields that should strictly not trigger audit records. + +### 2. Flyway Migration Generation + +Create a new migration script in `src/main/resources/db/migration/`. + +- Generate SQL to create a table named `[entity_name]_aud`. +- Include the base audit columns: `id` (or primary key), `rev` (`BIGINT NOT NULL`), and `revtype` (`SMALLINT`). +- Add the foreign key constraint: `CONSTRAINT fk_[entity_name]_aud_revinfo FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE`. +- Replicate the audited columns from the base table. + +### 3. Domain Port + +Create an interface `[EntityName]AuditPort` in `domain/port/audit/`. + +- Define the retrieval method: `List<[EntityName]AuditInfo> getAuditHistory([IdentifierType] id);`. + +### 4. Persistence Adapter + +Create `Postgres[EntityName]AuditAdapter` in `infrastructure/adapters/persistence/` implementing the port. + +- Use `AuditReaderFactory.get(entityManager).createQuery().forRevisionsOfEntity([EntityName]JpaEntity.class, false, true)` to fetch revisions. +- Map the resulting `Object[]` containing the Entity snapshot, `CustomRevisionEntity`, and `RevisionType` to the domain `AuditInfo` model. +- Map `CustomRevisionEntity.getAuthId()` to the `modifiedBy` field. + +### 5. Domain Service + +Create `[EntityName]AuditService` in `domain/service/[entity_name]/`. + +- Annotate with `@Transactional(readOnly = true)`. +- Inject the port and implement the retrieval method, adding necessary business logic or template validations. + +### 6. API Adapter & DTOs + +Create `[EntityName]AuditDtoOut` in `infrastructure/adapters/api/dto/out/`. + +- Annotate with `@Data`, `@Builder`, and `@JsonNaming(SnakeCaseStrategy.class)`. Include fields: `revisionNumber`, `revisionDate`, `revisionType`, `modifiedBy`, and `snapshot`. +- Create or update the `AuditController` to define `@GetMapping("entities/[object-plural]/{id}")`. +- Apply Swagger annotations (`@Operation`, `@ApiResponse`). + +## Validation + +- Verify that no manual entity tracking code was added. +- Ensure the Flyway migration syntax is strictly correct for PostgreSQL. +- Check that the API outputs map correctly to `SnakeCaseStrategy`. diff --git a/.vale/styles/config/vocabularies/IDP/accept.txt b/.vale/styles/config/vocabularies/IDP/accept.txt index a567d61e..697e7a30 100644 --- a/.vale/styles/config/vocabularies/IDP/accept.txt +++ b/.vale/styles/config/vocabularies/IDP/accept.txt @@ -47,3 +47,4 @@ getters Optionals untracked Performance +Envers diff --git a/docs/src/concepts/audit.md b/docs/src/concepts/audit.md new file mode 100644 index 00000000..9c62e950 --- /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_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 Envers**, 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` +- Audit table: `entity_aud` + +Audit tables store historical versions of every column with additional hibernate columns: + +- `REV` - Revision number +- `REVTYPE` - Revision type (0=CREATED, 1=UPDATED, 2=DELETED) + +### 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/contributing/code/audit-implemantation.md b/docs/src/contributing/code/audit-implemantation.md new file mode 100644 index 00000000..f7102caa --- /dev/null +++ b/docs/src/contributing/code/audit-implemantation.md @@ -0,0 +1,170 @@ +# Adding Audit Logging to Domain Objects + +This guide explains how to integrate our Hibernate Envers audit logging architecture for a new or existing domain object, and how to expose an endpoint to retrieve its history. This is part of our Hexagonal Architecture and ensures we keep a strict separation between database tracking and domain logic. + +## Architecture Overview + +We use **Hibernate Envers** with native entity tracking enabled (`track_entities_changed_in_revision: true`). + +- **Global Transaction Log:** `envers_transaction_log` tracks the revision number, timestamp, and the user's `auth_id`. +- **Modified Entities Log:** `envers_modified_entities` natively tracks which JPA entity classes were modified in a given transaction. +- **Entity-Specific Audit Tables:** (for example `entity_aud`, `property_aud`). These store the actual state snapshots of the modified rows. + +--- + +## Step 1: Add `@Audited` to the JPA Entity + +Navigate to your JPA Entity class (for example `PropertyJpaEntity`) in the `infrastructure.adapters.persistence.model` package. + +```java +import org.hibernate.envers.Audited; +import org.hibernate.envers.NotAudited; + +@Entity +@Table(name = "property") +@Audited // <--- Add this annotation to track changes +public class PropertyJpaEntity { + + @Id + private UUID id; + + private String name; + + // Use @NotAudited on fields or lazy relationships you DO NOT want to track + @NotAudited + @OneToMany(mappedBy = "property") + private Set children; +} +``` + +## Step 2: Create the Flyway Migration + +Create a new Flyway migration script (for example `V4_2__audit_property.sql`) in `src/main/resources/db/migration/` to define the specific `_aud` table for your entity. + +**Rules for Audit Tables:** + +1. Suffix the table name with `_aud` (for example `property_aud`). +2. Add a `rev` column (`BIGINT NOT NULL`). +3. Add a `revtype` column (`SMALLINT`) to track the operation (0=ADD, 1=MOD, 2=DEL). +4. Replicate the fields from the base table that you are auditing. +5. Make the Primary Key a composite of the entity's ID and `rev`. +6. Add the Foreign Key linking to `envers_transaction_log`. + +```sql +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); +``` + +## Step 3: Implement the Domain Port and Persistence Adapter + +Create your port in the domain (for example `PropertyAuditPort`), then implement the adapter using Hibernate Envers' `AuditReader`. + +```java +@Component +@RequiredArgsConstructor +public class PostgresPropertyAuditAdapter implements PropertyAuditPort { + + private final EntityManager entityManager; + + @Override + public List getPropertyAuditHistory(UUID propertyId) { + AuditReader auditReader = AuditReaderFactory.get(entityManager); + + // Query Envers for the specific entity class + @SuppressWarnings("unchecked") + List revisions = auditReader.createQuery() + .forRevisionsOfEntity(PropertyJpaEntity.class, false, true) + .add(AuditEntity.id().eq(propertyId)) + .addOrder(AuditEntity.revisionNumber().desc()) + .getResultList(); + + return revisions.stream().map(this::mapToDomainAuditInfo).toList(); + } + + private AuditInfo mapToDomainAuditInfo(Object[] revision) { + PropertyJpaEntity snapshot = (PropertyJpaEntity) revision[0]; + CustomRevisionEntity revEntity = (CustomRevisionEntity) revision[1]; + RevisionType revType = (RevisionType) revision[2]; + + // Map to your domain record... + } +} +``` + +## Step 4: Add/Update Domain Service and REST Endpoint + +Depending on the domain design, you can either reuse existing files to minimize boilerplate, or create dedicated audit files for clean separation of concerns. + +1. **Service:** Create `PropertyAuditService` to handle business logic (like ensuring the parent object exists before querying history). +2. **Controller:** Expose the endpoint, using standard DTOs formatted with Jackson's `SnakeCaseStrategy`. + +### Approach 1: Reusing Existing Structures (Recommended for simple resources) + +If you already have a PropertyService and a PropertyController, simply append the new functionality directly to them to keep things concise. + +1. **Service:** In PropertyService: Inject the PropertyAuditPort and add a getPropertyHistory(UUID id) method. +2. **Controller:** In PropertyController: Expose a nested route following clean RESTful guidelines. + +```java +// Within your existing PropertyController.java +@GetMapping("/{propertyId}/history") +public List getPropertyHistory(@PathVariable UUID propertyId) { +return auditMapper.fromDomainList(propertyService.getPropertyHistory(propertyId)); +} +``` + +### Approach 2: Creating Dedicated Audit Handlers (Recommended for complex auditing rules) + +If retrieving audit details requires dedicated permissions, complex filtering, or distinct business rules, decouple them into dedicated files. + +1. **Service:** Establish a PropertyAuditService to guarantee domain invariant verification (for example verifying object access permissions before compiling historical records). +2. **Controller:** Build a focused controller parsing outputs cleanly with Jackson's SnakeCaseStrategy. + +```java +package com.company.project.infrastructure.adapters.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/audit/") +@RequiredArgsConstructor +public class PropertyAuditController { + + private final PropertyAuditService auditService; + private final PropertyAuditDtoOutMapper mapper; + + @GetMapping("properties/{propertyId}") + public List getPropertyAuditHistory(@PathVariable UUID propertyId) { + return mapper.fromDomainList(auditService.getHistory(propertyId)); + } +} +``` + +```java +@RestController +@RequestMapping("/api/v1/audit/") +@RequiredArgsConstructor +public class PropertyAuditController { + + private final PropertyAuditService auditService; + private final PropertyAuditDtoOutMapper mapper; + + @GetMapping("properties/{propertyId}") + public List getPropertyAuditHistory(@PathVariable UUID propertyId) { + return mapper.fromDomainList(auditService.getHistory(propertyId)); + } +} +``` diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index e5f70058..7e740077 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -15,6 +15,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: @@ -546,6 +548,59 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/audit/entities/{templateIdentifier}/{entityIdentifier}: + get: + tags: + - Audit + summary: Get audit history + description: Retrieve the complete audit history for a specific object, + 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: @@ -1108,6 +1163,103 @@ 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 at a specific audit revision + 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 + properties: + type: array + description: Properties of the entity at this revision + items: + $ref: "#/components/schemas/PropertySnapshotDtoOut" + relations: + type: array + description: Relations of the entity at this revision + items: + $ref: "#/components/schemas/RelationSnapshotDtoOut" + PropertySnapshotDtoOut: + type: object + description: Snapshot of a property at a specific audit revision + properties: + id: + type: string + format: uuid + description: Unique identifier of the property + example: 550e8400-e29b-41d4-a716-446655440000 + name: + type: string + description: Name of the property matching a PropertyDefinition + example: description + value: + type: string + description: Value of the property at this revision + example: My service description + RelationSnapshotDtoOut: + type: object + description: Snapshot of a relation at a specific audit revision + properties: + id: + type: string + format: uuid + description: Unique identifier of the relation + example: 550e8400-e29b-41d4-a716-446655440000 + name: + type: string + description: Name of the relation matching a RelationDefinition + example: deployed-on + target_template_identifier: + type: string + description: Identifier of the target entity template + example: infrastructure + target_entity_identifiers: + type: array + description: Business identifiers of target entities + example: + - prod-cluster + - staging-cluster + items: + type: string EntityGraphEdgeDtoOut: type: object properties: diff --git a/pom.xml b/pom.xml index 717997fc..a3681ef0 100644 --- a/pom.xml +++ b/pom.xml @@ -239,6 +239,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/exception/mock/MockSecurityConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/mock/MockSecurityConfigurationException.java new file mode 100644 index 00000000..8231f8c8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/mock/MockSecurityConfigurationException.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.exception.mock; + +/// 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/domain/model/entity/EntityAuditInfo.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityAuditInfo.java new file mode 100644 index 00000000..0cd8cd99 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityAuditInfo.java @@ -0,0 +1,88 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.time.Instant; +import java.util.List; +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 (CREATED, UPDATED, DELETED) +/// @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) { + + /// Snapshot of an entity's complete state at a specific point in time. + /// + /// **Business purpose:** Preserves the full entity state (core data, + /// properties, + /// and relations) at the time of an audit revision, enabling historical + /// reconstruction + /// and change tracking. + /// + /// @param id unique UUID identifier of the entity + /// @param templateIdentifier identifier of the entity template this entity + /// conforms to + /// @param name human-readable name of the entity + /// @param identifier business identifier of the entity + /// @param properties list of property snapshots capturing property values at + /// this revision + /// @param relations list of relation snapshots capturing relationship targets + /// at this revision + public record EntitySnapshot(UUID id, String templateIdentifier, String name, String identifier, + List properties, List relations) { + } + + /// Snapshot of a property's state at a specific point in time during entity + /// history. + /// + /// **Business purpose:** Captures the immutable state of a single entity + /// property + /// as it existed at a specific audit revision, enabling point-in-time + /// reconstruction + /// of entity data and change tracking. + /// + /// @param id unique UUID identifier of the property + /// @param name name of the property matching a PropertyDefinition in the entity + /// template + /// @param value the value of the property as a string (preserves JSON-typed + /// values as strings) + public record PropertySnapshot(UUID id, String name, String value) { + } + + /// Snapshot of a relation's state at a specific point in time during entity + /// history. + /// + /// **Business purpose:** Captures the immutable state of a single entity + /// relation + /// (relationship to other entities) as it existed at a specific audit revision, + /// enabling point-in-time reconstruction of relationships and change tracking. + /// + /// @param id unique UUID identifier of the relation + /// @param name name of the relation matching a RelationDefinition in the entity + /// template + /// @param targetTemplateIdentifier identifier of the target entity template + /// @param targetEntityIdentifiers list of business identifiers of target + /// entities + public record RelationSnapshot(UUID id, String name, String targetTemplateIdentifier, + List targetEntityIdentifiers) { + public RelationSnapshot { + targetEntityIdentifiers = targetEntityIdentifiers == null + ? List.of() + : List.copyOf(targetEntityIdentifiers); + } + } +} 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..26f9168e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfiguration.java @@ -0,0 +1,144 @@ +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.domain.exception.mock.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 { + + /// 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(); + } + + /// 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")); + } + + } +} 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 20527874..5470466b 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 @@ -73,6 +73,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..a5dff4a6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/EntitySnapshotDtoOut.java @@ -0,0 +1,42 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit; + +import java.util.List; +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 within audit history. +/// +/// **Business purpose:** Captures the complete state of an entity (core data, all +/// properties, and all relations) as it existed at a specific audit revision, +/// enabling clients to reconstruct the exact entity state as it was at any point +/// in the entity's history. +@Data +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "Snapshot of entity state at a specific audit revision") +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; + + @Schema(description = "Properties of the entity at this revision") + private List properties; + + @Schema(description = "Relations of the entity at this revision") + private List relations; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/PropertySnapshotDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/PropertySnapshotDtoOut.java new file mode 100644 index 00000000..fffafbcc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/PropertySnapshotDtoOut.java @@ -0,0 +1,31 @@ +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 property snapshot within entity audit history. +/// +/// **Business purpose:** Captures the immutable state of a single entity property +/// as it existed at a specific audit revision, enabling clients to reconstruct +/// the exact property values as they were at any point in the entity's history. +@Data +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "Snapshot of a property at a specific audit revision") +public class PropertySnapshotDtoOut { + + @Schema(description = "Unique identifier of the property", example = "550e8400-e29b-41d4-a716-446655440000") + private UUID id; + + @Schema(description = "Name of the property matching a PropertyDefinition", example = "description") + private String name; + + @Schema(description = "Value of the property at this revision", example = "My service description") + private String value; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/RelationSnapshotDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/RelationSnapshotDtoOut.java new file mode 100644 index 00000000..5ad6f9a8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/audit/RelationSnapshotDtoOut.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit; + +import java.util.List; +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 relation snapshot within entity audit history. +/// +/// **Business purpose:** Captures the immutable state of a single entity relation +/// (relationship to other entities) as it existed at a specific audit revision, +/// enabling clients to reconstruct the exact relationship targets as they were +/// at any point in the entity's history. +@Data +@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "Snapshot of a relation at a specific audit revision") +public class RelationSnapshotDtoOut { + + @Schema(description = "Unique identifier of the relation", example = "550e8400-e29b-41d4-a716-446655440000") + private UUID id; + + @Schema(description = "Name of the relation matching a RelationDefinition", example = "deployed-on") + private String name; + + @Schema(description = "Identifier of the target entity template", example = "infrastructure") + private String targetTemplateIdentifier; + + @Schema(description = "Business identifiers of target entities", example = "[\"prod-cluster\", \"staging-cluster\"]") + private List targetEntityIdentifiers; +} 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..32dc87b6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityAuditDtoOutMapper.java @@ -0,0 +1,74 @@ +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; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.PropertySnapshotDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.RelationSnapshotDtoOut; + +/// Mapper converting domain entity audit information to API response DTOs. +/// +/// **Business purpose:** Translates immutable domain audit records into mutable +/// DTO structures suitable for serialization to JSON in REST API responses. +/// Handles null safety and defensive mapping of nested snapshot collections. +@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()) + .properties(mapPropertySnapshots(auditInfo.snapshot().properties())) + .relations(mapRelationSnapshots(auditInfo.snapshot().relations())).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(); + } + + /// Maps domain property snapshots to DTO property snapshots. + /// Ensures null safety and empty collection handling. + private List mapPropertySnapshots( + List properties) { + if (properties == null || properties.isEmpty()) { + return List.of(); + } + return properties.stream().map(prop -> PropertySnapshotDtoOut.builder().id(prop.id()) + .name(prop.name()).value(prop.value()).build()).toList(); + } + + /// Maps domain relation snapshots to DTO relation snapshots. + /// Ensures null safety and empty collection handling of target identifiers. + private List mapRelationSnapshots( + List relations) { + if (relations == null || relations.isEmpty()) { + return List.of(); + } + return relations.stream() + .map(rel -> RelationSnapshotDtoOut.builder().id(rel.id()).name(rel.name()) + .targetTemplateIdentifier(rel.targetTemplateIdentifier()) + .targetEntityIdentifiers(rel.targetEntityIdentifiers() != null + ? List.copyOf(rel.targetEntityIdentifiers()) + : List.of()) + .build()) + .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..45ca0de4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapter.java @@ -0,0 +1,145 @@ +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.exception.entity.EntityNotFoundException; +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.model.entity.PropertyJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationTargetJpaEntity; +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(revision -> mapToEntityAuditInfo(revision, entityId)).toList(); + } + + private UUID getEntityId(String templateIdentifier, String entityIdentifier) { + + return jpaEntityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .map(EntityJpaEntity::getId) + .orElseGet(() -> findEntityIdInAuditHistory(templateIdentifier, entityIdentifier)); + } + + private UUID findEntityIdInAuditHistory(String templateIdentifier, String entityIdentifier) { + AuditReader auditReader = AuditReaderFactory.get(entityManager); + + @SuppressWarnings("unchecked") + List revisions = auditReader.createQuery() + .forRevisionsOfEntity(EntityJpaEntity.class, false, true) + .add(AuditEntity.property("templateIdentifier").eq(templateIdentifier)) + .add(AuditEntity.property("identifier").eq(entityIdentifier)) + .addOrder(AuditEntity.revisionNumber().desc()).getResultList(); + + if (!revisions.isEmpty() && revisions.getFirst()[0]instanceof EntityJpaEntity auditedEntity) { + return auditedEntity.getId(); + } + throw new EntityNotFoundException(templateIdentifier, entityIdentifier); + } + + private EntityAuditInfo mapToEntityAuditInfo(Object[] revision, UUID entityId) { + CustomRevisionEntity revisionEntity = (CustomRevisionEntity) revision[1]; + RevisionType revisionType = (RevisionType) revision[2]; + + Number revisionNumber = revisionEntity.getRev(); + Instant revisionDate = Instant.ofEpochMilli(revisionEntity.getRevisionTimestamp()); + String revisionTypeStr = mapRevisionType(revisionType); + String modifiedBy = revisionEntity.getAuthId() != null ? revisionEntity.getAuthId() : "system"; + + EntityAuditInfo.EntitySnapshot snapshot = null; + + Number snapshotRevisionNumber = revisionType == RevisionType.DEL + ? revisionNumber.longValue() - 1 + : revisionNumber; + + AuditReader auditReader = AuditReaderFactory.get(entityManager); + EntityJpaEntity historicalEntity = auditReader.find(EntityJpaEntity.class, entityId, + snapshotRevisionNumber); + + if (historicalEntity != null) { + List propertySnapshots = mapPropertySnapshots( + historicalEntity.getProperties()); + List relationSnapshots = mapRelationSnapshots( + historicalEntity.getRelations()); + + snapshot = new EntityAuditInfo.EntitySnapshot(historicalEntity.getId(), + historicalEntity.getTemplateIdentifier(), historicalEntity.getName(), + historicalEntity.getIdentifier(), propertySnapshots, relationSnapshots); + } + + return new EntityAuditInfo(revisionNumber, revisionDate, revisionTypeStr, modifiedBy, snapshot); + } + + /// Converts a list of JPA property entities to domain property snapshot + /// records. + /// Ensures null safety and defensive copying to preserve immutability. + private List mapPropertySnapshots( + List properties) { + if (properties == null || properties.isEmpty()) { + return List.of(); + } + return properties.stream().map( + prop -> new EntityAuditInfo.PropertySnapshot(prop.getId(), prop.getName(), prop.getValue())) + .toList(); + } + + /// Converts a list of JPA relation entities to domain relation snapshot + /// records. + /// Ensures null safety and defensive copying to preserve immutability of target + /// identifiers. + private List mapRelationSnapshots( + List relations) { + if (relations == null || relations.isEmpty()) { + return List.of(); + } + return relations.stream() + .map(rel -> new EntityAuditInfo.RelationSnapshot(rel.getId(), rel.getName(), + rel.getTargetTemplateIdentifier(), + rel.getTargetEntities() != null + ? rel.getTargetEntities().stream() + .map(RelationTargetJpaEntity::getTargetEntityIdentifier).toList() + : List.of())) + .toList(); + } + + 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/CustomRevisionEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionEntity.java new file mode 100644 index 00000000..19b49e6d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionEntity.java @@ -0,0 +1,64 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import java.util.HashSet; +import java.util.Set; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; + +import org.hibernate.envers.ModifiedEntityNames; +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 + @GeneratedValue(strategy = GenerationType.IDENTITY) + @RevisionNumber + @Column(name = "rev") + private long rev; + + @RevisionTimestamp + @Column(name = "revision_timestamp") + private long revisionTimestamp; + + @Column(name = "auth_id") + private String authId; + + @ElementCollection + @CollectionTable(name = "envers_modified_entities", joinColumns = @JoinColumn(name = "rev")) + @Column(name = "entity_name") + @ModifiedEntityNames + private Set modifiedEntityNames = new HashSet<>(); + + public long getRev() { + return rev; + } + + public long getRevisionTimestamp() { + return revisionTimestamp; + } + + public String getAuthId() { + return authId; + } + + public void setAuthId(String authId) { + this.authId = authId; + } + + public Set getModifiedEntityNames() { + return modifiedEntityNames; + } +} 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..b7b39e4a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListener.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import org.hibernate.envers.RevisionListener; + +public class CustomRevisionListener implements RevisionListener { + + 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); + } +} 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..e1ef17b2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java @@ -0,0 +1,43 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import java.util.concurrent.atomic.AtomicReference; + +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; + +@Component +public class UserIdentityProviderHolder { + + private static final AtomicReference userIdentityProvider = new AtomicReference<>(); + + 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() { + UserIdentityProvider provider = userIdentityProvider.get(); + if (provider == null) { + throw new IllegalStateException( + "UserIdentityProviderHolder not initialized. Spring context may not be loaded."); + } + return provider; + } + + @PostConstruct + public void init() { + userIdentityProvider.set(this.injectedProvider); + } +} 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 9e618ad7..3a5b4e2f 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 @@ -16,6 +16,7 @@ import jakarta.persistence.UniqueConstraint; import org.hibernate.annotations.BatchSize; +import org.hibernate.envers.Audited; import lombok.AllArgsConstructor; import lombok.Builder; @@ -26,6 +27,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 6dd82c10..8f8c00e6 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 @@ -15,6 +15,7 @@ import jakarta.persistence.Table; import org.hibernate.annotations.BatchSize; +import org.hibernate.envers.Audited; import lombok.AllArgsConstructor; import lombok.Builder; @@ -24,6 +25,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 d2a07286..f0baf612 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 b6198fa6..9a97c589 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 { /** * Find relation summaries where the given entity identifiers are targets. Uses diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index a1c974c9..4a3f8b2d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -19,9 +19,23 @@ spring: jpa: hibernate: ddl-auto: none # Disable JPA schema auto-generation, use Flyway instead - - + 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 logging: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fac3c277..9c255760 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -85,6 +85,11 @@ spring: # works together with batch_size to reduce round-trips. order_inserts: true order_updates: true + envers: + # Enables Hibernate Envers for auditing entity changes, also deletion. + # Stores audit data in a separate table for each entity. + store_data_at_delete: true + track_entities_changed_in_revision: 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..71061ee0 --- /dev/null +++ b/src/main/resources/db/migration/V4_1__create_envers_audit_schema.sql @@ -0,0 +1,188 @@ +--- 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 an identity column. +-- 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 TABLE envers_transaction_log +( + rev BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + revision_timestamp BIGINT NOT NULL, + auth_id VARCHAR(255) +); + +-- Envers will automatically insert the names of the JPA Entity classes modified in each transaction here. +CREATE TABLE envers_modified_entities +( + rev BIGINT NOT NULL, + entity_name VARCHAR(255) NOT NULL, + PRIMARY KEY (rev, entity_name), + CONSTRAINT fk_envers_modified_entities_rev FOREIGN KEY (rev) REFERENCES envers_transaction_log (rev) ON DELETE CASCADE +); + +CREATE INDEX idx_transaction_log_timestamp ON envers_transaction_log (revision_timestamp); +CREATE INDEX idx_transaction_log_auth_id ON envers_transaction_log (auth_id); + +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 INDEX idx_entity_aud_template_identifier_identifier ON entity_aud (template_identifier, identifier); + +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/main/resources/db/migration/V5_2__add_target_entity_uuid_to_audit_table.sql b/src/main/resources/db/migration/V5_2__add_target_entity_uuid_to_audit_table.sql new file mode 100644 index 00000000..a8d5fcb2 --- /dev/null +++ b/src/main/resources/db/migration/V5_2__add_target_entity_uuid_to_audit_table.sql @@ -0,0 +1,13 @@ +-- Flyway migration script: Add target_entity_uuid to audit table +-- Purpose: Adds the target_entity_uuid column to relation_target_entities_aud table +-- to keep the audit table in sync with V5.1 changes to the main table + +ALTER TABLE relation_target_entities_aud +ADD COLUMN target_entity_uuid UUID; + +-- Create index for better performance on audit queries +CREATE INDEX idx_relation_target_entities_aud_target_uuid +ON relation_target_entities_aud (target_entity_uuid); + +-- Add table comment for documentation +COMMENT ON COLUMN relation_target_entities_aud.target_entity_uuid IS 'UUID identifier of the target entity; synced with V5.1 changes'; diff --git a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java index 00356b45..4b68eced 100644 --- a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java +++ b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java @@ -15,7 +15,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.ClassOrderer; @@ -77,8 +76,6 @@ public abstract class AbstractIntegrationTest { public static MockServerClient mockServerClient; - public static AtomicBoolean initToDo = new AtomicBoolean(true); - protected AbstractIntegrationTest() { this.objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); @@ -246,7 +243,6 @@ public static class TestBeanConfiguration { JwtDecoder jwtDecoder() { return mock(JwtDecoder.class); } - } } 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..e34b0ec9 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/mock/MockSecurityConfigurationTest.java @@ -0,0 +1,360 @@ +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 java.util.Objects; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.GrantedAuthority; +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 com.decathlon.idp_core.domain.exception.mock.MockSecurityConfigurationException; + +/// Unit tests for MockSecurityConfiguration and MockJwtAuthenticationFilter. +/// Covers mock security setup and JWT token generation for local development. +@DisplayName("MockSecurityConfiguration Tests") +class MockSecurityConfigurationTest { + + @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(GrantedAuthority::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) + .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) + .doesNotHaveBean("securityFilterChainMock")); + } + + @Test + void shouldNotLoadConfigurationWhenPropertyIsMissing() { + contextRunner + .run(context -> assertThat(context).doesNotHaveBean(MockSecurityConfiguration.class) + .doesNotHaveBean("securityFilterChainMock")); + } + + @Test + void shouldThrowMockSecurityConfigurationExceptionWhenHttpConfigFails() { + // 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); + } + + @Nested + @DisplayName("JWT Token Creation Tests") + class JwtTokenCreationTests { + + @Test + @DisplayName("Should create mock JWT with correct subject") + void shouldCreateMockJwtWithCorrectSubject() 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(); + Jwt jwt = ((JwtAuthenticationToken) auth).getToken(); + assertThat(jwt.getSubject()).isEqualTo("local-developer"); + } + }; + + // When + filter.doFilter(request, response, filterChain); + + // Then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("Should create mock JWT with client_id claim") + void shouldCreateMockJwtWithClientId() 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(); + Jwt jwt = ((JwtAuthenticationToken) auth).getToken(); + assertThat(jwt.getClaimAsString("client_id")).isEqualTo("client-id"); + } + }; + + // When + filter.doFilter(request, response, filterChain); + + // Then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("Should create mock JWT with scope claim") + void shouldCreateMockJwtWithScope() 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(); + Jwt jwt = ((JwtAuthenticationToken) auth).getToken(); + assertThat(jwt.getClaimAsString("scope")).isEqualTo("auth read write"); + } + }; + + // When + filter.doFilter(request, response, filterChain); + + // Then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("Should create mock JWT with valid timestamps") + void shouldCreateMockJwtWithValidTimestamps() 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(); + Jwt jwt = ((JwtAuthenticationToken) auth).getToken(); + assertThat(jwt.getIssuedAt()).isNotNull(); + assertThat(jwt.getExpiresAt()).isNotNull().isAfter(jwt.getIssuedAt()); + } + }; + + // When + filter.doFilter(request, response, filterChain); + + // Then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("Should create mock JWT with 1-hour expiration") + void shouldCreateMockJwtWith1HourExpiration() 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(); + Jwt jwt = ((JwtAuthenticationToken) Objects.requireNonNull(auth)).getToken(); + long expirationDurationSeconds = Objects.requireNonNull(jwt.getExpiresAt()) + .getEpochSecond() - Objects.requireNonNull(jwt.getIssuedAt()).getEpochSecond(); + assertThat(expirationDurationSeconds).isEqualTo(3600); + } + }; + + // When + filter.doFilter(request, response, filterChain); + } + + @Test + @DisplayName("Should create mock JWT with additional user claims") + void shouldCreateMockJwtWithAdditionalUserClaims() 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(); + Jwt jwt = ((JwtAuthenticationToken) Objects.requireNonNull(auth)).getToken(); + assertThat(jwt.getClaimAsString("user_id")).isEqualTo("dev-user-001"); + assertThat(jwt.getClaimAsString("email")).isEqualTo("developer@local.dev"); + } + }; + + // When + filter.doFilter(request, response, filterChain); + } + + @Test + @DisplayName("Should create mock JWT with correct header") + void shouldCreateMockJwtWithCorrectHeader() 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(); + Jwt jwt = ((JwtAuthenticationToken) Objects.requireNonNull(auth)).getToken(); + assertThat(jwt.getHeaders()).containsEntry("alg", "RS256"); + assertThat(jwt.getHeaders()).containsEntry("typ", "JWT"); + } + }; + // When + filter.doFilter(request, response, filterChain); + } + } + + @Nested + @DisplayName("Filter Chain Exception Handling Tests") + class FilterChainExceptionHandlingTests { + + @Test + @DisplayName("Should clear SecurityContext even if filter chain throws ServletException") + void shouldClearSecurityContextOnServletException() { + // 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) + throws ServletException { + throw new ServletException("Test exception"); + } + }; + + // When & Then + assertThatThrownBy(() -> filter.doFilter(request, response, filterChain)) + .isInstanceOf(ServletException.class); + + // Context should be cleared + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("Should clear SecurityContext even if filter chain throws IOException") + void shouldClearSecurityContextOnIOException() { + // 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) throws IOException { + throw new IOException("Test IO exception"); + } + }; + + // When & Then + assertThatThrownBy(() -> filter.doFilter(request, response, filterChain)) + .isInstanceOf(IOException.class); + + // Context should be cleared + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + } + + @Nested + @DisplayName("Authority Tests") + class AuthorityTests { + + @Test + @DisplayName("Should include both ROLE_USER and ROLE_API_CLIENT authorities") + void shouldIncludeBothRoles() 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(Objects.requireNonNull(auth).getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactlyInAnyOrder("ROLE_USER", "ROLE_API_CLIENT"); + } + }; + + // When + filter.doFilter(request, response, filterChain); + } + } +} 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..50ede59e --- /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("latest-tester")) + .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/api/mapper/entity/EntityAuditDtoOutMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityAuditDtoOutMapperTest.java new file mode 100644 index 00000000..c6ef330a --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityAuditDtoOutMapperTest.java @@ -0,0 +1,366 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.EntityAuditDtoOut; + +/// Unit tests for EntityAuditDtoOutMapper. +/// Covers mapping of domain audit information to API response DTOs with null safety. +@DisplayName("EntityAuditDtoOutMapper Tests") +class EntityAuditDtoOutMapperTest { + + private EntityAuditDtoOutMapper mapper; + + @BeforeEach + void setUp() { + mapper = new EntityAuditDtoOutMapper(); + } + + @Nested + @DisplayName("Single EntityAuditInfo Mapping Tests") + class SingleAuditInfoMappingTests { + + @Test + @DisplayName("Should map EntityAuditInfo with null snapshot to EntityAuditDtoOut") + void shouldMapAuditInfoWithNullSnapshot() { + // Given + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "CREATED", "test-user", null); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getRevisionNumber()).isEqualTo(1L); + assertThat(result.getRevisionType()).isEqualTo("CREATED"); + assertThat(result.getModifiedBy()).isEqualTo("test-user"); + assertThat(result.getSnapshot()).isNull(); + } + + @Test + @DisplayName("Should map EntityAuditInfo with snapshot containing properties and relations") + void shouldMapAuditInfoWithSnapshot() { + // Given + UUID entityId = UUID.randomUUID(); + Instant revisionDate = Instant.now(); + + var propertySnapshot = new EntityAuditInfo.PropertySnapshot(UUID.randomUUID(), "env", "PROD"); + var relationSnapshot = new EntityAuditInfo.RelationSnapshot(UUID.randomUUID(), "dependency", + "service", List.of("service-1", "service-2")); + + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(entityId, "web-service", + "Web API Service", "web-api-v1", List.of(propertySnapshot), List.of(relationSnapshot)); + + var auditInfo = new EntityAuditInfo(5L, revisionDate, "UPDATED", "developer", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getRevisionNumber()).isEqualTo(5L); + assertThat(result.getRevisionType()).isEqualTo("UPDATED"); + assertThat(result.getModifiedBy()).isEqualTo("developer"); + assertThat(result.getRevisionDate()).isEqualTo(revisionDate); + + // Verify snapshot + assertThat(result.getSnapshot()).isNotNull(); + assertThat(result.getSnapshot().getId()).isEqualTo(entityId); + assertThat(result.getSnapshot().getTemplateIdentifier()).isEqualTo("web-service"); + assertThat(result.getSnapshot().getName()).isEqualTo("Web API Service"); + assertThat(result.getSnapshot().getIdentifier()).isEqualTo("web-api-v1"); + + // Verify properties + assertThat(result.getSnapshot().getProperties()).hasSize(1); + assertThat(result.getSnapshot().getProperties().get(0).getName()).isEqualTo("env"); + assertThat(result.getSnapshot().getProperties().get(0).getValue()).isEqualTo("PROD"); + + // Verify relations + assertThat(result.getSnapshot().getRelations()).hasSize(1); + assertThat(result.getSnapshot().getRelations().get(0).getName()).isEqualTo("dependency"); + assertThat(result.getSnapshot().getRelations().get(0).getTargetEntityIdentifiers()) + .containsExactly("service-1", "service-2"); + } + + @Test + @DisplayName("Should map DELETED revision type audit info") + void shouldMapDeletedRevisionType() { + // Given + var propertySnapshot = new EntityAuditInfo.PropertySnapshot(UUID.randomUUID(), "status", + "active"); + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "web-service", + "Service", "web-api", List.of(propertySnapshot), List.of()); + + var auditInfo = new EntityAuditInfo(10L, Instant.now(), "DELETED", "admin", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getRevisionType()).isEqualTo("DELETED"); + assertThat(result.getSnapshot()).isNotNull(); + assertThat(result.getSnapshot().getProperties()).hasSize(1); + } + + @Test + @DisplayName("Should handle null EntityAuditInfo") + void shouldHandleNullAuditInfo() { + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map audit info with empty properties and relations") + void shouldMapAuditInfoWithEmptyCollections() { + // Given + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", + "identifier", List.of(), List.of()); + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSnapshot()).isNotNull(); + assertThat(result.getSnapshot().getProperties()).isEmpty(); + assertThat(result.getSnapshot().getRelations()).isEmpty(); + } + + @Test + @DisplayName("Should map audit info with null properties and relations collections") + void shouldMapAuditInfoWithNullCollections() { + // Given + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", + "identifier", null, null); + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "UPDATED", "user", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSnapshot()).isNotNull(); + assertThat(result.getSnapshot().getProperties()).isEmpty(); + assertThat(result.getSnapshot().getRelations()).isEmpty(); + } + + @Test + @DisplayName("Should map audit info with null targetEntityIdentifiers in relation") + void shouldMapRelationWithNullTargetIdentifiers() { + // Given + var relationSnapshot = new EntityAuditInfo.RelationSnapshot(UUID.randomUUID(), "relation", + "target-template", null); + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", + "id", List.of(), List.of(relationSnapshot)); + + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result.getSnapshot().getRelations()).hasSize(1); + assertThat(result.getSnapshot().getRelations().getFirst().getTargetEntityIdentifiers()) + .isEmpty(); + } + } + + @Nested + @DisplayName("List Mapping Tests") + class ListMappingTests { + + @Test + @DisplayName("Should map list of EntityAuditInfo to list of EntityAuditDtoOut") + void shouldMapAuditInfoList() { + // Given + var auditInfo1 = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user1", null); + var auditInfo2 = new EntityAuditInfo(2L, Instant.now(), "UPDATED", "user2", null); + var auditInfoList = List.of(auditInfo1, auditInfo2); + + // When + List result = mapper.fromEntityAuditInfoList(auditInfoList); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getRevisionNumber()).isEqualTo(1L); + assertThat(result.get(0).getModifiedBy()).isEqualTo("user1"); + assertThat(result.get(1).getRevisionNumber()).isEqualTo(2L); + assertThat(result.get(1).getModifiedBy()).isEqualTo("user2"); + } + + @Test + @DisplayName("Should map list with audit infos containing snapshots") + void shouldMapAuditInfoListWithSnapshots() { + // Given + var snapshot1 = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template1", "Name1", + "id1", List.of(), List.of()); + var snapshot2 = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template2", "Name2", + "id2", List.of(), List.of()); + + var auditInfo1 = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user1", snapshot1); + var auditInfo2 = new EntityAuditInfo(2L, Instant.now(), "UPDATED", "user2", snapshot2); + + // When + List result = mapper + .fromEntityAuditInfoList(List.of(auditInfo1, auditInfo2)); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getSnapshot()).isNotNull(); + assertThat(result.get(0).getSnapshot().getName()).isEqualTo("Name1"); + assertThat(result.get(1).getSnapshot()).isNotNull(); + assertThat(result.get(1).getSnapshot().getName()).isEqualTo("Name2"); + } + + @Test + @DisplayName("Should handle null list") + void shouldHandleNullList() { + // When + List result = mapper.fromEntityAuditInfoList(null); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should handle empty list") + void shouldHandleEmptyList() { + // When + List result = mapper.fromEntityAuditInfoList(List.of()); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should map list with mixed null and non-null snapshots") + void shouldMapListWithMixedSnapshots() { + // Given + var snapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", "id", + List.of(), List.of()); + + var auditInfo1 = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user1", null); + var auditInfo2 = new EntityAuditInfo(2L, Instant.now(), "UPDATED", "user2", snapshot); + + // When + List result = mapper + .fromEntityAuditInfoList(List.of(auditInfo1, auditInfo2)); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getSnapshot()).isNull(); + assertThat(result.get(1).getSnapshot()).isNotNull(); + } + } + + @Nested + @DisplayName("Edge Cases and Defensive Copying Tests") + class EdgeCasesTests { + + @Test + @DisplayName("Should handle property snapshot with null values") + void shouldHandlePropertySnapshotWithNullValues() { + // Given + var propertySnapshot = new EntityAuditInfo.PropertySnapshot(null, null, null); + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", + "id", List.of(propertySnapshot), List.of()); + + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result.getSnapshot().getProperties()).hasSize(1); + assertThat(result.getSnapshot().getProperties().get(0).getId()).isNull(); + assertThat(result.getSnapshot().getProperties().get(0).getName()).isNull(); + assertThat(result.getSnapshot().getProperties().get(0).getValue()).isNull(); + } + + @Test + @DisplayName("Should preserve immutability by copying targetEntityIdentifiers") + void shouldPreserveImmutabilityOfTargetIdentifiers() { + // Given + List originalIdentifiers = new java.util.ArrayList<>(List.of("id1", "id2")); + var relationSnapshot = new EntityAuditInfo.RelationSnapshot(UUID.randomUUID(), "relation", + "target", originalIdentifiers); + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", + "id", List.of(), List.of(relationSnapshot)); + + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + originalIdentifiers.add("id3"); + + // Then - Verify that the DTO list wasn't affected by the original list + // modification + assertThat(result.getSnapshot().getRelations().get(0).getTargetEntityIdentifiers()) + .containsExactly("id1", "id2"); + } + + @Test + @DisplayName("Should handle relation with empty targetEntityIdentifiers") + void shouldHandleRelationWithEmptyTargetIdentifiers() { + // Given + var relationSnapshot = new EntityAuditInfo.RelationSnapshot(UUID.randomUUID(), "relation", + "target", List.of()); + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", + "id", List.of(), List.of(relationSnapshot)); + + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result.getSnapshot().getRelations().get(0).getTargetEntityIdentifiers()).isEmpty(); + } + + @Test + @DisplayName("Should map multiple properties and relations correctly") + void shouldMapMultiplePropertiesAndRelations() { + // Given + var prop1 = new EntityAuditInfo.PropertySnapshot(UUID.randomUUID(), "prop1", "value1"); + var prop2 = new EntityAuditInfo.PropertySnapshot(UUID.randomUUID(), "prop2", "value2"); + var prop3 = new EntityAuditInfo.PropertySnapshot(UUID.randomUUID(), "prop3", "value3"); + + var rel1 = new EntityAuditInfo.RelationSnapshot(UUID.randomUUID(), "rel1", "t1", + List.of("id1")); + var rel2 = new EntityAuditInfo.RelationSnapshot(UUID.randomUUID(), "rel2", "t2", + List.of("id2", "id3")); + + var entitySnapshot = new EntityAuditInfo.EntitySnapshot(UUID.randomUUID(), "template", "Name", + "id", List.of(prop1, prop2, prop3), List.of(rel1, rel2)); + + var auditInfo = new EntityAuditInfo(1L, Instant.now(), "CREATED", "user", entitySnapshot); + + // When + EntityAuditDtoOut result = mapper.fromEntityAuditInfo(auditInfo); + + // Then + assertThat(result.getSnapshot().getProperties()).hasSize(3); + assertThat(result.getSnapshot().getRelations()).hasSize(2); + assertThat(result.getSnapshot().getProperties().get(0).getName()).isEqualTo("prop1"); + assertThat(result.getSnapshot().getProperties().get(1).getName()).isEqualTo("prop2"); + assertThat(result.getSnapshot().getProperties().get(2).getName()).isEqualTo("prop3"); + assertThat(result.getSnapshot().getRelations().get(0).getName()).isEqualTo("rel1"); + assertThat(result.getSnapshot().getRelations().get(1).getName()).isEqualTo("rel2"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapterTest.java new file mode 100644 index 00000000..d0f24634 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAuditAdapterTest.java @@ -0,0 +1,856 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +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.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.hibernate.envers.AuditReader; +import org.hibernate.envers.RevisionType; +import org.hibernate.envers.query.AuditQuery; +import org.hibernate.envers.query.AuditQueryCreator; +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.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; +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.model.entity.PropertyJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationTargetJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; + +/// Unit tests for PostgresEntityAuditAdapter. +/// Covers entity audit history retrieval with Hibernate Envers integration. +@DisplayName("PostgresEntityAuditAdapter Tests") +class PostgresEntityAuditAdapterTest { + + @Mock + private EntityManager entityManager; + + @Mock + private JpaEntityRepository jpaEntityRepository; + + @Mock + private AuditReader auditReader; + + @Mock + private AuditQueryCreator auditQueryCreator; + + @Mock + private AuditQuery auditQuery; + + private PostgresEntityAuditAdapter adapter; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + adapter = new PostgresEntityAuditAdapter(entityManager, jpaEntityRepository); + } + + @Nested + @DisplayName("Get Entity Audit History Tests") + class GetEntityAuditHistoryTests { + + @Test + @DisplayName("Should retrieve audit history for existing entity") + void shouldRetrieveAuditHistoryForExistingEntity() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("test-user"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().revisionNumber()).isEqualTo(1L); + assertThat(result.getFirst().revisionType()).isEqualTo("CREATED"); + assertThat(result.getFirst().modifiedBy()).isEqualTo("test-user"); + } + } + + @Test + @DisplayName("Should throw EntityNotFoundException if entity not found") + void shouldThrowExceptionIfEntityNotFound() { + // Given + String templateIdentifier = "non-existent"; + String entityIdentifier = "non-existent"; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.empty()); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of()); + + // When & Then + assertThatThrownBy( + () -> adapter.getEntityAuditHistory(templateIdentifier, entityIdentifier)) + .isInstanceOf(EntityNotFoundException.class) + .hasMessage("Entity not found with template identifier " + templateIdentifier + + " and entity identifier '" + entityIdentifier + "'"); + } + } + + @Test + @DisplayName("Should retrieve multiple revision entries sorted by revision number descending") + void shouldRetrieveMultipleRevisionsSorted() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + // Create multiple revisions + CustomRevisionEntity rev1 = mock(CustomRevisionEntity.class); + when(rev1.getRev()).thenReturn(3L); + when(rev1.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(rev1.getAuthId()).thenReturn("user1"); + + CustomRevisionEntity rev2 = mock(CustomRevisionEntity.class); + when(rev2.getRev()).thenReturn(2L); + when(rev2.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(rev2.getAuthId()).thenReturn("user2"); + + CustomRevisionEntity rev3 = mock(CustomRevisionEntity.class); + when(rev3.getRev()).thenReturn(1L); + when(rev3.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(rev3.getAuthId()).thenReturn("user3"); + + Object[] revision1 = {jpaEntity, rev1, RevisionType.MOD}; + Object[] revision2 = {jpaEntity, rev2, RevisionType.MOD}; + Object[] revision3 = {jpaEntity, rev3, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()) + .thenReturn(List.of(revision1, revision2, revision3)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + when(auditReader.find(EntityJpaEntity.class, entityId, 3L)).thenReturn(historicalEntity); + when(auditReader.find(EntityJpaEntity.class, entityId, 2L)).thenReturn(historicalEntity); + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result).hasSize(3); + assertThat(result.getFirst().revisionNumber()).isEqualTo(3L); + assertThat(result.get(1).revisionNumber()).isEqualTo(2L); + assertThat(result.get(2).revisionNumber()).isEqualTo(1L); + assertThat(result.get(2).revisionType()).isEqualTo("CREATED"); + } + } + } + + @Nested + @DisplayName("Revision Type Mapping Tests") + class RevisionTypeMappingTests { + + @Test + @DisplayName("Should map RevisionType.ADD to CREATED") + void shouldMapAddToCreated() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("test-user"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().revisionType()).isEqualTo("CREATED"); + } + } + + @Test + @DisplayName("Should map RevisionType.MOD to UPDATED") + void shouldMapModToUpdated() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(2L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("test-user"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.MOD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + when(auditReader.find(EntityJpaEntity.class, entityId, 2L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().revisionType()).isEqualTo("UPDATED"); + } + } + + @Test + @DisplayName("Should map RevisionType.DEL to DELETED and query previous revision for snapshot") + void shouldMapDelToDeletedAndQueryPreviousRevision() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(3L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("admin"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.DEL}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + // For DEL, should query revision 2 (3-1) + when(auditReader.find(EntityJpaEntity.class, entityId, 2L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().revisionType()).isEqualTo("DELETED"); + assertThat(result.getFirst().snapshot()).isNotNull(); + verify(auditReader).find(EntityJpaEntity.class, entityId, 2L); + } + } + } + + @Nested + @DisplayName("Snapshot Mapping Tests") + class SnapshotMappingTests { + + @Test + @DisplayName("Should include properties and relations in snapshot") + void shouldIncludePropertiesAndRelationsInSnapshot() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("test-user"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + PropertyJpaEntity property = mock(PropertyJpaEntity.class); + when(property.getId()).thenReturn(UUID.randomUUID()); + when(property.getName()).thenReturn("environment"); + when(property.getValue()).thenReturn("PROD"); + + RelationTargetJpaEntity target1 = mock(RelationTargetJpaEntity.class); + when(target1.getTargetEntityIdentifier()).thenReturn("svc-1"); + RelationTargetJpaEntity target2 = mock(RelationTargetJpaEntity.class); + when(target2.getTargetEntityIdentifier()).thenReturn("svc-2"); + + RelationJpaEntity relation = mock(RelationJpaEntity.class); + when(relation.getId()).thenReturn(UUID.randomUUID()); + when(relation.getName()).thenReturn("dependencies"); + when(relation.getTargetTemplateIdentifier()).thenReturn("service"); + when(relation.getTargetEntities()).thenReturn(List.of(target1, target2)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of(property)); + when(historicalEntity.getRelations()).thenReturn(List.of(relation)); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().snapshot()).isNotNull(); + assertThat(result.getFirst().snapshot().properties()).hasSize(1); + assertThat(result.getFirst().snapshot().properties().getFirst().name()) + .isEqualTo("environment"); + assertThat(result.getFirst().snapshot().relations()).hasSize(1); + assertThat(result.getFirst().snapshot().relations().getFirst().name()) + .isEqualTo("dependencies"); + assertThat(result.getFirst().snapshot().relations().getFirst().targetEntityIdentifiers()) + .containsExactly("svc-1", "svc-2"); + } + } + + @Test + @DisplayName("Should handle null properties and relations") + void shouldHandleNullPropertiesAndRelations() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("test-user"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(null); + when(historicalEntity.getRelations()).thenReturn(null); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().snapshot()).isNotNull(); + assertThat(result.getFirst().snapshot().properties()).isEmpty(); + assertThat(result.getFirst().snapshot().relations()).isEmpty(); + } + } + } + + @Nested + @DisplayName("Deleted Entity Audit History Tests") + class DeletedEntityAuditHistoryTests { + + @Test + @DisplayName("Should retrieve audit history for deleted entity from audit data") + void shouldRetrieveAuditForDeletedEntity() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api-deleted"; + + // Entity not in current database + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.empty()); + + EntityJpaEntity deletedJpaEntity = mock(EntityJpaEntity.class); + when(deletedJpaEntity.getId()).thenReturn(entityId); + when(deletedJpaEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(deletedJpaEntity.getIdentifier()).thenReturn(entityIdentifier); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(3L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("admin"); + + Object[] auditRevision = {deletedJpaEntity, revisionEntity, RevisionType.DEL}; + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(auditRevision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Deleted Service"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + when(auditReader.find(EntityJpaEntity.class, entityId, 2L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().revisionType()).isEqualTo("DELETED"); + } + } + } + + @Nested + @DisplayName("Modified By Field Tests") + class ModifiedByFieldTests { + + @Test + @DisplayName("Should use authId as modifiedBy when present") + void shouldUseAuthIdAsModifiedBy() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("john.doe@company.com"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().modifiedBy()).isEqualTo("john.doe@company.com"); + } + } + + @Test + @DisplayName("Should use 'system' as modifiedBy when authId is null") + void shouldUseSystemWhenAuthIdIsNull() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn(null); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of()); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().modifiedBy()).isEqualTo("system"); + } + } + } + + @Nested + @DisplayName("Relation Target Entities Tests") + class RelationTargetEntitiesTests { + + @Test + @DisplayName("Should handle mapping changes correctly across target list modifications") + void shouldHandleTargetEntityListMapping() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("test-user"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + RelationTargetJpaEntity target1 = mock(RelationTargetJpaEntity.class); + when(target1.getTargetEntityIdentifier()).thenReturn("id1"); + RelationTargetJpaEntity target2 = mock(RelationTargetJpaEntity.class); + when(target2.getTargetEntityIdentifier()).thenReturn("id2"); + + List targetEntities = new java.util.ArrayList<>( + List.of(target1, target2)); + RelationJpaEntity relation = mock(RelationJpaEntity.class); + when(relation.getId()).thenReturn(UUID.randomUUID()); + when(relation.getName()).thenReturn("deps"); + when(relation.getTargetTemplateIdentifier()).thenReturn("service"); + when(relation.getTargetEntities()).thenReturn(targetEntities); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of(relation)); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Modify original list setup + RelationTargetJpaEntity target3 = mock(RelationTargetJpaEntity.class); + targetEntities.add(target3); + + // Then - snapshot mapping guarantees isolation + assertThat(result.getFirst().snapshot().relations().getFirst().targetEntityIdentifiers()) + .containsExactly("id1", "id2"); + } + } + + @Test + @DisplayName("Should handle null target entities gracefully") + void shouldHandleNullTargetEntities() { + // Given + UUID entityId = UUID.randomUUID(); + String templateIdentifier = "web-service"; + String entityIdentifier = "web-api"; + + EntityJpaEntity jpaEntity = mock(EntityJpaEntity.class); + when(jpaEntity.getId()).thenReturn(entityId); + + CustomRevisionEntity revisionEntity = mock(CustomRevisionEntity.class); + when(revisionEntity.getRev()).thenReturn(1L); + when(revisionEntity.getRevisionTimestamp()).thenReturn(System.currentTimeMillis()); + when(revisionEntity.getAuthId()).thenReturn("test-user"); + + Object[] revision = {jpaEntity, revisionEntity, RevisionType.ADD}; + + when(jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier)).thenReturn(Optional.of(jpaEntity)); + + try ( + MockedStatic auditReaderFactoryMock = org.mockito.Mockito + .mockStatic(org.hibernate.envers.AuditReaderFactory.class)) { + auditReaderFactoryMock + .when(() -> org.hibernate.envers.AuditReaderFactory.get(entityManager)) + .thenReturn(auditReader); + + when(auditReader.createQuery()).thenReturn(auditQueryCreator); + when(auditQueryCreator.forRevisionsOfEntity(EntityJpaEntity.class, false, true)) + .thenReturn(auditQuery); + when(auditQuery.add(any())).thenReturn(auditQuery); + when(auditQuery.addOrder(any())).thenReturn(auditQuery); + when(auditQuery.getResultList()).thenReturn(List.of(revision)); + + RelationJpaEntity relation = mock(RelationJpaEntity.class); + when(relation.getId()).thenReturn(UUID.randomUUID()); + when(relation.getName()).thenReturn("deps"); + when(relation.getTargetTemplateIdentifier()).thenReturn("service"); + when(relation.getTargetEntities()).thenReturn(null); + + EntityJpaEntity historicalEntity = mock(EntityJpaEntity.class); + when(historicalEntity.getId()).thenReturn(entityId); + when(historicalEntity.getTemplateIdentifier()).thenReturn(templateIdentifier); + when(historicalEntity.getName()).thenReturn("Web API"); + when(historicalEntity.getIdentifier()).thenReturn(entityIdentifier); + when(historicalEntity.getProperties()).thenReturn(List.of()); + when(historicalEntity.getRelations()).thenReturn(List.of(relation)); + + when(auditReader.find(EntityJpaEntity.class, entityId, 1L)).thenReturn(historicalEntity); + + // When + List result = adapter.getEntityAuditHistory(templateIdentifier, + entityIdentifier); + + // Then + assertThat(result.getFirst().snapshot().relations().getFirst().targetEntityIdentifiers()) + .isEmpty(); + } + } + } +} 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..f46ea34a --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java @@ -0,0 +1,106 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 for Hibernate + * Envers revisions. + */ +@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"); + } + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java new file mode 100644 index 00000000..27ff96db --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java @@ -0,0 +1,331 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; + +/// Unit tests for UserIdentityProviderHolder. +/// Covers the static holder pattern used to bridge Spring-managed beans with Hibernate Envers. +@DisplayName("UserIdentityProviderHolder Tests") +class UserIdentityProviderHolderTest { + + private UserIdentityProvider mockProvider; + + @BeforeEach + void setUp() { + // Reset the static holder before each test + resetHolder(); + mockProvider = mock(UserIdentityProvider.class); + } + + /// Helper to reset the static holder for test isolation + private void resetHolder() { + try { + java.lang.reflect.Field field = UserIdentityProviderHolder.class + .getDeclaredField("userIdentityProvider"); + field.setAccessible(true); + + // If the field is an AtomicReference, clear its inner value instead of + // trying to overwrite the static final field itself (which is forbidden). + if (java.util.concurrent.atomic.AtomicReference.class.isAssignableFrom(field.getType())) { + @SuppressWarnings("unchecked") + java.util.concurrent.atomic.AtomicReference ref = (java.util.concurrent.atomic.AtomicReference) field + .get(null); + if (ref != null) { + ref.set(null); + } + } else { + // Fallback for non-final/non-AtomicReference implementations + field.set(null, null); + } + } catch (Exception e) { + throw new RuntimeException("Failed to reset UserIdentityProviderHolder", e); + } + } + + @Nested + @DisplayName("Initialization Tests") + class InitializationTests { + + @Test + @DisplayName("Should initialize static provider after PostConstruct") + void shouldInitializeStaticProvider() { + // Given + var holder = new UserIdentityProviderHolder(mockProvider); + + // When + holder.init(); + + // Then + assertThat(UserIdentityProviderHolder.getUserIdentityProvider()).isNotNull() + .isEqualTo(mockProvider); + } + + @Test + @DisplayName("Should use injected provider during initialization") + void shouldUseInjectedProvider() { + // Given + UserIdentityProvider customProvider = mock(UserIdentityProvider.class); + var holder = new UserIdentityProviderHolder(customProvider); + + // When + holder.init(); + + // Then + assertThat(UserIdentityProviderHolder.getUserIdentityProvider()).isEqualTo(customProvider); + } + + @Test + @DisplayName("Should throw IllegalStateException if accessed before initialization") + void shouldThrowExceptionIfNotInitialized() { + // When & Then + assertThatThrownBy(UserIdentityProviderHolder::getUserIdentityProvider) + .isInstanceOf(IllegalStateException.class).hasMessage( + "UserIdentityProviderHolder not initialized. Spring context may not be loaded."); + } + + @Test + @DisplayName("Should reinitialize with new provider") + void shouldReinitializeWithNewProvider() { + // Given + var provider1 = mock(UserIdentityProvider.class); + var provider2 = mock(UserIdentityProvider.class); + + var holder1 = new UserIdentityProviderHolder(provider1); + holder1.init(); + + // When - Reinitialize with different provider + var holder2 = new UserIdentityProviderHolder(provider2); + holder2.init(); + + // Then + assertThat(UserIdentityProviderHolder.getUserIdentityProvider()).isEqualTo(provider2); + } + } + + @Nested + @DisplayName("Thread Safety Tests") + class ThreadSafetyTests { + + @Test + @DisplayName("Should handle concurrent initialization safely") + void shouldHandleConcurrentInitializationSafely() throws InterruptedException { + // Given + var provider1 = mock(UserIdentityProvider.class); + var provider2 = mock(UserIdentityProvider.class); + + var thread1Results = new java.util.concurrent.CountDownLatch(1); + var thread2Results = new java.util.concurrent.CountDownLatch(1); + + // When - Initialize from two threads + Thread t1 = new Thread(() -> { + var holder = new UserIdentityProviderHolder(provider1); + holder.init(); + thread1Results.countDown(); + }); + + Thread t2 = new Thread(() -> { + var holder = new UserIdentityProviderHolder(provider2); + holder.init(); + thread2Results.countDown(); + }); + + t1.start(); + t2.start(); + thread1Results.await(); + thread2Results.await(); + + // Then - Should have some provider set (either one is acceptable due to race + // condition) + assertThat(UserIdentityProviderHolder.getUserIdentityProvider()).isNotNull(); + } + + @Test + @DisplayName("Should allow concurrent reads after initialization") + void shouldAllowConcurrentReadsAfterInitialization() { + // Given + var provider = mock(UserIdentityProvider.class); + var holder = new UserIdentityProviderHolder(provider); + holder.init(); + + var readCount = 100; + var barrier = new java.util.concurrent.CyclicBarrier(readCount); + + // When - Read from multiple threads + for (int i = 0; i < readCount; i++) { + new Thread(() -> { + try { + barrier.await(); + UserIdentityProvider result = UserIdentityProviderHolder.getUserIdentityProvider(); + assertThat(result).isEqualTo(provider); + } catch (Exception e) { + throw new RuntimeException(e); + } + }).start(); + } + // Then - Verify provider is still accessible + assertThat(UserIdentityProviderHolder.getUserIdentityProvider()).isEqualTo(provider); + } + } + + @Nested + @DisplayName("Static Accessor Tests") + class StaticAccessorTests { + + @Test + @DisplayName("Should return the initialized provider") + void shouldReturnInitializedProvider() { + // Given + var holder = new UserIdentityProviderHolder(mockProvider); + holder.init(); + + // When + UserIdentityProvider result = UserIdentityProviderHolder.getUserIdentityProvider(); + + // Then + assertThat(result).isEqualTo(mockProvider); + } + + @Test + @DisplayName("Should persist provider across multiple accesses") + void shouldPersistProviderAcrossAccesses() { + // Given + var holder = new UserIdentityProviderHolder(mockProvider); + holder.init(); + + // When & Then + UserIdentityProvider result1 = UserIdentityProviderHolder.getUserIdentityProvider(); + UserIdentityProvider result2 = UserIdentityProviderHolder.getUserIdentityProvider(); + UserIdentityProvider result3 = UserIdentityProviderHolder.getUserIdentityProvider(); + + assertThat(result1).isEqualTo(result2).isEqualTo(result3).isEqualTo(mockProvider); + } + + @Test + @DisplayName("Should work as bridge for Hibernate Envers listeners") + void shouldBridgeHibernateEnversAccess() { + // Given + var provider = mock(UserIdentityProvider.class); + var holder = new UserIdentityProviderHolder(provider); + holder.init(); + + // When - Simulate Envers listener access (non-Spring context) + UserIdentityProvider enversProvider = UserIdentityProviderHolder.getUserIdentityProvider(); + + // Then + assertThat(enversProvider).isNotNull().isEqualTo(provider); + } + } + + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + @DisplayName("Should accept UserIdentityProvider in constructor") + void shouldAcceptProviderInConstructor() { + // When + UserIdentityProviderHolder holder = new UserIdentityProviderHolder(mockProvider); + + // Then + assertThat(holder).isNotNull(); + } + + @Test + @DisplayName("Should accept null provider in constructor (initialization required)") + void shouldAcceptNullProviderInConstructor() { + // When & Then + UserIdentityProviderHolder holder = new UserIdentityProviderHolder(null); + assertThat(holder).isNotNull(); + } + + @Test + @DisplayName("Should initialize with null provider and throw on access") + void shouldThrowWhenInitializedWithNullProvider() { + // Given + var holder = new UserIdentityProviderHolder(null); + holder.init(); + + // When & Then - Null provider means userIdentityProvider becomes null + assertThatThrownBy(UserIdentityProviderHolder::getUserIdentityProvider) + .isInstanceOf(IllegalStateException.class); + } + } + + @Nested + @DisplayName("Error Handling Tests") + class ErrorHandlingTests { + + @Test + @DisplayName("Should provide clear error message on uninitialized access") + void shouldProvideClearErrorMessage() { + // When & Then + assertThatThrownBy(UserIdentityProviderHolder::getUserIdentityProvider) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("UserIdentityProviderHolder not initialized") + .hasMessageContaining("Spring context"); + } + + @Test + @DisplayName("Should handle provider access after context initialization failure") + void shouldHandleContextInitializationFailure() { + // When - No initialization happens + // Then - Accessing without init should fail + assertThatThrownBy(UserIdentityProviderHolder::getUserIdentityProvider) + .isInstanceOf(IllegalStateException.class); + } + } + + @Nested + @DisplayName("Integration Pattern Tests") + class IntegrationPatternTests { + + @Test + @DisplayName("Should follow Spring component pattern with injection") + void shouldFollowSpringComponentPattern() { + // Given + var holder = new UserIdentityProviderHolder(mockProvider); + + // When + holder.init(); + + // Then - Simulates what Spring would do + UserIdentityProvider provider = UserIdentityProviderHolder.getUserIdentityProvider(); + assertThat(provider).isEqualTo(mockProvider); + } + + @Test + @DisplayName("Should enable Hibernate Envers listener to access provider") + void shouldEnableHibernateEnversAccess() { + // Given + var provider = mock(UserIdentityProvider.class); + var holder = new UserIdentityProviderHolder(provider); + holder.init(); + UserIdentityProvider enversAccessibleProvider = UserIdentityProviderHolder + .getUserIdentityProvider(); + + // Then + assertThat(enversAccessibleProvider).isNotNull().isEqualTo(provider); + } + + @Test + @DisplayName("Should serve as singleton-like holder for Spring-unmanaged code") + void shouldServeSingletonPatternForNonSpringCode() { + // Given + var holder = new UserIdentityProviderHolder(mockProvider); + holder.init(); + + UserIdentityProvider access1 = UserIdentityProviderHolder.getUserIdentityProvider(); + UserIdentityProvider access2 = UserIdentityProviderHolder.getUserIdentityProvider(); + + assertThat(access1).isSameAs(access2).isEqualTo(mockProvider); + } + } +} diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index 4d0abf2e..6dd4f384 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 @@ -127,7 +127,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 @@ -305,5 +306,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 cb7e8e3e..96ef86a4 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 @@ -4,9 +4,6 @@ INSERT INTO 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'), @@ -21,7 +18,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" + } +}