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/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); } 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..eb67dd3 --- /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.name(), + effectiveCriteria.topic(), + effectiveCriteria.type()); + + return productDiscoveryService.discover( + principal.clientId(), + organisation, + effectiveCriteria.name(), + effectiveCriteria.topic(), + effectiveCriteria.type()); + } +} 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..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 @@ -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,56 @@ 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.getDescription(false), + 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.getDescription(false), + ex.getMessage()); + + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.BAD_REQUEST.value(), "Invalid request body", errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + /** * Handles RuntimeException. * 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..1218bd0 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryRequestDTO.java @@ -0,0 +1,19 @@ +/* + * 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.Builder; + +/** + * 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 +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 new file mode 100644 index 0000000..5abf82f --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDiscoveryResponseDTO.java @@ -0,0 +1,23 @@ +/* + * 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.Builder; + +/** + * 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 +public record ProductDiscoveryResponseDTO(List products) { + public ProductDiscoveryResponseDTO { + products = products != null ? 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..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 @@ -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,54 @@ 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} + */ + 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 (: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 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/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..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 @@ -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,30 @@ 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 + * @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) { + 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; } /** @@ -59,4 +77,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..40ac4d6 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ProductDiscoveryControllerTest.java @@ -0,0 +1,186 @@ +/* + * 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.verify; +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_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("{\"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 + 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..1bd251a --- /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.name()).isNull(); + assertThat(dto.topic()).isNull(); + assertThat(dto.type()).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.products()).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/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..66b7f78 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepositoryTest.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.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.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.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 = + mock(ProductRepository.class, withSettings().defaultAnswer(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("%Data\\_Feed%", "%topic\\%1%", "TypeA", 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)); + } +} 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..c0f3615 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductDiscoveryServiceImplTest.java @@ -0,0 +1,125 @@ +/* + * 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.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(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.products()).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.products()).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..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 @@ -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,56 @@ 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)); + } + + @Test + void constructor_rejectsZeroMaxCandidates() { + assertThrows( + IllegalArgumentException.class, () -> new ProductServiceImpl(productRepository, productConverter, 0)); + } + + @Test + void constructor_rejectsNegativeMaxCandidates() { + assertThrows( + IllegalArgumentException.class, () -> new ProductServiceImpl(productRepository, productConverter, -1)); + } }