From bef3c2938a6fbddb045112de57090fc6de60fa2e Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Mon, 31 Aug 2026 16:18:59 +0200 Subject: [PATCH 01/10] fix(policy): return 400 for invalid request bodies instead of 500 @Valid request-body validation failures and malformed JSON bodies were falling through to GlobalExceptionHandler's generic Exception handler, which returns 500. Add dedicated MethodArgumentNotValidException and HttpMessageNotReadableException handlers so both cases return 400, as already relied on by CertificateController's bootstrap endpoint and the new product discovery endpoint. --- .../handlers/GlobalExceptionHandler.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java index 4622403..42e5144 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java @@ -10,7 +10,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; @@ -136,6 +138,53 @@ public ResponseEntity handlePkiException(PkiException ex, WebRequ return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } + /** + * Handles {@code @Valid} request body validation failures (e.g. field size/blank + * constraints) with a 400, rather than falling through to the 500 handler below. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with a 400 error message + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug( + "Request validation failed, error_id={}, path={}: {}", + errorId, + request.getContextPath(), + ex.getMessage()); + + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Invalid request: " + ex.getMessage(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + /** + * Handles malformed/unreadable request bodies (e.g. invalid JSON, wrong field types) + * with a 400, rather than falling through to the 500 handler below. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with a 400 error message + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug( + "Malformed request body, error_id={}, path={}: {}", errorId, request.getContextPath(), ex.getMessage()); + + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Invalid request body", errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + /** * Handles RuntimeException. * From 6812287baa83d781dbbda8a77735d9880569e2c6 Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Mon, 31 Aug 2026 16:18:59 +0200 Subject: [PATCH 02/10] refactor(policy): expose organisation id lookup for reuse outside config Widen RequestRejectionSupport (and getOrganisationId) to public so the new product discovery controller, in a different package, can read the organisation CertificateValidationInterceptor already resolved for the request instead of duplicating the request-attribute lookup. --- .../node/management/config/RequestRejectionSupport.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/RequestRejectionSupport.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/RequestRejectionSupport.java index af9b70f..761847d 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/RequestRejectionSupport.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/RequestRejectionSupport.java @@ -19,9 +19,11 @@ /** * Shared request-rejection behaviour for {@code HandlerInterceptor}s that gate access * on the authenticated client: resolving the client id from the security context and - * writing a JSON {@link ErrorResponse} for a rejected request. + * writing a JSON {@link ErrorResponse} for a rejected request. {@link #getOrganisationId} + * is also read by controllers (e.g. product discovery) that need the organisation + * {@link CertificateValidationInterceptor} resolved for the current request. */ -final class RequestRejectionSupport { +public final class RequestRejectionSupport { private static final String ORGANISATION_ID_ATTRIBUTE = "ndtp.organisationId"; @@ -31,7 +33,7 @@ static void setOrganisationId(HttpServletRequest request, Long organisationId) { request.setAttribute(ORGANISATION_ID_ATTRIBUTE, organisationId); } - static String getOrganisationId(HttpServletRequest request) { + public static String getOrganisationId(HttpServletRequest request) { Object value = request.getAttribute(ORGANISATION_ID_ATTRIBUTE); return value == null ? null : String.valueOf(value); } From 8135d4c84b01a39bc97a6c5f166af0e5d020788f Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Mon, 31 Aug 2026 16:18:59 +0200 Subject: [PATCH 03/10] feat(product-discovery): add policy-aware product discovery endpoint Implements DPAV-3018: POST /api/v1/product/discovery returns only the products the authenticated requester is authorised to see. Search criteria (name/topic/type, all optional) narrow the org-unscoped candidate query, then ProductDiscoveryService evaluates one PDP decision per candidate via the existing PolicyDecisionClient (built for DPAV-3017's PEP), keeping only ALLOWed products - a PDP denial or failure excludes just that candidate rather than the whole request. - ProductRepository.findDiscoveryCandidates: bounded, filtered candidate query across all organisations (policy decides visibility, not org membership) - ProductDiscoveryService: queries candidates then filters by policy; new `discover_products` role gates the endpoint - docker/opa/policy.rego: starting example for a discover-action rule - application.yml: application.product-discovery.max-candidates bounds the per-request PDP call count --- docker/opa/policy.rego | 7 + docs/AUTHENTICATION_REQUIREMENTS.md | 5 + .../v1/ProductDiscoveryController.java | 88 +++++++++ .../model/dto/ProductDiscoveryRequestDTO.java | 36 ++++ .../dto/ProductDiscoveryResponseDTO.java | 31 +++ .../repository/ProductRepository.java | 25 +++ .../service/data/ProductDiscoveryService.java | 44 +++++ .../service/data/ProductService.java | 13 ++ .../impl/ProductDiscoveryServiceImpl.java | 71 +++++++ .../service/data/impl/ProductServiceImpl.java | 28 ++- .../service/providers/policy/PolicyInput.java | 10 +- src/main/resources/application.yml | 4 + .../v1/ProductDiscoveryControllerTest.java | 183 ++++++++++++++++++ .../model/dto/ProductDiscoveryDtoTest.java | 71 +++++++ .../impl/ProductDiscoveryServiceImplTest.java | 126 ++++++++++++ .../data/impl/ProductServiceImplTest.java | 51 ++++- 16 files changed, 787 insertions(+), 6 deletions(-) create mode 100644 docker/opa/policy.rego create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductDiscoveryService.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImpl.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java diff --git a/docker/opa/policy.rego b/docker/opa/policy.rego new file mode 100644 index 0000000..005ab9f --- /dev/null +++ b/docker/opa/policy.rego @@ -0,0 +1,7 @@ +package management_node + +default allow = true + +# Product discovery (ProductDiscoveryService) evaluates one decision per candidate product, +# with resource "product:{id}" and action "discover" - see PolicyInput. A real discovery +# policy belongs here once authored (see docs/POLICY_ENFORCEMENT_TESTING.md). diff --git a/docs/AUTHENTICATION_REQUIREMENTS.md b/docs/AUTHENTICATION_REQUIREMENTS.md index b775d5a..b12cf82 100644 --- a/docs/AUTHENTICATION_REQUIREMENTS.md +++ b/docs/AUTHENTICATION_REQUIREMENTS.md @@ -78,6 +78,9 @@ Notes: - Bootstrap Certificate API: The onboarding service account may request bootstrap certificate packages when its token contains the role `request_bootstrap_certificate`. The request body contains the target `organisationId` and a CSR. If no certificate record exists for the organisation, one is created automatically. This role is typically assigned only to the website backend service account, not to individual federator clients. - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:request_bootstrap_certificate')")` on `POST /api/v1/certificate/bootstrap`. +- Product Discovery API: Clients may discover the products they are authorised to see when their token contains the role `discover_products`. Even with the role, results are further filtered per-product by the PDP (see `docs/POLICY_ENFORCEMENT_TESTING.md`) - the role only gates access to the endpoint itself. + - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:discover_products')")` on `POST /api/v1/product/discovery`. + ## How this maps to Keycloak - In Keycloak, roles are typically assigned to a client (here conceptually the `management-node` client) and appear in tokens under `resource_access["management-node"].roles`. @@ -91,6 +94,7 @@ Notes: - `sign_certificate` - `access_public_certificates` - `request_bootstrap_certificate` + - `discover_products` - Assign configuration roles to the appropriate Producer or Consumer Federator clients or service accounts. - Assign certificate roles (`create_keys`, `sign_certificate`, `access_public_certificates`) to federator service accounts that manage their own certificates. - Assign `request_bootstrap_certificate` only to the website/onboarding backend service account. @@ -123,4 +127,5 @@ curl -k 'https://localhost:8090/api/v1/configuration/producer' \ - CSR Signing API requires role: `sign_certificate`. - Intermediate Certificate API requires role: `access_public_certificates`. - Bootstrap Certificate API requires role: `request_bootstrap_certificate`. + - Product Discovery API requires role: `discover_products` (plus per-product PDP authorisation). - Swagger/OpenAPI: Use Swagger UI at `/swagger-ui.html` to explore and test with a valid token. \ No newline at end of file diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.java new file mode 100644 index 0000000..9c2a96e --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.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.controller.v1; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import uk.gov.dbt.ndtp.ia.node.management.config.RequestRejectionSupport; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryRequestDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductDiscoveryService; + +@RestController +@RequestMapping("/api/v1/product") +@Slf4j +@Tag( + name = "Product Discovery", + description = "Policy-aware discovery of data products the requester is authorised to see.") +public class ProductDiscoveryController { + + private final ProductDiscoveryService productDiscoveryService; + + public ProductDiscoveryController(ProductDiscoveryService productDiscoveryService) { + this.productDiscoveryService = productDiscoveryService; + } + + @PostMapping("/discovery") + @PreAuthorize("hasAuthority('ROLE_management-node:discover_products')") + @Operation( + summary = "Discover authorised products", + description = "Returns only the products the authenticated requester is authorised to discover, " + + "narrowed by the supplied search criteria. Never returns products denied by policy, " + + "even if they match the search criteria.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "Discovery response returned (possibly with an empty product list)", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProductDiscoveryResponseDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid request body") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public ProductDiscoveryResponseDTO discoverProducts( + @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal, + HttpServletRequest request, + @Valid @RequestBody(required = false) ProductDiscoveryRequestDTO criteria) { + ProductDiscoveryRequestDTO effectiveCriteria = criteria != null + ? criteria + : ProductDiscoveryRequestDTO.builder().build(); + String organisation = RequestRejectionSupport.getOrganisationId(request); + + log.info( + "Product discovery request clientId={} organisation={} name={} topic={} type={}", + principal.clientId(), + organisation, + effectiveCriteria.getName(), + effectiveCriteria.getTopic(), + effectiveCriteria.getType()); + + return productDiscoveryService.discover( + principal.clientId(), + organisation, + effectiveCriteria.getName(), + effectiveCriteria.getTopic(), + effectiveCriteria.getType()); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java new file mode 100644 index 0000000..262ee50 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java @@ -0,0 +1,36 @@ +/* + * 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 jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Search criteria for {@code POST /v1/product/discovery}. All fields are optional; an + * empty/absent field means "no filter" on that attribute. Filters only narrow the set of + * products the requester is authorised to discover - they cannot widen it. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductDiscoveryRequestDTO { + + @Size(max = 50) + private String name; + + @Size(max = 150) + private String topic; + + @Size(max = 255) + private String type; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java new file mode 100644 index 0000000..285f007 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java @@ -0,0 +1,31 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Response for {@code POST /v1/product/discovery}: the products the requester is authorised + * to discover, after policy filtering and search criteria are both applied. Empty (never + * null) when no products are authorised or none match the search criteria. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductDiscoveryResponseDTO { + + @Builder.Default + private List products = new ArrayList<>(); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java index 2c74b10..70a8779 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java @@ -7,8 +7,10 @@ package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; import java.util.List; +import org.springframework.data.domain.Pageable; 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.Product; @@ -41,4 +43,27 @@ public interface ProductRepository extends JpaRepository { */ @Query("SELECT o FROM Product o " + "JOIN FETCH o.productType t " + " WHERE o.producer.id IN :producers") List findByProducerIds(List producers); + + /** + * Discovery candidate query: products across all organisations matching the optional + * search filters (case-insensitive contains on name/topic, exact match on type name). + * A {@code null} filter matches everything for that attribute. Not organisation-scoped - + * policy (the PDP), not org membership, decides visibility for discovery. Uses a LEFT + * JOIN on productType (unlike the other queries here) since type is optional and a + * product without one must still be a candidate when no type filter is supplied. + * + * @param name optional case-insensitive contains filter on product name + * @param topic optional case-insensitive contains filter on product topic + * @param type optional case-insensitive exact filter on product type name + * @param pageable bounds the candidate set size (e.g. {@code PageRequest.of(0, maxCandidates)}) + * @return candidate products matching the filters, bounded by {@code pageable} + */ + @Query("SELECT p FROM Product p " + + "LEFT JOIN FETCH p.productType t " + + "WHERE (:name IS NULL OR LOWER(p.name) LIKE LOWER(CONCAT('%', :name, '%'))) " + + "AND (:topic IS NULL OR LOWER(p.topic) LIKE LOWER(CONCAT('%', :topic, '%'))) " + + "AND (:type IS NULL OR LOWER(t.name) = LOWER(:type)) " + + "ORDER BY p.id") + List findDiscoveryCandidates( + @Param("name") String name, @Param("topic") String topic, @Param("type") String type, Pageable pageable); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductDiscoveryService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductDiscoveryService.java new file mode 100644 index 0000000..c016a66 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductDiscoveryService.java @@ -0,0 +1,44 @@ +/* + * 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.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryResponseDTO; + +/** + * Runs product discovery: queries candidate products matching the requester's search + * criteria, then applies per-candidate PDP authorisation, keeping only the products the + * requester is authorised to discover. + */ +public interface ProductDiscoveryService { + + /** + * Queries discovery candidates matching the given search criteria, then evaluates one + * PDP decision per candidate, keeping only the ALLOWed ones. + * + * @param clientId identity of the calling client + * @param organisation organisation the client belongs to, if known + * @param name optional case-insensitive contains filter on product name + * @param topic optional case-insensitive contains filter on product topic + * @param type optional case-insensitive exact filter on product type name + * @return the products the requester is authorised to discover, matching the criteria + */ + ProductDiscoveryResponseDTO discover(String clientId, String organisation, String name, String topic, String type); + + /** + * Evaluates one PDP decision per candidate product and returns only the ALLOWed ones. A + * candidate is excluded (not the whole request failed) if the PDP denies it or the PDP + * call itself fails, so a partial PDP outage degrades results rather than the request. + * + * @param clientId identity of the calling client + * @param organisation organisation the client belongs to, if known + * @param candidates discovery candidate products to authorise + * @return the subset of candidates the PDP allows for this requester + */ + List filterAuthorised(String clientId, String organisation, List candidates); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java index 1507ed7..ccdb0d0 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java @@ -29,4 +29,17 @@ public interface ProductService { * @return a list of DataProviderDTO objects corresponding to the given producer IDs */ List getProductsByProducerIds(List producerIds); + + /** + * Retrieves discovery candidate products across all organisations matching the optional + * search filters, bounded by the configured max-candidate limit. This is the pre-policy + * candidate set for {@code POST /v1/product/discovery}; authorisation is applied + * separately, per candidate, by the PDP. + * + * @param name optional case-insensitive contains filter on product name + * @param topic optional case-insensitive contains filter on product topic + * @param type optional case-insensitive exact filter on product type name + * @return candidate products matching the filters, bounded by the max-candidate limit + */ + List findDiscoveryCandidates(String name, String topic, String type); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImpl.java new file mode 100644 index 0000000..549f4e6 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImpl.java @@ -0,0 +1,71 @@ +/* + * 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 java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductDiscoveryService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductService; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyDecision; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyDecisionClient; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyInput; + +/** + * Reuses {@link PolicyDecisionClient} (built for the whole-request PEP on + * {@code /api/v1/configuration/**}) once per candidate product, since discovery needs to + * authorise a set of resources rather than the single request URI. The {@code resource} and + * {@code action} fields of {@link PolicyInput} are repurposed here: {@code resource} carries + * a stable {@code PRODUCT_RESOURCE_PREFIX + id} identifier instead of a request URI, and + * {@code action} is the literal string {@code "discover"} instead of an HTTP method. + */ +@Service +@Slf4j +public class ProductDiscoveryServiceImpl implements ProductDiscoveryService { + + private static final String DISCOVER_ACTION = "discover"; + private static final String PRODUCT_RESOURCE_PREFIX = "product:"; + + private final ProductService productService; + private final PolicyDecisionClient policyDecisionClient; + + public ProductDiscoveryServiceImpl(ProductService productService, PolicyDecisionClient policyDecisionClient) { + this.productService = productService; + this.policyDecisionClient = policyDecisionClient; + } + + @Override + public ProductDiscoveryResponseDTO discover( + String clientId, String organisation, String name, String topic, String type) { + List candidates = productService.findDiscoveryCandidates(name, topic, type); + List authorised = filterAuthorised(clientId, organisation, candidates); + return ProductDiscoveryResponseDTO.builder().products(authorised).build(); + } + + @Override + public List filterAuthorised(String clientId, String organisation, List candidates) { + return candidates.stream() + .filter(candidate -> isAuthorised(clientId, organisation, candidate)) + .toList(); + } + + private boolean isAuthorised(String clientId, String organisation, ProductDTO candidate) { + PolicyInput input = + new PolicyInput(clientId, organisation, PRODUCT_RESOURCE_PREFIX + candidate.getId(), DISCOVER_ACTION); + PolicyDecision decision = policyDecisionClient.evaluate(input); + if (decision == PolicyDecision.DENY) { + log.debug( + "Policy decision DENY clientId={} resource={} action={}", + clientId, + input.resource(), + DISCOVER_ACTION); + } + return decision == PolicyDecision.ALLOW; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java index 2762633..e36c0b9 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java @@ -8,7 +8,11 @@ import java.util.List; import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConverter; import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; @@ -23,16 +27,23 @@ public class ProductServiceImpl implements ProductService { private final ProductRepository productRepository; private final ProductConverter productConverter; + private final int maxDiscoveryCandidates; /** * Constructor-based dependency injection. * * @param productRepository the organisation data provider repository * @param productConverter the converter for entity-to-DTO conversion + * @param maxDiscoveryCandidates upper bound on candidates fetched for discovery, keeping + * the per-candidate PDP call loop in {@code ProductDiscoveryService} bounded */ - public ProductServiceImpl(ProductRepository productRepository, ProductConverter productConverter) { + public ProductServiceImpl( + ProductRepository productRepository, + ProductConverter productConverter, + @Value("${application.product-discovery.max-candidates:200}") int maxDiscoveryCandidates) { this.productRepository = productRepository; this.productConverter = productConverter; + this.maxDiscoveryCandidates = maxDiscoveryCandidates; } /** @@ -59,4 +70,19 @@ public List getProductsByProducerIds(List producerIds) { .map(productConverter::toDtoList) .orElse(List.of()); } + + /** + * {@inheritDoc} + */ + @Override + public List findDiscoveryCandidates(String name, String topic, String type) { + Pageable limit = PageRequest.of(0, maxDiscoveryCandidates); + List candidates = productRepository.findDiscoveryCandidates( + blankToNull(name), blankToNull(topic), blankToNull(type), limit); + return productConverter.toDtoList(candidates); + } + + private static String blankToNull(String value) { + return StringUtils.hasText(value) ? value : null; + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/policy/PolicyInput.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/policy/PolicyInput.java index 4c24271..ed22c89 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/policy/PolicyInput.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/policy/PolicyInput.java @@ -10,12 +10,16 @@ /** * Policy attributes describing who is making a request and what they are trying to do, - * sent to the PDP (OPA) as the {@code input} of a decision request. + * sent to the PDP (OPA) as the {@code input} of a decision request. {@code resource} and + * {@code action} are opaque strings whose convention is caller-defined: the whole-request + * PEP ({@link uk.gov.dbt.ndtp.ia.node.management.config.PolicyEnforcementInterceptor}) uses + * the request URI and HTTP method; per-candidate callers (e.g. product discovery) may use a + * different convention, such as a stable resource id and a named action. * * @param clientId identity of the calling client * @param organisation organisation the client belongs to, if known - * @param resource the requested resource (request URI) - * @param action the requested action (HTTP method) + * @param resource the resource being evaluated, in whatever convention the caller uses + * @param action the action being evaluated, in whatever convention the caller uses */ @JsonInclude(JsonInclude.Include.NON_NULL) public record PolicyInput(String clientId, String organisation, String resource, String action) {} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9276141..81c18fa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,6 +69,10 @@ application: read-timeout: ${OPA_READ_TIMEOUT:3s} # max time to wait for an OPA decision response protected-paths: # API path patterns the Policy Enforcement Point intercepts - /api/v1/configuration/** + product-discovery: + # upper bound on candidates fetched per discovery request, before per-candidate PDP + # evaluation - keeps the synchronous PDP call loop bounded + max-candidates: ${PRODUCT_DISCOVERY_MAX_CANDIDATES:200} # Actuator Configuration management: diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java new file mode 100644 index 0000000..b3c6d4f --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java @@ -0,0 +1,183 @@ +/* + * 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.controller.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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 org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import uk.gov.dbt.ndtp.ia.node.management.exception.handlers.GlobalExceptionHandler; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductDiscoveryService; + +/** + * Integration test for {@code POST /api/v1/product/discovery} wiring + * {@link ProductDiscoveryController} to a mocked {@link ProductDiscoveryService}, covering + * the discovery spec scenarios (fully permitted, partially filtered, no candidates, no + * authorised products, and request validation, AC1-AC9). + */ +@ExtendWith(MockitoExtension.class) +class ProductDiscoveryControllerTest { + + @Mock + private ProductDiscoveryService productDiscoveryService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + ProductDiscoveryController controller = new ProductDiscoveryController(productDiscoveryService); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + authenticateAs("client-1"); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(String clientId) { + // lenient: not every test (e.g. request-validation-failure tests) reaches argument + // resolution far enough to consult these mocks + EnhancedPrincipal principal = new EnhancedPrincipal("subject", clientId); + Authentication authentication = mock(Authentication.class); + lenient().when(authentication.getPrincipal()).thenReturn(principal); + SecurityContext context = mock(SecurityContext.class); + lenient().when(context.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(context); + } + + private static ProductDiscoveryResponseDTO responseWith(ProductDTO... products) { + return ProductDiscoveryResponseDTO.builder().products(List.of(products)).build(); + } + + @Test + void fullyPermitted_returnsAllCandidates() throws Exception { + ProductDTO product = ProductDTO.builder().id(1L).name("Alpha").build(); + when(productDiscoveryService.discover(anyString(), any(), any(), any(), any())) + .thenReturn(responseWith(product)); + + mockMvc.perform(post("/api/v1/product/discovery") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.products.length()").value(1)) + .andExpect(jsonPath("$.products[0].name").value("Alpha")); + } + + @Test + void partiallyFiltered_returnsOnlyAuthorisedSubset() throws Exception { + ProductDTO allowed = ProductDTO.builder().id(1L).name("Allowed").build(); + when(productDiscoveryService.discover(anyString(), any(), any(), any(), any())) + .thenReturn(responseWith(allowed)); + + mockMvc.perform(post("/api/v1/product/discovery") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.products.length()").value(1)) + .andExpect(jsonPath("$.products[0].name").value("Allowed")); + } + + @Test + void noCandidates_returnsEmptyListNotError() throws Exception { + when(productDiscoveryService.discover(anyString(), any(), any(), any(), any())) + .thenReturn(responseWith()); + + mockMvc.perform(post("/api/v1/product/discovery") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.products").isEmpty()); + } + + @Test + void noAuthorisedProducts_returnsEmptyListNotError() throws Exception { + when(productDiscoveryService.discover(anyString(), any(), any(), any(), any())) + .thenReturn(responseWith()); + + mockMvc.perform(post("/api/v1/product/discovery") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.products").isEmpty()); + } + + @Test + void filterMatchingDeniedProduct_stillExcludedFromResponse() throws Exception { + // A search filter matching a product does not widen what the PDP authorises: the + // candidate query narrows by filter, but the PDP filter (mocked here as denying it) + // still wins. + when(productDiscoveryService.discover(anyString(), any(), eq("Restricted"), any(), any())) + .thenReturn(responseWith()); + + mockMvc.perform(post("/api/v1/product/discovery") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Restricted\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.products").isEmpty()); + } + + @Test + void invalidRequestBody_oversizedField_returns400() throws Exception { + String oversizedName = "x".repeat(51); + + mockMvc.perform(post("/api/v1/product/discovery") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"" + oversizedName + "\"}")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productDiscoveryService); + } + + @Test + void malformedJsonBody_returns400() throws Exception { + mockMvc.perform(post("/api/v1/product/discovery") + .contentType(MediaType.APPLICATION_JSON) + .content("{not-json")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productDiscoveryService); + } + + @Test + void emptyBody_treatedAsNoFilter() throws Exception { + when(productDiscoveryService.discover(anyString(), any(), isNull(), isNull(), isNull())) + .thenReturn(responseWith()); + + mockMvc.perform(post("/api/v1/product/discovery").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.products").isEmpty()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java new file mode 100644 index 0000000..867cdb3 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java @@ -0,0 +1,71 @@ +/* + * 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.ObjectMapper; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ProductDiscoveryDtoTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void requestDTO_emptyObject_deserializesWithNoViolations() throws Exception { + ProductDiscoveryRequestDTO dto = objectMapper.readValue("{}", ProductDiscoveryRequestDTO.class); + + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + Validator validator = factory.getValidator(); + Set> violations = validator.validate(dto); + assertThat(violations).isEmpty(); + } + assertThat(dto.getName()).isNull(); + assertThat(dto.getTopic()).isNull(); + assertThat(dto.getType()).isNull(); + } + + @Test + void requestDTO_oversizedField_failsValidation() { + ProductDiscoveryRequestDTO dto = + ProductDiscoveryRequestDTO.builder().name("x".repeat(51)).build(); + + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + Validator validator = factory.getValidator(); + Set> violations = validator.validate(dto); + assertThat(violations).isNotEmpty(); + } + } + + @Test + void responseDTO_defaultsToEmptyList_notNull() throws Exception { + ProductDiscoveryResponseDTO dto = ProductDiscoveryResponseDTO.builder().build(); + + assertThat(dto.getProducts()).isNotNull().isEmpty(); + + String json = objectMapper.writeValueAsString(dto); + assertThat(json).contains("\"products\":[]"); + } + + @Test + void responseDTO_withProducts_serializesWithoutInternalId() throws Exception { + ProductDTO product = + ProductDTO.builder().id(99L).name("Alpha").topic("topic-1").build(); + ProductDiscoveryResponseDTO dto = + ProductDiscoveryResponseDTO.builder().products(List.of(product)).build(); + + String json = objectMapper.writeValueAsString(dto); + + assertThat(json).contains("\"name\":\"Alpha\"").doesNotContain("99"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java new file mode 100644 index 0000000..eb815d8 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java @@ -0,0 +1,126 @@ +/* + * 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDiscoveryResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductService; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyDecision; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyDecisionClient; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.policy.PolicyInput; + +/** + * Verifies {@link ProductDiscoveryServiceImpl} queries candidates then evaluates one PDP + * decision per candidate, keeping only ALLOWed products (fully permitted, partially + * permitted, and PDP-failure scenarios, AC3/AC4/AC9), and that no denied/failed candidate's + * data leaks into the result. + */ +@ExtendWith(MockitoExtension.class) +class ProductDiscoveryServiceImplTest { + + @Mock + private ProductService productService; + + @Mock + private PolicyDecisionClient policyDecisionClient; + + @InjectMocks + private ProductDiscoveryServiceImpl productDiscoveryService; + + private ProductDTO allowedProduct; + private ProductDTO deniedProduct; + + @BeforeEach + void setUp() { + allowedProduct = ProductDTO.builder().id(1L).name("Allowed").build(); + deniedProduct = ProductDTO.builder().id(2L).name("Denied").build(); + } + + @Test + void filterAuthorised_fullyPermitted_returnsAllCandidates() { + when(policyDecisionClient.evaluate(any())).thenReturn(PolicyDecision.ALLOW); + + List result = + productDiscoveryService.filterAuthorised("client-1", "org-1", List.of(allowedProduct, deniedProduct)); + + assertThat(result).containsExactlyInAnyOrder(allowedProduct, deniedProduct); + } + + @Test + void filterAuthorised_partiallyPermitted_returnsOnlyAllowedAndLeaksNoDeniedData() { + // Also covers the PDP-failure case: PolicyDecisionClient already fails closed + // (returns DENY) on any PDP error, so a denied candidate here is indistinguishable + // from a failed one - both are excluded the same way. + when(policyDecisionClient.evaluate(argThatResource("product:1"))).thenReturn(PolicyDecision.ALLOW); + when(policyDecisionClient.evaluate(argThatResource("product:2"))).thenReturn(PolicyDecision.DENY); + + List result = + productDiscoveryService.filterAuthorised("client-1", "org-1", List.of(allowedProduct, deniedProduct)); + + assertThat(result).containsExactly(allowedProduct); + assertThat(result).extracting(ProductDTO::getId).doesNotContain(2L); + assertThat(result).extracting(ProductDTO::getName).doesNotContain("Denied"); + } + + @Test + void filterAuthorised_noneAuthorised_returnsEmptyList() { + when(policyDecisionClient.evaluate(any())).thenReturn(PolicyDecision.DENY); + + List result = + productDiscoveryService.filterAuthorised("client-1", "org-1", List.of(allowedProduct, deniedProduct)); + + assertThat(result).isEmpty(); + } + + @Test + void filterAuthorised_buildsPolicyInputWithDiscoverActionAndProductResource() { + when(policyDecisionClient.evaluate(any())).thenReturn(PolicyDecision.ALLOW); + + productDiscoveryService.filterAuthorised("client-1", "org-1", List.of(allowedProduct)); + + verify(policyDecisionClient).evaluate(eq(new PolicyInput("client-1", "org-1", "product:1", "discover"))); + } + + @Test + void discover_queriesCandidatesThenFiltersByPolicy() { + when(productService.findDiscoveryCandidates("Alpha", "topic-1", "TypeA")) + .thenReturn(List.of(allowedProduct, deniedProduct)); + when(policyDecisionClient.evaluate(argThatResource("product:1"))).thenReturn(PolicyDecision.ALLOW); + when(policyDecisionClient.evaluate(argThatResource("product:2"))).thenReturn(PolicyDecision.DENY); + + ProductDiscoveryResponseDTO result = + productDiscoveryService.discover("client-1", "org-1", "Alpha", "topic-1", "TypeA"); + + assertThat(result.getProducts()).containsExactly(allowedProduct); + } + + @Test + void discover_noCandidates_returnsEmptyResponse() { + when(productService.findDiscoveryCandidates(any(), any(), any())).thenReturn(List.of()); + + ProductDiscoveryResponseDTO result = productDiscoveryService.discover("client-1", "org-1", null, null, null); + + assertThat(result.getProducts()).isEmpty(); + } + + private PolicyInput argThatResource(String resource) { + return org.mockito.ArgumentMatchers.argThat(input -> input != null && resource.equals(input.resource())); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java index 3917ed2..560d8f2 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java @@ -7,6 +7,10 @@ package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; import java.util.Collections; @@ -14,9 +18,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConverter; import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; @@ -32,7 +36,6 @@ class ProductServiceImplTest { @Mock private ProductConverter productConverter; - @InjectMocks private ProductServiceImpl productService; private Product product; @@ -43,6 +46,10 @@ class ProductServiceImplTest { @BeforeEach void setUp() { + // Constructed manually (not @InjectMocks) - the constructor's int max-candidates + // parameter has no mock to inject + productService = new ProductServiceImpl(productRepository, productConverter, 200); + // Set up test data Producer producer = new Producer(); producer.setId(producerId); @@ -197,4 +204,44 @@ void getProductsByProducerIds_withNullRepositoryResult_shouldReturnEmptyList() { verify(productRepository).findByProducerIds(producerIds); verify(productConverter, never()).toDtoList(any()); } + + @Test + void findDiscoveryCandidates_delegatesFiltersAndLimitToRepository() { + // Arrange: constructed directly (not @InjectMocks) so the max-candidates limit is explicit + ProductServiceImpl service = new ProductServiceImpl(productRepository, productConverter, 5); + List products = List.of(product); + List productDTOs = List.of(productDTO); + + when(productRepository.findDiscoveryCandidates(eq("Alpha"), eq("topic-1"), eq("TypeA"), any(Pageable.class))) + .thenReturn(products); + when(productConverter.toDtoList(products)).thenReturn(productDTOs); + + // Act + List result = service.findDiscoveryCandidates("Alpha", "topic-1", "TypeA"); + + // Assert + assertEquals(productDTOs, result); + verify(productRepository) + .findDiscoveryCandidates( + eq("Alpha"), + eq("topic-1"), + eq("TypeA"), + argThat(pageable -> pageable.getPageSize() == 5 && pageable.getPageNumber() == 0)); + } + + @Test + void findDiscoveryCandidates_blankFilters_passedAsNullToRepository() { + // Arrange + ProductServiceImpl service = new ProductServiceImpl(productRepository, productConverter, 5); + when(productRepository.findDiscoveryCandidates(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(Collections.emptyList()); + when(productConverter.toDtoList(Collections.emptyList())).thenReturn(Collections.emptyList()); + + // Act + List result = service.findDiscoveryCandidates("", null, " "); + + // Assert + assertTrue(result.isEmpty()); + verify(productRepository).findDiscoveryCandidates(isNull(), isNull(), isNull(), any(Pageable.class)); + } } From e0ff3cbc3184a33c376a03d18565a6974d254cb0 Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Mon, 31 Aug 2026 19:15:32 +0200 Subject: [PATCH 04/10] test(product-discovery): de-duplicate identical controller test bodies noAuthorisedProducts_returnsEmptyListNotError was byte-identical to noCandidates_returnsEmptyListNotError (Sonar). Give it distinct value: send search criteria and verify they're passed through to ProductDiscoveryService.discover unchanged, instead of repeating the same empty-body/empty-response assertion. --- .../controller/v1/ProductDiscoveryControllerTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java index b3c6d4f..40ac4d6 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java @@ -12,6 +12,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -123,15 +124,17 @@ void noCandidates_returnsEmptyListNotError() throws Exception { } @Test - void noAuthorisedProducts_returnsEmptyListNotError() throws Exception { + void noAuthorisedProducts_returnsEmptyListNotErrorAndPassesCriteriaThrough() throws Exception { when(productDiscoveryService.discover(anyString(), any(), any(), any(), any())) .thenReturn(responseWith()); mockMvc.perform(post("/api/v1/product/discovery") .contentType(MediaType.APPLICATION_JSON) - .content("{}")) + .content("{\"name\":\"Alpha\",\"topic\":\"topic-1\",\"type\":\"TypeA\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.products").isEmpty()); + + verify(productDiscoveryService).discover(eq("client-1"), any(), eq("Alpha"), eq("topic-1"), eq("TypeA")); } @Test From 997f8e17c35db82fdb2978268f20412b617a876d Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Mon, 31 Aug 2026 19:15:35 +0200 Subject: [PATCH 05/10] test(product-discovery): remove useless eq() around sole verify argument evaluate() takes a single argument, so wrapping it in eq(...) is a no-op Mockito already does by default (Sonar). Pass the PolicyInput value directly. --- .../service/data/impl/ProductDiscoveryServiceImplTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java index eb815d8..698b611 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java @@ -8,7 +8,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -95,7 +94,7 @@ void filterAuthorised_buildsPolicyInputWithDiscoverActionAndProductResource() { productDiscoveryService.filterAuthorised("client-1", "org-1", List.of(allowedProduct)); - verify(policyDecisionClient).evaluate(eq(new PolicyInput("client-1", "org-1", "product:1", "discover"))); + verify(policyDecisionClient).evaluate(new PolicyInput("client-1", "org-1", "product:1", "discover")); } @Test From 21a109efdbd5b869aa544a07d825906e70edc53f Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Tue, 1 Sep 2026 10:26:51 +0200 Subject: [PATCH 06/10] fix(product-discovery): log request URI, not empty context path, on 400s The MethodArgumentNotValidException/HttpMessageNotReadableException handlers logged request.getContextPath() (empty for a root-mapped app) instead of request.getDescription(false), unlike every sibling handler in this class - leaving debug logs with no indication of which endpoint a validation failure came from. --- .../exception/handlers/GlobalExceptionHandler.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java index 42e5144..f0ca438 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java @@ -154,7 +154,7 @@ public ResponseEntity handleMethodArgumentNotValidException( log.debug( "Request validation failed, error_id={}, path={}: {}", errorId, - request.getContextPath(), + request.getDescription(false), ex.getMessage()); ErrorResponse errorResponse = @@ -177,7 +177,10 @@ public ResponseEntity handleHttpMessageNotReadableException( String errorId = generateErrorId(); log.debug( - "Malformed request body, error_id={}, path={}: {}", errorId, request.getContextPath(), ex.getMessage()); + "Malformed request body, error_id={}, path={}: {}", + errorId, + request.getDescription(false), + ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Invalid request body", errorId); From 2c47062872e5529e5da4ab6536b8f6b74a688aa4 Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Tue, 1 Sep 2026 10:26:55 +0200 Subject: [PATCH 07/10] fix(product-discovery): fail fast on invalid max-candidates config application.product-discovery.max-candidates fed straight into PageRequest.of(0, n), which throws IllegalArgumentException for n < 1 (e.g. a misconfigured 0, or an attempt to mean "unlimited") - crashing every discovery request with a 500 instead of failing at startup. Validate in the constructor, matching the sibling OpaProperties' fail-fast intent. --- .../service/data/impl/ProductServiceImpl.java | 7 +++++++ .../service/data/impl/ProductServiceImplTest.java | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java index e36c0b9..9654ffa 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java @@ -36,11 +36,18 @@ public class ProductServiceImpl implements ProductService { * @param productConverter the converter for entity-to-DTO conversion * @param maxDiscoveryCandidates upper bound on candidates fetched for discovery, keeping * the per-candidate PDP call loop in {@code ProductDiscoveryService} bounded + * @throws IllegalArgumentException if maxDiscoveryCandidates is less than 1 - fails fast + * at startup rather than on every discovery request (PageRequest.of rejects a page + * size below 1) */ public ProductServiceImpl( ProductRepository productRepository, ProductConverter productConverter, @Value("${application.product-discovery.max-candidates:200}") int maxDiscoveryCandidates) { + if (maxDiscoveryCandidates < 1) { + throw new IllegalArgumentException( + "application.product-discovery.max-candidates must be at least 1, got " + maxDiscoveryCandidates); + } this.productRepository = productRepository; this.productConverter = productConverter; this.maxDiscoveryCandidates = maxDiscoveryCandidates; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java index 560d8f2..b7dd622 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java @@ -244,4 +244,16 @@ void findDiscoveryCandidates_blankFilters_passedAsNullToRepository() { assertTrue(result.isEmpty()); verify(productRepository).findDiscoveryCandidates(isNull(), isNull(), isNull(), any(Pageable.class)); } + + @Test + void constructor_rejectsZeroMaxCandidates() { + assertThrows( + IllegalArgumentException.class, () -> new ProductServiceImpl(productRepository, productConverter, 0)); + } + + @Test + void constructor_rejectsNegativeMaxCandidates() { + assertThrows( + IllegalArgumentException.class, () -> new ProductServiceImpl(productRepository, productConverter, -1)); + } } From d7141d93899c7580a8fb4c19f3999fd7dde5a520 Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Tue, 1 Sep 2026 10:26:59 +0200 Subject: [PATCH 08/10] fix(product-discovery): escape LIKE wildcards in name/topic filters findDiscoveryCandidates built its %contains% pattern via CONCAT without escaping the SQL LIKE metacharacters % and _ in the caller-supplied value, so e.g. name=Data_Feed also matched DataXFeed (since '_' is the LIKE single-char wildcard) - silently violating the documented "contains" filter contract for any name/topic containing % or _. --- .../repository/ProductRepository.java | 35 ++++++++-- .../repository/ProductRepositoryTest.java | 70 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java index 70a8779..359966a 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java @@ -58,12 +58,39 @@ public interface ProductRepository extends JpaRepository { * @param pageable bounds the candidate set size (e.g. {@code PageRequest.of(0, maxCandidates)}) * @return candidate products matching the filters, bounded by {@code pageable} */ + default List findDiscoveryCandidates(String name, String topic, String type, Pageable pageable) { + return findDiscoveryCandidatesByPattern(containsPattern(name), containsPattern(topic), type, pageable); + } + + /** + * Backing query for {@link #findDiscoveryCandidates}. Takes pre-built, LIKE-escaped + * {@code %pattern%} strings (see {@link #containsPattern}) rather than raw filter values, + * so the LIKE wildcards {@code %}/{@code _} in caller-supplied input are matched + * literally, not interpreted as wildcards. + */ @Query("SELECT p FROM Product p " + "LEFT JOIN FETCH p.productType t " - + "WHERE (:name IS NULL OR LOWER(p.name) LIKE LOWER(CONCAT('%', :name, '%'))) " - + "AND (:topic IS NULL OR LOWER(p.topic) LIKE LOWER(CONCAT('%', :topic, '%'))) " + + "WHERE (:namePattern IS NULL OR LOWER(p.name) LIKE LOWER(:namePattern) ESCAPE '\\') " + + "AND (:topicPattern IS NULL OR LOWER(p.topic) LIKE LOWER(:topicPattern) ESCAPE '\\') " + "AND (:type IS NULL OR LOWER(t.name) = LOWER(:type)) " + "ORDER BY p.id") - List findDiscoveryCandidates( - @Param("name") String name, @Param("topic") String topic, @Param("type") String type, Pageable pageable); + List findDiscoveryCandidatesByPattern( + @Param("namePattern") String namePattern, + @Param("topicPattern") String topicPattern, + @Param("type") String type, + Pageable pageable); + + /** + * Builds a {@code %value%} LIKE pattern with the LIKE metacharacters {@code \}, {@code %} + * and {@code _} in {@code value} escaped (backslash-escaped, matching the query's + * {@code ESCAPE '\'} clause), so a search value containing them is matched literally + * instead of as wildcards. + */ + private static String containsPattern(String value) { + if (value == null) { + return null; + } + String escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + return "%" + escaped + "%"; + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java new file mode 100644 index 0000000..ad8d2cb --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java @@ -0,0 +1,70 @@ +/* + * 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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; + +/** + * Verifies {@link ProductRepository#findDiscoveryCandidates}'s default method builds + * LIKE-escaped patterns before delegating to {@link ProductRepository#findDiscoveryCandidatesByPattern}, + * so a search value containing the LIKE metacharacters {@code %}/{@code _} is matched + * literally rather than as a wildcard. Mocked with {@code CALLS_REAL_METHODS} so the default + * method itself executes, with only the underlying {@code @Query} method stubbed. + */ +class ProductRepositoryTest { + + private final ProductRepository productRepository = + Mockito.mock(ProductRepository.class, Mockito.withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS)); + + @Test + void findDiscoveryCandidates_escapesPercentAndUnderscoreInNameAndTopic() { + Pageable pageable = PageRequest.of(0, 10); + when(productRepository.findDiscoveryCandidatesByPattern(any(), any(), any(), eq(pageable))) + .thenReturn(List.of()); + + productRepository.findDiscoveryCandidates("Data_Feed", "topic%1", "TypeA", pageable); + + verify(productRepository) + .findDiscoveryCandidatesByPattern(eq("%Data\\_Feed%"), eq("%topic\\%1%"), eq("TypeA"), eq(pageable)); + } + + @Test + void findDiscoveryCandidates_escapesLiteralBackslash() { + Pageable pageable = PageRequest.of(0, 10); + when(productRepository.findDiscoveryCandidatesByPattern(any(), any(), any(), eq(pageable))) + .thenReturn(List.of()); + + productRepository.findDiscoveryCandidates("a\\b", null, null, pageable); + + verify(productRepository).findDiscoveryCandidatesByPattern(eq("%a\\\\b%"), isNull(), isNull(), eq(pageable)); + } + + @Test + void findDiscoveryCandidates_nullFilters_passedAsNullPatterns() { + Pageable pageable = PageRequest.of(0, 10); + List expected = List.of(); + when(productRepository.findDiscoveryCandidatesByPattern(isNull(), isNull(), isNull(), eq(pageable))) + .thenReturn(expected); + + List result = productRepository.findDiscoveryCandidates(null, null, null, pageable); + + assertThat(result).isEqualTo(expected); + verify(productRepository).findDiscoveryCandidatesByPattern(isNull(), isNull(), isNull(), eq(pageable)); + } +} From 43d36f979cec7198ba59a7ee9a59e7dc1056a992 Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Wed, 2 Sep 2026 13:32:36 +0200 Subject: [PATCH 09/10] refactor(product-discovery): use records for the discovery DTOs Convert ProductDiscoveryRequestDTO/ResponseDTO from mutable Lombok classes to records - value objects with no post-construction mutation needs, so records fit better than the mutable builder pattern used elsewhere in model/dto. @Builder still works on records (Lombok >=1.18.30, this repo is on 1.18.46), so callers keep the same .builder()...build() API; only the getX() accessors change to the record's x() accessors. ProductDiscoveryResponseDTO's never-null products guarantee moves from @Builder.Default (builder-only) to a compact constructor (applies to every construction path, including Jackson deserialization). Matches the existing PolicyInput/ PolicyDecisionRequest/PolicyDecisionResponse record precedent from DPAV-3017. --- .../v1/ProductDiscoveryController.java | 12 +++++------ .../model/dto/ProductDiscoveryRequestDTO.java | 21 ++----------------- .../dto/ProductDiscoveryResponseDTO.java | 16 ++++---------- .../model/dto/ProductDiscoveryDtoTest.java | 8 +++---- .../impl/ProductDiscoveryServiceImplTest.java | 4 ++-- 5 files changed, 18 insertions(+), 43 deletions(-) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.java index 9c2a96e..eb67dd3 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryController.java @@ -74,15 +74,15 @@ public ProductDiscoveryResponseDTO discoverProducts( "Product discovery request clientId={} organisation={} name={} topic={} type={}", principal.clientId(), organisation, - effectiveCriteria.getName(), - effectiveCriteria.getTopic(), - effectiveCriteria.getType()); + effectiveCriteria.name(), + effectiveCriteria.topic(), + effectiveCriteria.type()); return productDiscoveryService.discover( principal.clientId(), organisation, - effectiveCriteria.getName(), - effectiveCriteria.getTopic(), - effectiveCriteria.getType()); + effectiveCriteria.name(), + effectiveCriteria.topic(), + effectiveCriteria.type()); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java index 262ee50..1218bd0 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java @@ -7,11 +7,7 @@ package uk.gov.dbt.ndtp.ia.node.management.model.dto; import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; /** * Search criteria for {@code POST /v1/product/discovery}. All fields are optional; an @@ -19,18 +15,5 @@ * products the requester is authorised to discover - they cannot widen it. */ @Builder -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class ProductDiscoveryRequestDTO { - - @Size(max = 50) - private String name; - - @Size(max = 150) - private String topic; - - @Size(max = 255) - private String type; -} +public record ProductDiscoveryRequestDTO( + @Size(max = 50) String name, @Size(max = 150) String topic, @Size(max = 255) String type) {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java index 285f007..5abf82f 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java @@ -8,11 +8,7 @@ import java.util.ArrayList; import java.util.List; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; /** * Response for {@code POST /v1/product/discovery}: the products the requester is authorised @@ -20,12 +16,8 @@ * null) when no products are authorised or none match the search criteria. */ @Builder -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class ProductDiscoveryResponseDTO { - - @Builder.Default - private List products = new ArrayList<>(); +public record ProductDiscoveryResponseDTO(List products) { + public ProductDiscoveryResponseDTO { + products = products != null ? products : new ArrayList<>(); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java index 867cdb3..1bd251a 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryDtoTest.java @@ -30,9 +30,9 @@ void requestDTO_emptyObject_deserializesWithNoViolations() throws Exception { Set> violations = validator.validate(dto); assertThat(violations).isEmpty(); } - assertThat(dto.getName()).isNull(); - assertThat(dto.getTopic()).isNull(); - assertThat(dto.getType()).isNull(); + assertThat(dto.name()).isNull(); + assertThat(dto.topic()).isNull(); + assertThat(dto.type()).isNull(); } @Test @@ -51,7 +51,7 @@ void requestDTO_oversizedField_failsValidation() { void responseDTO_defaultsToEmptyList_notNull() throws Exception { ProductDiscoveryResponseDTO dto = ProductDiscoveryResponseDTO.builder().build(); - assertThat(dto.getProducts()).isNotNull().isEmpty(); + assertThat(dto.products()).isNotNull().isEmpty(); String json = objectMapper.writeValueAsString(dto); assertThat(json).contains("\"products\":[]"); diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java index 698b611..c0f3615 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java @@ -107,7 +107,7 @@ void discover_queriesCandidatesThenFiltersByPolicy() { ProductDiscoveryResponseDTO result = productDiscoveryService.discover("client-1", "org-1", "Alpha", "topic-1", "TypeA"); - assertThat(result.getProducts()).containsExactly(allowedProduct); + assertThat(result.products()).containsExactly(allowedProduct); } @Test @@ -116,7 +116,7 @@ void discover_noCandidates_returnsEmptyResponse() { ProductDiscoveryResponseDTO result = productDiscoveryService.discover("client-1", "org-1", null, null, null); - assertThat(result.getProducts()).isEmpty(); + assertThat(result.products()).isEmpty(); } private PolicyInput argThatResource(String resource) { From 1bd80e2a5b8c3975fac25726be726187574a04db Mon Sep 17 00:00:00 2001 From: filipSzarek Date: Wed, 2 Sep 2026 13:48:35 +0200 Subject: [PATCH 10/10] test(product-discovery): resolve Sonar findings in ProductRepositoryTest - Use static imports for mock/withSettings/CALLS_REAL_METHODS instead of the Mockito.* qualified form (S8924). - Remove the useless eq(...) wraps in one verify() call where every argument used eq() and none needed a real matcher (S6068). --- .../persistency/repository/ProductRepositoryTest.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java index ad8d2cb..66b7f78 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.java @@ -10,12 +10,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; import java.util.List; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; @@ -30,7 +32,7 @@ class ProductRepositoryTest { private final ProductRepository productRepository = - Mockito.mock(ProductRepository.class, Mockito.withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS)); + mock(ProductRepository.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); @Test void findDiscoveryCandidates_escapesPercentAndUnderscoreInNameAndTopic() { @@ -40,8 +42,7 @@ void findDiscoveryCandidates_escapesPercentAndUnderscoreInNameAndTopic() { productRepository.findDiscoveryCandidates("Data_Feed", "topic%1", "TypeA", pageable); - verify(productRepository) - .findDiscoveryCandidatesByPattern(eq("%Data\\_Feed%"), eq("%topic\\%1%"), eq("TypeA"), eq(pageable)); + verify(productRepository).findDiscoveryCandidatesByPattern("%Data\\_Feed%", "%topic\\%1%", "TypeA", pageable); } @Test