diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 440c889..efad6cf 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -39,6 +39,9 @@ erDiagram CONSUMER ||--o{ PRODUCT_CONSUMER : consumes PRODUCT_CONSUMER ||--o{ PRODUCT_CONSUMER_ATTRIBUTE : has PRODUCT_TYPE ||--o{ PRODUCT : categorizes + ATTRIBUTE_DEFINITION ||--o{ ATTRIBUTE_DEFINITION_SCOPE : "bound via" + ATTRIBUTE_SCOPE ||--o{ ATTRIBUTE_DEFINITION_SCOPE : "bound via" + ATTRIBUTE_DEFINITION_SCOPE ||--o{ ATTRIBUTE_VALUE : has ORGANISATION { BIGSERIAL id PK @@ -116,6 +119,53 @@ erDiagram TIMESTAMP event_time VARCHAR performed_by } + ATTRIBUTE_SCOPE { + BIGSERIAL id PK + VARCHAR code + VARCHAR table_name + VARCHAR description + } + ATTRIBUTE_DEFINITION { + BIGSERIAL id PK + VARCHAR namespace + VARCHAR name + VARCHAR display_name + TEXT description + VARCHAR data_type + BOOLEAN multi_valued + JSONB allowed_values + VARCHAR validation_pattern + JSONB classification + BOOLEAN sensitive + BOOLEAN is_deleted + TIMESTAMP created_at + VARCHAR created_by + TIMESTAMP updated_at + VARCHAR updated_by + } + ATTRIBUTE_DEFINITION_SCOPE { + BIGSERIAL id PK + BIGINT attribute_definition_id FK + BIGINT attribute_scope_id FK + BOOLEAN required + JSONB default_value + BOOLEAN is_deleted + TIMESTAMP created_at + VARCHAR created_by + TIMESTAMP updated_at + VARCHAR updated_by + } + ATTRIBUTE_VALUE { + BIGSERIAL id PK + BIGINT attribute_definition_scope_id FK + BIGINT entity_id + JSONB value + BOOLEAN is_deleted + TIMESTAMP created_at + VARCHAR created_by + TIMESTAMP updated_at + VARCHAR updated_by + } ``` --- @@ -284,6 +334,104 @@ Usage: --- +### attribute_scope +Which core entity types may carry dynamic policy attributes, and the table `attribute_value.entity_id` resolves against for that scope. + +Columns: +- `id` BIGSERIAL, primary key +- `code` VARCHAR(50), not null — unique scope identifier (e.g. `PRODUCT`) +- `table_name` VARCHAR(150), not null — the table `attribute_value.entity_id` is a row id in, for this scope +- `description` VARCHAR(500), nullable + +Constraints: +- UNIQUE on `code` (`uq_attribute_scope__code`) + +Usage: +- Seeded by migration with one row per core entity type: `ORGANISATION` (`organisation`), `CONSUMER` (`consumer`), `PRODUCER` (`producer`), `PRODUCT` (`product`), `SUBSCRIPTION` (`product_consumer`). +- Referenced by `attribute_definition_scope` to say which scopes an attribute definition applies to. + +--- + +### attribute_definition +Vocabulary of policy attributes: name, type, and validation metadata, independent of which scope(s) it applies to. + +Columns: +- `id` BIGSERIAL, primary key +- `namespace` VARCHAR(150), not null +- `name` VARCHAR(150), not null +- `display_name` VARCHAR(255), nullable +- `description` TEXT, not null +- `data_type` VARCHAR(50), not null +- `multi_valued` BOOLEAN, not null, default FALSE +- `allowed_values` JSONB, nullable +- `validation_pattern` VARCHAR(500), nullable +- `classification` JSONB, nullable +- `sensitive` BOOLEAN, not null, default FALSE +- `is_deleted` BOOLEAN, not null, default FALSE +- `created_at` TIMESTAMP, not null, default `now()` +- `created_by` VARCHAR(255), not null +- `updated_at` TIMESTAMP, nullable +- `updated_by` VARCHAR(255), nullable + +Constraints: +- UNIQUE on (`namespace`, `name`) (`uq_attribute_definition__namespace_name`) + +Usage: +- Defines the shape of a policy attribute (e.g. data type, whether it can hold multiple values, allowed values, sensitivity) independently of where it can be attached. + +--- + +### attribute_definition_scope +Which scopes an `attribute_definition` is valid on, whether required there, and its default value. + +Columns: +- `id` BIGSERIAL, primary key +- `attribute_definition_id` BIGINT, not null, foreign key → `attribute_definition(id)` +- `attribute_scope_id` BIGINT, not null, foreign key → `attribute_scope(id)` +- `required` BOOLEAN, not null, default FALSE +- `default_value` JSONB, nullable +- `is_deleted` BOOLEAN, not null, default FALSE +- `created_at` TIMESTAMP, not null, default `now()` +- `created_by` VARCHAR(255), not null +- `updated_at` TIMESTAMP, nullable +- `updated_by` VARCHAR(255), nullable + +Constraints: +- UNIQUE on (`attribute_definition_id`, `attribute_scope_id`) (`uq_attribute_definition_scope__definition_scope`) +- Index on `attribute_definition_id` (`idx_attribute_definition_scope__attribute_definition_id`) +- Index on `attribute_scope_id` (`idx_attribute_definition_scope__attribute_scope_id`) + +Usage: +- Binds a definition to one or more scopes, controlling per-scope requiredness and default. + +--- + +### attribute_value +Actual policy attribute values recorded against a specific entity. + +Columns: +- `id` BIGSERIAL, primary key +- `attribute_definition_scope_id` BIGINT, not null, foreign key → `attribute_definition_scope(id)` +- `entity_id` BIGINT, not null — polymorphic reference: the primary key of the row in the table named by the value's `attribute_scope.table_name`. Not a declared foreign key, since the target table varies by scope. +- `value` JSONB, not null +- `is_deleted` BOOLEAN, not null, default FALSE +- `created_at` TIMESTAMP, not null, default `now()` +- `created_by` VARCHAR(255), not null +- `updated_at` TIMESTAMP, nullable +- `updated_by` VARCHAR(255), nullable + +Constraints: +- Index on `entity_id` (`idx_attribute_value__entity_id`) +- Partial UNIQUE index on (`attribute_definition_scope_id`, `entity_id`, `value`) WHERE `is_deleted = FALSE` (`uq_attr_value_live`) — an idempotency guard against persisting an exact-duplicate live value; it does not by itself enforce "one live value per entity" for single-valued attributes (that check spans `attribute_definition.multi_valued` and is left to the service layer that writes these rows) + +Soft-delete triggers: +- `trg_organisation_attribute_value_soft_delete`, `trg_consumer_attribute_value_soft_delete`, `trg_producer_attribute_value_soft_delete`, `trg_product_attribute_value_soft_delete`, `trg_product_consumer_attribute_value_soft_delete` — one `AFTER DELETE` trigger per owning table (`organisation`, `consumer`, `producer`, `product`, `product_consumer`), all calling the shared function `fn_attribute_value_soft_delete_on_entity_delete()`. When a row in one of those tables is deleted, every live (`is_deleted = FALSE`) `attribute_value` row scoped to that table and entity id is set `is_deleted = TRUE` rather than deleted or left orphaned. + +Usage: +- Stores the actual attribute values used to build the OPA data bundle for policy decisions, keyed by which entity (organisation, consumer, producer, product, or subscription) they describe. + +--- + ## Migration Notes - Schema is versioned and applied with Flyway on application startup. - Foreign keys enforce referential integrity among core entities. diff --git a/pom.xml b/pom.xml index e28a186..9560a2c 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,7 @@ 1.84 6.5.9 5.4.3 + 10.1.59 **/config/**, **/dto/**, **/entity/**, @@ -197,6 +198,16 @@ commons-lang3 3.18.0 + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java index 88e12cf..ff5939d 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java @@ -42,8 +42,10 @@ public ConfigurationController(ConfigurationProvider configurationProvider) { @PreAuthorize("hasAuthority('ROLE_management-node:access_producer_configurations')") @Operation( summary = "Get Federator Producer configuration", - description = - "Returns configuration for the authenticated client, optionally scoped to a specific producer.", + description = "Returns configuration for the authenticated client, optionally scoped to a specific" + + " producer. Each producer, its allowed consumers, their organisations, and each" + + " subscription carry their live policy attributes (policyAttributes /" + + " organisationPolicyAttributes), returned as an empty array when none are set.", security = {@SecurityRequirement(name = "bearerAuth")}) @ApiResponse( responseCode = "200", diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java index f327ab6..ee70a99 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java @@ -35,6 +35,7 @@ public ProductConsumerDTO toDto(ProductConsumer entity) { } ProductConsumerDTO dto = ProductConsumerDTO.builder() + .id(entity.getId()) .productId(entity.getProduct() != null ? entity.getProduct().getId() : null) .consumerId(entity.getConsumer() != null ? entity.getConsumer().getId() : null) .grantedTs(entity.getGrantedTs()) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java index 6ec60ff..f319f28 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java @@ -39,4 +39,7 @@ public class ConsumerDTO { private String scheduleExpression; private final List attributes = new ArrayList<>(); + + private final List policyAttributes = new ArrayList<>(); + private final List organisationPolicyAttributes = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeDTO.java new file mode 100644 index 0000000..5c0d694 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeDTO.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A policy attribute resolved from the {@code attribute_scope}/{@code attribute_definition}/ + * {@code attribute_value} schema (added by PR #69) - the same three fields as {@link + * AttributesDTO} (the legacy {@code product_consumer_attribute}-backed representation), so it + * reads as a drop-in "policy" counterpart rather than a new shape to learn. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PolicyAttributeDTO { + + /** The attribute's dotted {@code namespace.name} logical identifier (e.g. {@code "policy.risk-tier"}). */ + private String name; + + private String value; + private String type; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java index aaa858f..92c5b46 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java @@ -41,4 +41,6 @@ public class ProducerDTO { private BigDecimal port; private Boolean tls; private String idpClientId; + + private final List policyAttributes = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java index bf1ac6f..f597843 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java @@ -26,6 +26,11 @@ @NoArgsConstructor @AllArgsConstructor public class ProductConsumerDTO { + /** The underlying {@code product_consumer.id} - not exposed to callers, only needed + * internally to key {@code SUBSCRIPTION}-scope policy attribute lookups. */ + @JsonIgnore + private Long id; + @JsonIgnore private Long productId; @@ -42,4 +47,5 @@ public class ProductConsumerDTO { private String scheduleExpression; private String destination; private final List attributes = new ArrayList<>(); + private final List policyAttributes = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeAuditFields.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeAuditFields.java new file mode 100644 index 0000000..29c6936 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeAuditFields.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.sql.Timestamp; +import lombok.Getter; +import lombok.Setter; + +/** + * Shared soft-delete and audit columns for the policy attribute schema entities + * ({@link AttributeDefinition}, {@link AttributeDefinitionScope}, {@link AttributeValue}). + */ +@Getter +@Setter +@MappedSuperclass +public abstract class AttributeAuditFields { + + @NotNull + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @NotNull + @Column(name = "created_at", nullable = false) + private Timestamp createdAt; + + @Size(max = 255) + @NotNull + @Column(name = "created_by", nullable = false, length = 255) + private String createdBy; + + @Column(name = "updated_at") + private Timestamp updatedAt; + + @Size(max = 255) + @Column(name = "updated_by", length = 255) + private String updatedBy; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinition.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinition.java new file mode 100644 index 0000000..6338baa --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinition.java @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Setter +@Entity +@Table(name = "attribute_definition") +public class AttributeDefinition extends AttributeAuditFields { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Size(max = 150) + @NotNull + @Column(name = "namespace", nullable = false, length = 150) + private String namespace; + + @Size(max = 150) + @NotNull + @Column(name = "name", nullable = false, length = 150) + private String name; + + @Size(max = 255) + @Column(name = "display_name", length = 255) + private String displayName; + + @NotNull + @Column(name = "description", nullable = false) + private String description; + + @Size(max = 50) + @NotNull + @Column(name = "data_type", nullable = false, length = 50) + private String dataType; + + @NotNull + @Column(name = "multi_valued", nullable = false) + private Boolean multiValued = false; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "allowed_values") + private String allowedValues; + + @Size(max = 500) + @Column(name = "validation_pattern", length = 500) + private String validationPattern; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "classification") + private String classification; + + @NotNull + @Column(name = "sensitive", nullable = false) + private Boolean sensitive = false; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinitionScope.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinitionScope.java new file mode 100644 index 0000000..364ab43 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinitionScope.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Setter +@Entity +@Table(name = "attribute_definition_scope") +public class AttributeDefinitionScope extends AttributeAuditFields { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "attribute_definition_id", nullable = false) + private AttributeDefinition attributeDefinition; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "attribute_scope_id", nullable = false) + private AttributeScope attributeScope; + + @NotNull + @Column(name = "required", nullable = false) + private Boolean required = false; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "default_value") + private String defaultValue; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeScope.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeScope.java new file mode 100644 index 0000000..eb8f553 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeScope.java @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "attribute_scope") +public class AttributeScope { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Size(max = 50) + @NotNull + @Column(name = "code", nullable = false, length = 50) + private String code; + + @Size(max = 150) + @NotNull + @Column(name = "table_name", nullable = false, length = 150) + private String tableName; + + @Size(max = 500) + @Column(name = "description", length = 500) + private String description; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeValue.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeValue.java new file mode 100644 index 0000000..64ccd63 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeValue.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Setter +@Entity +@Table(name = "attribute_value") +public class AttributeValue extends AttributeAuditFields { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "attribute_definition_scope_id", nullable = false) + private AttributeDefinitionScope attributeDefinitionScope; + + /** + * Polymorphic reference: the primary key of the row in the table named by + * {@code attributeDefinitionScope.attributeScope.tableName}. Not a JPA relationship + * because the target entity type varies by scope; see the migration's soft-delete + * trigger for how this is enforced at the database level. + */ + @NotNull + @Column(name = "entity_id", nullable = false) + private Long entityId; + + @NotNull + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "value", nullable = false) + private String value; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepository.java new file mode 100644 index 0000000..a740c6b --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepository.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; + +/** + * Repository interface for managing {@link AttributeDefinition} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeDefinition}. + */ +@Repository +public interface AttributeDefinitionRepository extends JpaRepository { + + Optional findByNamespaceAndName(String namespace, String name); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepository.java new file mode 100644 index 0000000..27104c5 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepository.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; + +/** + * Repository interface for managing {@link AttributeDefinitionScope} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeDefinitionScope}. + */ +@Repository +public interface AttributeDefinitionScopeRepository extends JpaRepository { + + List findByAttributeDefinitionId(Long attributeDefinitionId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepository.java new file mode 100644 index 0000000..21ac1a9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepository.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; + +/** + * Repository interface for managing {@link AttributeScope} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeScope}. + */ +@Repository +public interface AttributeScopeRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepository.java new file mode 100644 index 0000000..11bbcea --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepository.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; + +/** + * Repository interface for managing {@link AttributeValue} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeValue}. + */ +@Repository +public interface AttributeValueRepository extends JpaRepository { + + List findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + Long attributeDefinitionScopeId, Long entityId); + + /** + * Every live (non-soft-deleted) attribute value recorded against one entity within one + * {@code attribute_scope.code}, with its defining {@code attribute_definition_scope}/ + * {@code attribute_definition} eagerly fetched so callers can read {@code namespace}/ + * {@code name}/{@code data_type} without a second query per row. + * + * @param entityId the polymorphic entity id (e.g. a {@code producer.id} or {@code consumer.id}) + * @param scopeCode the {@code attribute_scope.code} to filter to (e.g. {@code "PRODUCER"}) + */ + @Query("SELECT av FROM AttributeValue av " + + "JOIN FETCH av.attributeDefinitionScope ads " + + "JOIN FETCH ads.attributeDefinition ad " + + "WHERE av.entityId = :entityId " + + "AND ads.attributeScope.code = :scopeCode " + + "AND av.isDeleted = false") + List findLiveByEntityIdAndScopeCode( + @Param("entityId") Long entityId, @Param("scopeCode") String scopeCode); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScope.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScope.java new file mode 100644 index 0000000..1731246 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScope.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +/** + * The {@code attribute_scope.code} values this change resolves policy attributes for, on {@code + * GET /api/v1/configuration/producer}: the producer itself, each allowed consumer, each of those + * consumers' organisations, and each subscription ({@code product_consumer}). Not a general + * registry of every {@code attribute_scope} row (e.g. {@code PRODUCT} is seeded but out of scope + * for this change - see design.md). + */ +public enum PolicyAttributeScope { + PRODUCER("PRODUCER"), + CONSUMER("CONSUMER"), + ORGANISATION("ORGANISATION"), + SUBSCRIPTION("SUBSCRIPTION"); + + private final String code; + + PolicyAttributeScope(String code) { + this.code = code; + } + + /** The {@code attribute_scope.code} value this constant corresponds to. */ + public String code() { + return code; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeService.java new file mode 100644 index 0000000..11acdc1 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeService.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import java.util.List; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.PolicyAttributeDTO; + +/** + * Resolves every live policy attribute recorded against one entity within one {@link + * PolicyAttributeScope}. + */ +public interface PolicyAttributeService { + + /** + * @param entityId the polymorphic entity id (e.g. a {@code producer.id} or {@code + * consumer.id}) + * @param scope which {@code attribute_scope} to resolve attributes for + * @return the entity's live policy attributes for that scope, or an empty list (never + * {@code null}) if it has none + */ + List findAttributes(Long entityId, PolicyAttributeScope scope); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImpl.java new file mode 100644 index 0000000..9126cc1 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImpl.java @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.PolicyAttributeDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeValueRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeService; + +@Service +public class PolicyAttributeServiceImpl implements PolicyAttributeService { + + private final AttributeValueRepository attributeValueRepository; + private final ObjectMapper objectMapper; + + public PolicyAttributeServiceImpl(AttributeValueRepository attributeValueRepository, ObjectMapper objectMapper) { + this.attributeValueRepository = attributeValueRepository; + this.objectMapper = objectMapper; + } + + @Override + public List findAttributes(Long entityId, PolicyAttributeScope scope) { + return attributeValueRepository.findLiveByEntityIdAndScopeCode(entityId, scope.code()).stream() + .map(this::toDto) + .toList(); + } + + private PolicyAttributeDTO toDto(AttributeValue attributeValue) { + AttributeDefinition definition = + attributeValue.getAttributeDefinitionScope().getAttributeDefinition(); + return PolicyAttributeDTO.builder() + .name(definition.getNamespace() + "." + definition.getName()) + .value(renderValue(attributeValue.getValue())) + .type(definition.getDataType()) + .build(); + } + + /** + * Renders a stored {@code attribute_value.value} (JSON text) as plain text - a JSON string's + * quotes are stripped, a number/boolean is rendered as-is. Falls back to the raw stored text + * on a parse failure rather than throwing: this is a display-layer concern, not a policy + * decision to compile a predicate against, so failing softly here is the right trade-off (see + * design.md - Risks). + */ + private String renderValue(String rawJson) { + try { + return objectMapper.readTree(rawJson).asText(); + } catch (JsonProcessingException e) { + return rawJson; + } + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java index 68d790c..c66c815 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java @@ -21,6 +21,8 @@ import org.springframework.stereotype.Service; import uk.gov.dbt.ndtp.ia.node.management.model.dto.*; import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; @@ -39,6 +41,8 @@ public class ConfigurationProviderImpl implements ConfigurationProvider { private final CertificateValidationProvider certificateValidationProvider; + private final PolicyAttributeService policyAttributeService; + /** * Constructs a new ConfigurationProviderImpl with required services. * @@ -46,17 +50,20 @@ public class ConfigurationProviderImpl implements ConfigurationProvider { * @param consumerAllowedDataProviders the product consumer service * @param producerService the producer service * @param certificateValidationProvider the certificate validation provider + * @param policyAttributeService resolves policy attributes for the producer config response */ public ConfigurationProviderImpl( ConsumerService consumerService, ProductConsumerService consumerAllowedDataProviders, ProducerService producerService, - CertificateValidationProvider certificateValidationProvider) { + CertificateValidationProvider certificateValidationProvider, + PolicyAttributeService policyAttributeService) { this.consumerService = consumerService; this.productConsumerService = consumerAllowedDataProviders; this.producerService = producerService; this.certificateValidationProvider = certificateValidationProvider; + this.policyAttributeService = policyAttributeService; } /** @@ -76,7 +83,12 @@ private static boolean isValidGrantedTs(Timestamp grantedTs, BigDecimal validity @Override public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumerId) { - List consumers = getFilteredConsumers(clientId, consumerId); + List consumers = consumerService.findByIdpClientId(clientId); + if (consumerId.isPresent()) { + consumers = consumers.stream() + .filter(consumer -> consumer.getId().equals(consumerId.get())) + .toList(); + } List consumerIds = consumers.stream().map(ConsumerDTO::getId).toList(); List validProductConsumers = getValidProductConsumers(consumers); @@ -144,13 +156,21 @@ private List getValidProductConsumers(List cons @Override public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId) { - List producers = getFilteredActiveProducers(clientId, producerId); + List producers = producerService.getProducersByClientId(clientId).stream() + .filter(ProducerDTO::getActive) + .toList(); + if (producerId.isPresent()) { + producers = producers.stream() + .filter(producer -> producerId.get().equals(producer.getId())) + .toList(); + } List dataProviderIds = collectDataProviderIds(producers); // Get allowed consumers (not directly used but might be needed for side effects) consumerService.getConsumersOfProviders(dataProviderIds); populateConsumersForProducers(producers); + populatePolicyAttributes(producers); return ProducerConfigDTO.builder() .clientId(clientId) @@ -158,46 +178,6 @@ public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional getFilteredConsumers(String clientId, Optional consumerId) { - List consumers = consumerService.findByIdpClientId(clientId); - - if (consumerId.isPresent()) { - consumers = consumers.stream() - .filter(consumer -> consumer.getId().equals(consumerId.get())) - .toList(); - } - - return consumers; - } - - /** - * Filters active producers by client ID and optional producer ID. - * - * @param clientId the client ID - * @param producerId the optional producer ID - * @return a list of filtered active producers - */ - private List getFilteredActiveProducers(String clientId, Optional producerId) { - List producers = producerService.getProducersByClientId(clientId).stream() - .filter(ProducerDTO::getActive) - .toList(); - - if (producerId.isPresent()) { - producers = producers.stream() - .filter(producer -> producerId.get().equals(producer.getId())) - .toList(); - } - - return producers; - } - /** * Collects data provider IDs from a list of producers. * @@ -218,7 +198,11 @@ private List collectDataProviderIds(List producers) { /** * Resolves consumers for each product and populates them onto the product DTOs, - * filtering out consumers whose organisations have inactive certificates. + * filtering out consumers whose organisations have inactive certificates. Also attaches + * each product's live subscriptions ({@code product_consumer} rows) as {@code + * configurations} - previously never populated on this path - since {@link + * #populatePolicyAttributes} needs a {@link ProductConsumerDTO} instance per subscription to + * attach {@code SUBSCRIPTION}-scope policy attributes to. * * @param producers the list of producers whose products need consumer resolution */ @@ -227,8 +211,13 @@ private void populateConsumersForProducers(List producers) { Map> consumersByProduct = new LinkedHashMap<>(); for (ProducerDTO producer : producers) { for (ProductDTO product : producer.getProducts()) { - List resolved = productConsumerService.findByDataProviderId(product.getId()).stream() - .filter(this::isValidProvider) + List validConfigurations = + productConsumerService.findByDataProviderId(product.getId()).stream() + .filter(this::isValidProvider) + .toList(); + product.setConfigurations(validConfigurations); + + List resolved = validConfigurations.stream() .map(cp -> consumerService.findById(cp.getConsumerId())) .filter(Optional::isPresent) .map(Optional::get) @@ -257,6 +246,37 @@ private void populateConsumersForProducers(List producers) { } } + /** + * Attaches live policy attributes to every producer, allowed consumer, consumer organisation, + * and subscription in the (already assembled) producer config DTO graph - DPAV-3162. + * + * @param producers the fully assembled producer DTO graph ({@link #populateConsumersForProducers} + * must have already run, so each product's {@code consumers}/{@code configurations} are populated) + */ + private void populatePolicyAttributes(List producers) { + for (ProducerDTO producer : producers) { + producer.getPolicyAttributes() + .addAll(policyAttributeService.findAttributes(producer.getId(), PolicyAttributeScope.PRODUCER)); + + for (ProductDTO product : producer.getProducts()) { + for (ConsumerDTO consumer : product.getConsumers()) { + consumer.getPolicyAttributes() + .addAll(policyAttributeService.findAttributes( + consumer.getId(), PolicyAttributeScope.CONSUMER)); + consumer.getOrganisationPolicyAttributes() + .addAll(policyAttributeService.findAttributes( + consumer.getOrgId(), PolicyAttributeScope.ORGANISATION)); + } + for (ProductConsumerDTO configuration : product.getConfigurations()) { + configuration + .getPolicyAttributes() + .addAll(policyAttributeService.findAttributes( + configuration.getId(), PolicyAttributeScope.SUBSCRIPTION)); + } + } + } + } + /** * Checks if a provider (product consumer) is valid based on its granted date and validity period. * @@ -265,7 +285,7 @@ private void populateConsumersForProducers(List producers) { */ private boolean isValidProvider(ProductConsumerDTO provider) { - if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true; + if (provider.getValidity() == null || provider.getValidity().compareTo(BigDecimal.ZERO) == 0) return true; return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity()); } diff --git a/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql new file mode 100644 index 0000000..2034188 --- /dev/null +++ b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +-- Which core entity types may carry dynamic attributes, and the table entity_id resolves against. +CREATE TABLE attribute_scope ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + table_name VARCHAR(150) NOT NULL, + description VARCHAR(500), + CONSTRAINT uq_attribute_scope__code UNIQUE (code) +); + +-- Attribute vocabulary: name/type/validation metadata, independent of which scope(s) it applies to. +CREATE TABLE attribute_definition ( + id BIGSERIAL PRIMARY KEY, + namespace VARCHAR(150) NOT NULL, + name VARCHAR(150) NOT NULL, + display_name VARCHAR(255), + description TEXT NOT NULL, + data_type VARCHAR(50) NOT NULL, + multi_valued BOOLEAN NOT NULL DEFAULT FALSE, + allowed_values JSONB, + validation_pattern VARCHAR(500), + classification JSONB, + sensitive BOOLEAN NOT NULL DEFAULT FALSE, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + created_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP, + updated_by VARCHAR(255), + CONSTRAINT uq_attribute_definition__namespace_name UNIQUE (namespace, name) +); + +-- Which scopes a definition is valid on, whether required there, and its default. +CREATE TABLE attribute_definition_scope ( + id BIGSERIAL PRIMARY KEY, + attribute_definition_id BIGINT NOT NULL, + attribute_scope_id BIGINT NOT NULL, + required BOOLEAN NOT NULL DEFAULT FALSE, + default_value JSONB, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + created_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP, + updated_by VARCHAR(255), + CONSTRAINT fk_attribute_definition_scope__attribute_definition_id + FOREIGN KEY (attribute_definition_id) REFERENCES attribute_definition (id), + CONSTRAINT fk_attribute_definition_scope__attribute_scope_id + FOREIGN KEY (attribute_scope_id) REFERENCES attribute_scope (id), + CONSTRAINT uq_attribute_definition_scope__definition_scope + UNIQUE (attribute_definition_id, attribute_scope_id) +); +CREATE INDEX idx_attribute_definition_scope__attribute_definition_id + ON attribute_definition_scope (attribute_definition_id); +CREATE INDEX idx_attribute_definition_scope__attribute_scope_id + ON attribute_definition_scope (attribute_scope_id); + +-- Actual values. entity_id is polymorphic: PK of the row in attribute_scope.table_name for that pairing's +-- scope, not a declared FK — enforced by the soft-delete trigger below, not by the database schema. +CREATE TABLE attribute_value ( + id BIGSERIAL PRIMARY KEY, + attribute_definition_scope_id BIGINT NOT NULL, + entity_id BIGINT NOT NULL, + value JSONB NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + created_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP, + updated_by VARCHAR(255), + CONSTRAINT fk_attribute_value__attribute_definition_scope_id + FOREIGN KEY (attribute_definition_scope_id) REFERENCES attribute_definition_scope (id) +); +CREATE INDEX idx_attribute_value__entity_id ON attribute_value (entity_id); +-- Backs the PEP's EXISTS sub-queries per constraint (attribute_definition_scope_id, entity_id, value). +CREATE UNIQUE INDEX uq_attr_value_live + ON attribute_value (attribute_definition_scope_id, entity_id, value) + WHERE is_deleted = FALSE; + +INSERT INTO attribute_scope (code, table_name, description) VALUES + ('ORGANISATION', 'organisation', 'Attributes carried by an organisation'), + ('CONSUMER', 'consumer', 'Attributes carried by a consumer'), + ('PRODUCER', 'producer', 'Attributes carried by a producer'), + ('PRODUCT', 'product', 'Attributes carried by a product'), + ('SUBSCRIPTION', 'product_consumer', 'Attributes carried by a product/consumer subscription'); + +-- Soft-delete any live attribute_value rows for the entity being removed, scoped to the deleted table. +CREATE FUNCTION fn_attribute_value_soft_delete_on_entity_delete() RETURNS TRIGGER AS $$ +BEGIN + UPDATE attribute_value av + SET is_deleted = TRUE, + updated_at = now(), + updated_by = 'trigger:' || TG_TABLE_NAME + FROM attribute_definition_scope ads + JOIN attribute_scope asc_ ON asc_.id = ads.attribute_scope_id + WHERE av.attribute_definition_scope_id = ads.id + AND asc_.table_name = TG_TABLE_NAME + AND av.entity_id = OLD.id + AND av.is_deleted = FALSE; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_organisation_attribute_value_soft_delete + AFTER DELETE ON organisation + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_consumer_attribute_value_soft_delete + AFTER DELETE ON consumer + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_producer_attribute_value_soft_delete + AFTER DELETE ON producer + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_product_attribute_value_soft_delete + AFTER DELETE ON product + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_product_consumer_attribute_value_soft_delete + AFTER DELETE ON product_consumer + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeFieldsSerializationTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeFieldsSerializationTest.java new file mode 100644 index 0000000..ba54518 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/PolicyAttributeFieldsSerializationTest.java @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +/** + * A freshly-built (unpopulated) {@link ProducerDTO}/{@link ConsumerDTO}/{@link + * ProductConsumerDTO} must serialise its new policy attribute fields as {@code []}, never {@code + * null} or an omitted field - see design.md's "Empty, not null, arrays" decision. + */ +class PolicyAttributeFieldsSerializationTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void producerDto_policyAttributesSerialisesAsEmptyArray() throws Exception { + JsonNode json = objectMapper.readTree( + objectMapper.writeValueAsString(ProducerDTO.builder().build())); + + assertThat(json.has("policyAttributes")).isTrue(); + assertThat(json.get("policyAttributes").isArray()).isTrue(); + assertThat(json.get("policyAttributes")).isEmpty(); + } + + @Test + void consumerDto_policyAttributeFieldsSerialiseAsEmptyArrays() throws Exception { + JsonNode json = objectMapper.readTree( + objectMapper.writeValueAsString(ConsumerDTO.builder().build())); + + assertThat(json.get("policyAttributes").isArray()).isTrue(); + assertThat(json.get("policyAttributes")).isEmpty(); + assertThat(json.get("organisationPolicyAttributes").isArray()).isTrue(); + assertThat(json.get("organisationPolicyAttributes")).isEmpty(); + } + + @Test + void productConsumerDto_policyAttributesSerialisesAsEmptyArray() throws Exception { + JsonNode json = objectMapper.readTree( + objectMapper.writeValueAsString(ProductConsumerDTO.builder().build())); + + assertThat(json.get("policyAttributes").isArray()).isTrue(); + assertThat(json.get("policyAttributes")).isEmpty(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java new file mode 100644 index 0000000..df49d13 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Base class for repository tests that need the real Flyway migrations and real + * Postgres behaviour (partial unique indexes, {@code plpgsql} triggers) that the + * project's shared H2 test profile ({@code src/test/resources/application.yml}) cannot + * provide. Points the Spring context at a shared Postgres container and re-enables + * Flyway (disabled in the shared profile) so migrations apply for real. + * + *

The container is started eagerly in a static initializer rather than left to the + * {@code @Testcontainers}/{@code @Container} JUnit extension. With multiple concrete + * subclasses - each getting its own Spring context - relying on the extension's + * per-class {@code beforeAll} to start (or no-op past) the container raced against + * context refresh on CI and on some local Docker setups: the first class or two would + * see the container "started" but not yet accepting TCP connections, and every test in + * that class would time out. Starting synchronously here, before any JUnit lifecycle + * callback runs for any subclass, removes that race. Testcontainers' Ryuk reaper still + * cleans the container up at JVM exit; it is not tied to the JUnit5 extension. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class AbstractPostgresRepositoryTest { + + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")); + + static { + POSTGRES.start(); + } + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> "true"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java new file mode 100644 index 0000000..1ebdfb4 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; + +class AttributeDefinitionRepositoryTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + private static AttributeDefinition newDefinition(String namespace, String name) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace(namespace); + definition.setName(name); + definition.setDescription("Test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + return definition; + } + + @Test + void findByNamespaceAndName_returnsPersistedDefinition() { + attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "risk-tier")); + + Optional found = + attributeDefinitionRepository.findByNamespaceAndName("policy", "risk-tier"); + + assertThat(found).isPresent(); + assertThat(found.get().getDataType()).isEqualTo("STRING"); + assertThat(found.get().getMultiValued()).isFalse(); + assertThat(found.get().getSensitive()).isFalse(); + } + + @Test + void findByNamespaceAndName_returnsEmptyForUnknownPair() { + Optional found = attributeDefinitionRepository.findByNamespaceAndName("nope", "nope"); + + assertThat(found).isEmpty(); + } + + @Test + void save_rejectsDuplicateNamespaceAndName() { + attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "duplicate-check")); + AttributeDefinition duplicate = newDefinition("policy", "duplicate-check"); + + assertThrows( + DataIntegrityViolationException.class, () -> attributeDefinitionRepository.saveAndFlush(duplicate)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java new file mode 100644 index 0000000..605dcb4 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; + +class AttributeDefinitionScopeRepositoryTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Autowired + private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository; + + private AttributeDefinition persistDefinition(String name) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace("policy"); + definition.setName(name); + definition.setDescription("Test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + return attributeDefinitionRepository.saveAndFlush(definition); + } + + private static AttributeDefinitionScope newBinding( + AttributeDefinition definition, AttributeScope scope, boolean required) { + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + binding.setAttributeScope(scope); + binding.setRequired(required); + binding.setCreatedAt(Timestamp.from(Instant.now())); + binding.setCreatedBy("test"); + return binding; + } + + @Test + void findByAttributeDefinitionId_returnsAllBoundScopes() { + AttributeDefinition definition = persistDefinition("multi-scope-attr"); + AttributeScope productScope = + attributeScopeRepository.findByCode("PRODUCT").orElseThrow(); + AttributeScope consumerScope = + attributeScopeRepository.findByCode("CONSUMER").orElseThrow(); + + attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, true)); + attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, consumerScope, false)); + + List bindings = + attributeDefinitionScopeRepository.findByAttributeDefinitionId(definition.getId()); + + assertThat(bindings).hasSize(2); + assertThat(bindings) + .extracting(b -> b.getAttributeScope().getId()) + .containsExactlyInAnyOrder(productScope.getId(), consumerScope.getId()); + } + + @Test + void save_rejectsDuplicateDefinitionScopePair() { + AttributeDefinition definition = persistDefinition("duplicate-binding-attr"); + AttributeScope productScope = + attributeScopeRepository.findByCode("PRODUCT").orElseThrow(); + attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, false)); + + AttributeDefinitionScope duplicate = newBinding(definition, productScope, true); + + assertThrows( + DataIntegrityViolationException.class, + () -> attributeDefinitionScopeRepository.saveAndFlush(duplicate)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java new file mode 100644 index 0000000..a76c944 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; + +class AttributeScopeRepositoryTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Test + void findByCode_returnsSeededScope() { + Optional found = attributeScopeRepository.findByCode("PRODUCT"); + + assertThat(found).isPresent(); + assertThat(found.get().getTableName()).isEqualTo("product"); + } + + @Test + void findByCode_returnsEmptyForUnknownCode() { + Optional found = attributeScopeRepository.findByCode("DOES_NOT_EXIST"); + + assertThat(found).isEmpty(); + } + + @Test + void save_rejectsDuplicateCode() { + AttributeScope duplicate = new AttributeScope(); + duplicate.setCode("PRODUCT"); + duplicate.setTableName("product"); + duplicate.setDescription("Duplicate of the seeded PRODUCT scope"); + + assertThrows(DataIntegrityViolationException.class, () -> attributeScopeRepository.saveAndFlush(duplicate)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java new file mode 100644 index 0000000..3f08068 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java @@ -0,0 +1,206 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.persistence.EntityManagerFactory; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; + +class AttributeValueRepositoryTest extends AbstractPostgresRepositoryTest { + + @DynamicPropertySource + static void statisticsProperty(DynamicPropertyRegistry registry) { + registry.add("spring.jpa.properties.hibernate.generate_statistics", () -> "true"); + } + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Autowired + private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository; + + @Autowired + private AttributeValueRepository attributeValueRepository; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + private AttributeDefinitionScope persistProductScopedBinding(String attributeName) { + return persistScopedBinding(attributeName, "PRODUCT"); + } + + private AttributeDefinitionScope persistScopedBinding(String attributeName, String scopeCode) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace("policy"); + definition.setName(attributeName); + definition.setDescription("Test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + definition = attributeDefinitionRepository.saveAndFlush(definition); + + AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow(); + + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + binding.setAttributeScope(scope); + binding.setRequired(false); + binding.setCreatedAt(Timestamp.from(Instant.now())); + binding.setCreatedBy("test"); + return attributeDefinitionScopeRepository.saveAndFlush(binding); + } + + private static AttributeValue newValue(AttributeDefinitionScope binding, Long entityId, String json) { + AttributeValue value = new AttributeValue(); + value.setAttributeDefinitionScope(binding); + value.setEntityId(entityId); + value.setValue(json); + value.setCreatedAt(Timestamp.from(Instant.now())); + value.setCreatedBy("test"); + return value; + } + + @Test + void findLiveValue_returnsNonDeletedValue() { + AttributeDefinitionScope binding = persistProductScopedBinding("live-value-attr"); + attributeValueRepository.saveAndFlush(newValue(binding, 1001L, "\"gold\"")); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1001L); + + assertThat(live).hasSize(1); + assertThat(live.get(0).getValue()).isEqualTo("\"gold\""); + } + + @Test + void findLiveValue_excludesSoftDeletedValue() { + AttributeDefinitionScope binding = persistProductScopedBinding("soft-deleted-attr"); + AttributeValue value = newValue(binding, 1002L, "\"silver\""); + value.setIsDeleted(true); + attributeValueRepository.saveAndFlush(value); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1002L); + + assertThat(live).isEmpty(); + } + + @Test + void save_rejectsExactDuplicateLiveValue() { + AttributeDefinitionScope binding = persistProductScopedBinding("duplicate-value-attr"); + attributeValueRepository.saveAndFlush(newValue(binding, 1003L, "\"gold\"")); + + AttributeValue duplicate = newValue(binding, 1003L, "\"gold\""); + + assertThrows(DataIntegrityViolationException.class, () -> attributeValueRepository.saveAndFlush(duplicate)); + } + + @Test + void save_acceptsDistinctValueForSameBindingAndEntity() { + // uq_attr_value_live keys on (scope, entity, value) - it is an idempotency guard against exact + // duplicates, not a single-valuedness constraint, so a different value is allowed. See design.md. + AttributeDefinitionScope binding = persistProductScopedBinding("multi-valued-attr"); + attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"gold\"")); + + attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"silver\"")); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1004L); + assertThat(live) + .hasSize(2) + .extracting(AttributeValue::getValue) + .containsExactlyInAnyOrder("\"gold\"", "\"silver\""); + } + + @Test + void save_allowsSameValueAgainAfterPriorDuplicateIsSoftDeleted() { + AttributeDefinitionScope binding = persistProductScopedBinding("resurrected-attr"); + AttributeValue first = newValue(binding, 1005L, "\"gold\""); + first = attributeValueRepository.saveAndFlush(first); + first.setIsDeleted(true); + attributeValueRepository.saveAndFlush(first); + + AttributeValue resurrected = newValue(binding, 1005L, "\"gold\""); + attributeValueRepository.saveAndFlush(resurrected); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1005L); + assertThat(live).hasSize(1); + assertThat(live.get(0).getValue()).isEqualTo("\"gold\""); + } + + // findLiveByEntityIdAndScopeCode - task 1.1 + + @Test + void findLiveByEntityIdAndScopeCode_returnsMatchWithDefinitionEagerlyFetched() { + AttributeDefinitionScope binding = persistScopedBinding("producer-tier", "PRODUCER"); + attributeValueRepository.saveAndFlush(newValue(binding, 2001L, "\"gold\"")); + + SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class); + sessionFactory.getStatistics().clear(); + + List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(2001L, "PRODUCER"); + + assertThat(live).hasSize(1); + AttributeValue found = live.get(0); + // Accessing the definition must not trigger an additional query - proves the JOIN FETCH, + // not lazy N+1 loading, populated it. + assertThat(found.getAttributeDefinitionScope().getAttributeDefinition().getName()) + .isEqualTo("producer-tier"); + assertThat(sessionFactory.getStatistics().getQueryExecutionCount()).isEqualTo(1); + } + + @Test + void findLiveByEntityIdAndScopeCode_excludesSoftDeletedValue() { + AttributeDefinitionScope binding = persistScopedBinding("producer-tier-deleted", "PRODUCER"); + AttributeValue value = newValue(binding, 2002L, "\"gold\""); + value.setIsDeleted(true); + attributeValueRepository.saveAndFlush(value); + + List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(2002L, "PRODUCER"); + + assertThat(live).isEmpty(); + } + + @Test + void findLiveByEntityIdAndScopeCode_excludesValueUnderDifferentScope() { + AttributeDefinitionScope binding = persistScopedBinding("consumer-only-tier", "CONSUMER"); + attributeValueRepository.saveAndFlush(newValue(binding, 2003L, "\"gold\"")); + + List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(2003L, "PRODUCER"); + + assertThat(live).isEmpty(); + } + + @Test + void findLiveByEntityIdAndScopeCode_returnsEmptyForUnknownEntity() { + List live = attributeValueRepository.findLiveByEntityIdAndScopeCode(999999L, "PRODUCER"); + + assertThat(live).isEmpty(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java new file mode 100644 index 0000000..c8ffe58 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java @@ -0,0 +1,235 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; + +/** + * Verifies the migration's five {@code AFTER DELETE} triggers, which soft-delete + * {@code attribute_value} rows scoped to the deleted owning entity rather than + * leaving them orphaned. + */ +class AttributeValueSoftDeleteTriggerTest extends AbstractPostgresRepositoryTest { + + @Autowired + private OrganisationRepository organisationRepository; + + @Autowired + private ProducerRepository producerRepository; + + @Autowired + private ConsumerRepository consumerRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ProductConsumerRepository productConsumerRepository; + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + @Autowired + private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository; + + @Autowired + private AttributeValueRepository attributeValueRepository; + + @Autowired + private TestEntityManager testEntityManager; + + private AttributeDefinitionScope bindingFor(String scopeCode, String attributeName) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace("policy"); + definition.setName(attributeName); + definition.setDescription("Trigger test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + definition = attributeDefinitionRepository.saveAndFlush(definition); + + AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow(); + + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + binding.setAttributeScope(scope); + binding.setRequired(false); + binding.setCreatedAt(Timestamp.from(Instant.now())); + binding.setCreatedBy("test"); + return attributeDefinitionScopeRepository.saveAndFlush(binding); + } + + private Long persistLiveValue(AttributeDefinitionScope binding, Long entityId) { + AttributeValue value = new AttributeValue(); + value.setAttributeDefinitionScope(binding); + value.setEntityId(entityId); + value.setValue("\"trigger-test-value\""); + value.setCreatedAt(Timestamp.from(Instant.now())); + value.setCreatedBy("test"); + return attributeValueRepository.saveAndFlush(value).getId(); + } + + private Organisation persistOrganisation() { + Organisation organisation = new Organisation(); + organisation.setName("Trigger Test Org"); + return organisationRepository.saveAndFlush(organisation); + } + + private Consumer persistConsumer(Organisation organisation) { + Consumer consumer = new Consumer(); + consumer.setName("Trigger Test Consumer"); + consumer.setOrg(organisation); + consumer.setIdpClientId("trigger-test-consumer"); + consumer.setScheduleType("cron"); + return consumerRepository.saveAndFlush(consumer); + } + + private Producer persistProducer(Organisation organisation) { + Producer producer = new Producer(); + producer.setName("Trigger Test Producer"); + producer.setDescription("Trigger test producer"); + producer.setOrg(organisation); + producer.setActive(true); + producer.setHost("localhost"); + producer.setPort(BigDecimal.valueOf(8080)); + producer.setTls(true); + producer.setIdpClientId("trigger-test-producer"); + return producerRepository.saveAndFlush(producer); + } + + private Product persistProduct(Producer producer) { + Product product = new Product(); + product.setName("Trigger Test Product"); + product.setTopic("topic.trigger-test"); + product.setProducer(producer); + return productRepository.saveAndFlush(product); + } + + private ProductConsumer persistProductConsumer(Product product, Consumer consumer) { + ProductConsumer productConsumer = new ProductConsumer(); + productConsumer.setProduct(product); + productConsumer.setConsumer(consumer); + productConsumer.setGrantedTs(Timestamp.from(Instant.now())); + productConsumer.setValidity(BigDecimal.valueOf(30)); + productConsumer.setScheduleType("cron"); + return productConsumerRepository.saveAndFlush(productConsumer); + } + + @Test + void deletingOrganisation_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + AttributeDefinitionScope binding = bindingFor("ORGANISATION", "org-trigger-attr"); + Long valueId = persistLiveValue(binding, organisation.getId()); + + organisationRepository.delete(organisation); + organisationRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingConsumer_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Consumer consumer = persistConsumer(organisation); + AttributeDefinitionScope binding = bindingFor("CONSUMER", "consumer-trigger-attr"); + Long valueId = persistLiveValue(binding, consumer.getId()); + + consumerRepository.delete(consumer); + consumerRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingProducer_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Producer producer = persistProducer(organisation); + AttributeDefinitionScope binding = bindingFor("PRODUCER", "producer-trigger-attr"); + Long valueId = persistLiveValue(binding, producer.getId()); + + producerRepository.delete(producer); + producerRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingProduct_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Producer producer = persistProducer(organisation); + Product product = persistProduct(producer); + AttributeDefinitionScope binding = bindingFor("PRODUCT", "product-trigger-attr"); + Long valueId = persistLiveValue(binding, product.getId()); + + productRepository.delete(product); + productRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingProductConsumer_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Producer producer = persistProducer(organisation); + Product product = persistProduct(producer); + Consumer consumer = persistConsumer(organisation); + ProductConsumer productConsumer = persistProductConsumer(product, consumer); + AttributeDefinitionScope binding = bindingFor("SUBSCRIPTION", "subscription-trigger-attr"); + Long valueId = persistLiveValue(binding, productConsumer.getId()); + + productConsumerRepository.delete(productConsumer); + productConsumerRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingEntityWithNoAttributeValues_succeedsAndLeavesAttributeValueTableUntouched() { + Organisation organisation = persistOrganisation(); + long countBefore = attributeValueRepository.count(); + + organisationRepository.delete(organisation); + organisationRepository.flush(); + + assertThat(organisationRepository.existsById(organisation.getId())).isFalse(); + assertThat(attributeValueRepository.count()).isEqualTo(countBefore); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScopeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScopeTest.java new file mode 100644 index 0000000..db8eb11 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/PolicyAttributeScopeTest.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AbstractPostgresRepositoryTest; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeScopeRepository; + +/** + * Verifies every {@link PolicyAttributeScope} constant's code names a real, seeded {@code + * attribute_scope.code} row - against real Postgres, so a future rename of a seeded code would + * be caught here rather than only surfacing as an always-empty result at runtime. + */ +class PolicyAttributeScopeTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @ParameterizedTest + @EnumSource(PolicyAttributeScope.class) + void code_namesASeededAttributeScopeRow(PolicyAttributeScope scope) { + assertThat(attributeScopeRepository.findByCode(scope.code())).isPresent(); + } + + @Test + void doesNotIncludeProductScope() { + assertThat(PolicyAttributeScope.values()) + .extracting(PolicyAttributeScope::code) + .doesNotContain("PRODUCT"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImplTest.java new file mode 100644 index 0000000..51ef86d --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/PolicyAttributeServiceImplTest.java @@ -0,0 +1,100 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.PolicyAttributeDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeValueRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeScope; + +@ExtendWith(MockitoExtension.class) +class PolicyAttributeServiceImplTest { + + @Mock + private AttributeValueRepository attributeValueRepository; + + private PolicyAttributeServiceImpl service; + + @BeforeEach + void setUp() { + service = new PolicyAttributeServiceImpl(attributeValueRepository, new ObjectMapper()); + } + + private static AttributeValue attributeValue(String namespace, String name, String dataType, String rawJson) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace(namespace); + definition.setName(name); + definition.setDataType(dataType); + + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + + AttributeValue value = new AttributeValue(); + value.setAttributeDefinitionScope(binding); + value.setValue(rawJson); + return value; + } + + @Test + void findAttributes_mapsNamespaceDotNameValueAndType() { + when(attributeValueRepository.findLiveByEntityIdAndScopeCode(10L, "PRODUCER")) + .thenReturn(List.of(attributeValue("policy", "risk-tier", "STRING", "\"gold\""))); + + List result = service.findAttributes(10L, PolicyAttributeScope.PRODUCER); + + assertThat(result).hasSize(1); + PolicyAttributeDTO dto = result.get(0); + assertThat(dto.getName()).isEqualTo("policy.risk-tier"); + assertThat(dto.getValue()).isEqualTo("gold"); + assertThat(dto.getType()).isEqualTo("STRING"); + } + + @Test + void findAttributes_returnsEmptyListWhenRepositoryFindsNothing() { + when(attributeValueRepository.findLiveByEntityIdAndScopeCode(11L, "CONSUMER")) + .thenReturn(List.of()); + + List result = service.findAttributes(11L, PolicyAttributeScope.CONSUMER); + + assertThat(result).isEmpty(); + } + + @Test + void findAttributes_rendersNonStringValueAsPlainText() { + when(attributeValueRepository.findLiveByEntityIdAndScopeCode(12L, "ORGANISATION")) + .thenReturn(List.of( + attributeValue("policy", "priority", "INTEGER", "42"), + attributeValue("policy", "enabled", "BOOLEAN", "true"))); + + List result = service.findAttributes(12L, PolicyAttributeScope.ORGANISATION); + + assertThat(result).extracting(PolicyAttributeDTO::getValue).containsExactly("42", "true"); + } + + @Test + void findAttributes_fallsBackToRawTextOnMalformedStoredValue() { + when(attributeValueRepository.findLiveByEntityIdAndScopeCode(13L, "SUBSCRIPTION")) + .thenReturn(List.of(attributeValue("policy", "broken", "STRING", "not-valid-json{"))); + + List result = service.findAttributes(13L, PolicyAttributeScope.SUBSCRIPTION); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getValue()).isEqualTo("not-valid-json{"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java index 27a429e..d3d62c1 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -27,6 +27,8 @@ import org.mockito.MockitoAnnotations; import uk.gov.dbt.ndtp.ia.node.management.model.dto.*; import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; @@ -45,6 +47,9 @@ class ConfigurationProviderImplTest { @Mock private CertificateValidationProvider certificateValidationProvider; + @Mock + private PolicyAttributeService policyAttributeService; + @InjectMocks private ConfigurationProviderImpl configurationProvider; @@ -52,7 +57,11 @@ class ConfigurationProviderImplTest { void setUp() { MockitoAnnotations.openMocks(this); configurationProvider = new ConfigurationProviderImpl( - consumerService, productConsumerService, producerService, certificateValidationProvider); + consumerService, + productConsumerService, + producerService, + certificateValidationProvider, + policyAttributeService); // Default: treat all orgs as having active certificates, override in specific // tests to simulate inactive/missing certs. when(certificateValidationProvider.findActiveOrganisationIds(any())).thenAnswer(invocation -> { @@ -159,8 +168,7 @@ void getConsumerConfigByClientId_whenNoValidProducts_clearsAllProducerProducts() void getConsumerConfigByClientId_withConsumerIdFilter_appliesFilter_andRemovesNullProductIds() { String clientId = "clientC"; ConsumerDTO c1 = consumer(3L, clientId, "c3", "CRON", "@daily"); - ConsumerDTO cOther = consumer(99L, clientId, "other", "CRON", "@minutely"); - when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, cOther)); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); ProductConsumerDTO pc = productConsumer(300L, 3L, null, null); when(productConsumerService.findByConsumerId(3L)).thenReturn(List.of(pc)); @@ -272,8 +280,8 @@ void getProducerConfigByClientId_withValidValidity_includesConsumer() { void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() { String clientId = "clientA"; ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); - ConsumerDTO c2 = consumer(2L, clientId, "c2", "CRON", "@daily"); - when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, c2)); + + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(1L)); @@ -284,11 +292,9 @@ void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() { void getProducerConfigByClientId_withProducerId_filtersByProducerId() { String clientId = "producerClient"; ProductDTO p1 = product(100L, "p1"); - ProductDTO p2 = product(101L, "p2"); ProducerDTO pr1 = producer(1L, true, p1); - ProducerDTO pr2 = producer(2L, true, p2); - when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1, pr2)); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(1L)); @@ -368,4 +374,93 @@ void getProducerConfig_filtersOutConsumersWithInactiveCerts() { assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()) .containsExactly(activeOrgConsumer); } + + // DPAV-3162: policy attribute wiring + + @Test + void getProducerConfigByClientId_populatesPolicyAttributesForEveryScope() { + String clientId = "policyClient"; + ProductDTO product = product(700L, "prod"); + ProducerDTO producer = producer(70L, true, product); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(producer)); + when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of()); + + ProductConsumerDTO subscription = productConsumer(700L, 701L, null, null); + subscription.setId(9001L); + when(productConsumerService.findByDataProviderId(700L)).thenReturn(List.of(subscription)); + + ConsumerDTO consumer = consumer(701L, "cid701", "c701", "CRON", "@hourly"); + consumer.setOrgId(801L); + when(consumerService.findById(701L)).thenReturn(Optional.of(consumer)); + + PolicyAttributeDTO producerAttr = PolicyAttributeDTO.builder() + .name("policy.a") + .value("1") + .type("STRING") + .build(); + PolicyAttributeDTO consumerAttr = PolicyAttributeDTO.builder() + .name("policy.b") + .value("2") + .type("STRING") + .build(); + PolicyAttributeDTO orgAttr = PolicyAttributeDTO.builder() + .name("policy.c") + .value("3") + .type("STRING") + .build(); + PolicyAttributeDTO subscriptionAttr = PolicyAttributeDTO.builder() + .name("policy.d") + .value("4") + .type("STRING") + .build(); + when(policyAttributeService.findAttributes(70L, PolicyAttributeScope.PRODUCER)) + .thenReturn(List.of(producerAttr)); + when(policyAttributeService.findAttributes(701L, PolicyAttributeScope.CONSUMER)) + .thenReturn(List.of(consumerAttr)); + when(policyAttributeService.findAttributes(801L, PolicyAttributeScope.ORGANISATION)) + .thenReturn(List.of(orgAttr)); + when(policyAttributeService.findAttributes(9001L, PolicyAttributeScope.SUBSCRIPTION)) + .thenReturn(List.of(subscriptionAttr)); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + ProducerDTO returnedProducer = cfg.getProducers().get(0); + assertThat(returnedProducer.getPolicyAttributes()).containsExactly(producerAttr); + + ConsumerDTO returnedConsumer = + returnedProducer.getProducts().get(0).getConsumers().get(0); + assertThat(returnedConsumer.getPolicyAttributes()).containsExactly(consumerAttr); + assertThat(returnedConsumer.getOrganisationPolicyAttributes()).containsExactly(orgAttr); + + ProductConsumerDTO returnedSubscription = + returnedProducer.getProducts().get(0).getConfigurations().get(0); + assertThat(returnedSubscription.getPolicyAttributes()).containsExactly(subscriptionAttr); + } + + @Test + void getProducerConfigByClientId_producerWithNoAttributes_getsEmptyPolicyAttributesList() { + String clientId = "noAttrClient"; + ProducerDTO producer = producer(71L, true); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(producer)); + when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of()); + when(policyAttributeService.findAttributes(71L, PolicyAttributeScope.PRODUCER)) + .thenReturn(List.of()); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg.getProducers().get(0).getPolicyAttributes()).isEmpty(); + } + + @Test + void getConsumerConfigByClientId_neverCallsPolicyAttributeService() { + String clientId = "clientA"; + ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); + when(productConsumerService.findByConsumerId(1L)).thenReturn(List.of()); + when(producerService.getProducersByConsumerIds(List.of(1L))).thenReturn(List.of()); + + configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + verifyNoInteractions(policyAttributeService); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ProducerConfigPolicyAttributesIntegrationTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ProducerConfigPolicyAttributesIntegrationTest.java new file mode 100644 index 0000000..cce6505 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ProducerConfigPolicyAttributesIntegrationTest.java @@ -0,0 +1,258 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.HashSet; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ConsumerConverter; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationProducerConverter; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConsumerConverter; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.PolicyAttributeDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerConfigDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AbstractPostgresRepositoryTest; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeDefinitionRepository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeDefinitionScopeRepository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeScopeRepository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.AttributeValueRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.PolicyAttributeService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.ConsumerServiceImpl; +import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.PolicyAttributeServiceImpl; +import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.ProducerServiceImpl; +import uk.gov.dbt.ndtp.ia.node.management.service.data.impl.ProductConsumerServiceImpl; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; + +/** + * End-to-end coverage (real Postgres, real converters/services, real {@link + * PolicyAttributeServiceImpl}) of DPAV-3162's core requirement: {@code + * GET /api/v1/configuration/producer}'s response carries each resource's live policy attributes + * across all four scopes, and a resource with none gets {@code []}. {@link + * CertificateValidationProvider} is a plain Mockito mock (not the real certificate chain) - out + * of scope for what this test needs to prove, already covered elsewhere. + */ +@Transactional +@Import({ + OrganisationProducerConverter.class, + ProductConverter.class, + ConsumerConverter.class, + ProductConsumerConverter.class, + ProducerServiceImpl.class, + ConsumerServiceImpl.class, + ProductConsumerServiceImpl.class +}) +class ProducerConfigPolicyAttributesIntegrationTest extends AbstractPostgresRepositoryTest { + + @Autowired + private EntityManager entityManager; + + @Autowired + private ProducerService producerService; + + @Autowired + private ConsumerService consumerService; + + @Autowired + private ProductConsumerService productConsumerService; + + @Autowired + private AttributeValueRepository attributeValueRepository; + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + @Autowired + private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository; + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + private ConfigurationProviderImpl configurationProvider() { + CertificateValidationProvider certificateValidationProvider = mock(CertificateValidationProvider.class); + when(certificateValidationProvider.findActiveOrganisationIds(any())) + .thenAnswer(invocation -> new HashSet<>(invocation.getArgument(0))); + PolicyAttributeService policyAttributeService = + new PolicyAttributeServiceImpl(attributeValueRepository, new ObjectMapper()); + return new ConfigurationProviderImpl( + consumerService, + productConsumerService, + producerService, + certificateValidationProvider, + policyAttributeService); + } + + private Organisation persistOrganisation(String name) { + Organisation org = new Organisation(); + org.setName(name); + entityManager.persist(org); + return org; + } + + private Producer persistProducer(Organisation org, String name, String clientId) { + Producer producer = new Producer(); + producer.setName(name); + producer.setDescription("test"); + producer.setOrg(org); + producer.setActive(true); + producer.setHost("host.example"); + producer.setPort(BigDecimal.valueOf(443)); + producer.setTls(true); + producer.setIdpClientId(clientId); + entityManager.persist(producer); + return producer; + } + + private Product persistProduct(Producer producer, String name) { + ProductType topicType = entityManager + .createQuery("SELECT t FROM ProductType t WHERE t.name = :name", ProductType.class) + .setParameter("name", "topic") + .getSingleResult(); + + Product product = new Product(); + product.setName(name); + product.setTopic(name + "-topic"); + product.setProducer(producer); + product.setProductType(topicType); + entityManager.persist(product); + return product; + } + + private Consumer persistConsumer(Organisation org, String name, String clientId) { + Consumer consumer = new Consumer(); + consumer.setName(name); + consumer.setScheduleType("cron"); + consumer.setOrg(org); + consumer.setIdpClientId(clientId); + entityManager.persist(consumer); + return consumer; + } + + private ProductConsumer persistSubscription(Product product, Consumer consumer) { + ProductConsumer subscription = new ProductConsumer(); + subscription.setProduct(product); + subscription.setConsumer(consumer); + subscription.setGrantedTs(Timestamp.from(Instant.now())); + subscription.setValidity(BigDecimal.ZERO); + subscription.setScheduleType("cron"); + entityManager.persist(subscription); + return subscription; + } + + private void persistAttribute(String scopeCode, Long entityId, String attrName, String rawJsonValue) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace("policy"); + definition.setName(attrName); + definition.setDescription("test"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + definition = attributeDefinitionRepository.saveAndFlush(definition); + + AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow(); + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + binding.setAttributeScope(scope); + binding.setRequired(false); + binding.setCreatedAt(Timestamp.from(Instant.now())); + binding.setCreatedBy("test"); + binding = attributeDefinitionScopeRepository.saveAndFlush(binding); + + AttributeValue value = new AttributeValue(); + value.setAttributeDefinitionScope(binding); + value.setEntityId(entityId); + value.setValue(rawJsonValue); + value.setCreatedAt(Timestamp.from(Instant.now())); + value.setCreatedBy("test"); + entityManager.persist(value); + } + + @Test + void producerConfig_carriesPolicyAttributesAcrossAllFourScopes_andEmptyForSiblingWithNone() { + Organisation producerOrg = persistOrganisation("producer-org"); + Producer producer = persistProducer(producerOrg, "attributed-producer", "client-with-attrs"); + Product product = persistProduct(producer, "prod-1"); + + Organisation consumerOrg = persistOrganisation("consumer-org"); + Consumer consumer = persistConsumer(consumerOrg, "attributed-consumer", "consumer-client"); + ProductConsumer subscription = persistSubscription(product, consumer); + + entityManager.flush(); + + persistAttribute("PRODUCER", producer.getId(), "producer-tier", "\"gold\""); + persistAttribute("CONSUMER", consumer.getId(), "consumer-tier", "\"silver\""); + persistAttribute("ORGANISATION", consumerOrg.getId(), "org-region", "\"uk\""); + persistAttribute("SUBSCRIPTION", subscription.getId(), "sub-priority", "1"); + + // A sibling producer with no attributes at all, for the empty-array assertion. It still + // needs a product - getProducersByClientId inner-joins products, so a producer with none + // would never appear in the response regardless of policy attributes. + Producer bareProducer = persistProducer(producerOrg, "bare-producer", "client-no-attrs"); + persistProduct(bareProducer, "prod-2"); + entityManager.flush(); + // Force subsequent reads through the query layer's JOIN FETCHes rather than reusing the + // self-constructed, association-less entity instances already sitting in this session's + // first-level cache. + entityManager.clear(); + + ProducerConfigDTO cfg = + configurationProvider().getProducerConfigByClientId("client-with-attrs", Optional.empty()); + + ProducerDTO producerDto = cfg.getProducers().get(0); + assertThat(producerDto.getPolicyAttributes()) + .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType) + .containsExactly(tuple("policy.producer-tier", "gold", "STRING")); + + ProductDTO productDto = producerDto.getProducts().get(0); + ConsumerDTO consumerDto = productDto.getConsumers().get(0); + assertThat(consumerDto.getPolicyAttributes()) + .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType) + .containsExactly(tuple("policy.consumer-tier", "silver", "STRING")); + assertThat(consumerDto.getOrganisationPolicyAttributes()) + .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType) + .containsExactly(tuple("policy.org-region", "uk", "STRING")); + + ProductConsumerDTO subscriptionDto = productDto.getConfigurations().get(0); + assertThat(subscriptionDto.getPolicyAttributes()) + .extracting(PolicyAttributeDTO::getName, PolicyAttributeDTO::getValue, PolicyAttributeDTO::getType) + .containsExactly(tuple("policy.sub-priority", "1", "STRING")); + + ProducerConfigDTO bareCfg = + configurationProvider().getProducerConfigByClientId("client-no-attrs", Optional.empty()); + assertThat(bareCfg.getProducers().get(0).getPolicyAttributes()).isEmpty(); + } +}