From fe62e54d915e636ed159b9ce382251b291099282 Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Thu, 30 Jul 2026 04:18:35 +0200 Subject: [PATCH 1/3] CELDEV-1334 Add navigation REST API --- celements-navigation-rest/pom.xml | 189 ++++++++ .../DefaultNavigationNodeValueResolver.java | 51 ++ .../api/NavigationApiException.java | 26 + .../navigation/api/NavigationController.java | 102 ++++ .../api/NavigationErrorResponse.java | 12 + .../api/NavigationExceptionHandler.java | 44 ++ .../navigation/api/NavigationNodeDto.java | 29 ++ .../api/NavigationNodeValueResolver.java | 13 + .../navigation/api/NavigationRequest.java | 12 + .../api/NavigationRequestResolver.java | 105 ++++ .../navigation/api/NavigationSegmentDto.java | 20 + .../navigation/api/NavigationTreeBuilder.java | 178 +++++++ .../api/NavigationTreeResponse.java | 26 + ...efaultNavigationNodeValueResolverTest.java | 42 ++ .../api/NavigationControllerTest.java | 206 ++++++++ .../api/NavigationJsonContractTest.java | 49 ++ .../api/NavigationOpenApiContractTest.java | 150 ++++++ .../api/NavigationRequestResolverTest.java | 225 +++++++++ .../api/NavigationRightsFilteringTest.java | 127 +++++ .../api/NavigationTreeBuilderTest.java | 452 ++++++++++++++++++ 20 files changed, 2058 insertions(+) create mode 100644 celements-navigation-rest/pom.xml create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationApiException.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequest.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequestResolver.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java create mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java create mode 100644 celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java create mode 100644 celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java create mode 100644 celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java create mode 100644 celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java create mode 100644 celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java create mode 100644 celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java create mode 100644 celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java diff --git a/celements-navigation-rest/pom.xml b/celements-navigation-rest/pom.xml new file mode 100644 index 000000000..b8cf2898e --- /dev/null +++ b/celements-navigation-rest/pom.xml @@ -0,0 +1,189 @@ + + + + com.celements + celements-parent + 7.1-SNAPSHOT + + 4.0.0 + celements-navigation-rest + 7.1-SNAPSHOT + Celements Navigation REST API + + + com.celements + celements-core + provided + + + com.celements + celements-model + provided + + + + com.celements + celements-spring-security + provided + + + com.celements + celements-xwiki-core + provided + + + org.xwiki.platform + xwiki-core-model + provided + + + org.springframework + spring-context + provided + + + org.springframework + spring-beans + provided + + + org.springframework + spring-core + provided + + + org.springframework + spring-web + provided + + + org.springframework + spring-webmvc + provided + + + org.springframework.security + spring-security-core + provided + + + com.fasterxml.jackson.core + jackson-annotations + provided + + + io.swagger.core.v3 + swagger-annotations + provided + + + javax.inject + javax.inject + provided + + + com.google.code.findbugs + jsr305 + provided + + + org.slf4j + slf4j-api + provided + + + javax.servlet + javax.servlet-api + provided + + + com.celements + celements-component + test + + + com.celements + celements-commons + test + + + + com.celements + celements-reference + test + + + com.celements + celements-observation + test + + + org.springframework + spring-test + test + + + com.fasterxml.jackson.core + jackson-databind + test + + + org.springdoc + springdoc-openapi-webmvc-core + test + + + org.springdoc + springdoc-openapi-common + 1.8.0 + test + + + org.springframework.security + spring-security-config + test + + + + javax.xml.bind + jaxb-api + test + + + org.easymock + easymock + test + + + junit + junit + test + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + com.celements:celements-spring-security + com.celements:celements-commons + com.celements:celements-reference + com.celements:celements-observation + javax.xml.bind:jaxb-api + + + + + + + scm:git:git@github.com:celements/celements-base.git + scm:git:git@github.com:celements/celements-base.git + https://github.com/celements/celements-base/celements-navigation-rest + HEAD + + diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java new file mode 100644 index 000000000..951354fae --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java @@ -0,0 +1,51 @@ +package com.celements.navigation.api; + +import javax.inject.Inject; + +import org.springframework.stereotype.Component; +import org.xwiki.model.reference.DocumentReference; + +import com.celements.model.context.ModelContext; +import com.celements.model.util.ModelUtils; +import com.celements.navigation.cmd.MultilingualMenuNameCommand; +import com.celements.url.UrlService; + +@Component +final class DefaultNavigationNodeValueResolver implements NavigationNodeValueResolver { + + private final ModelUtils modelUtils; + private final ModelContext modelContext; + private final UrlService urlService; + private final MultilingualMenuNameCommand menuNameCommand; + + @Inject + DefaultNavigationNodeValueResolver(ModelUtils modelUtils, ModelContext modelContext, + UrlService urlService) { + this(modelUtils, modelContext, urlService, new MultilingualMenuNameCommand()); + } + + DefaultNavigationNodeValueResolver(ModelUtils modelUtils, ModelContext modelContext, + UrlService urlService, MultilingualMenuNameCommand menuNameCommand) { + this.modelUtils = modelUtils; + this.modelContext = modelContext; + this.urlService = urlService; + this.menuNameCommand = menuNameCommand; + } + + @Override + public String serialize(DocumentReference docRef) { + return modelUtils.serializeRefLocal(docRef); + } + + @Override + public String resolveTitle(DocumentReference docRef, String language) { + return menuNameCommand.getMultilingualMenuName(modelUtils.serializeRefLocal(docRef), language, + modelContext.getXWikiContext()); + } + + @Override + public String resolveUrl(DocumentReference docRef, String language) { + return urlService.getURL(docRef, "view", "language=" + language); + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationApiException.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationApiException.java new file mode 100644 index 000000000..4dd3dd55b --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationApiException.java @@ -0,0 +1,26 @@ +package com.celements.navigation.api; + +import org.springframework.http.HttpStatus; + +final class NavigationApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final HttpStatus status; + private final String code; + + NavigationApiException(HttpStatus status, String code, String message) { + super(message); + this.status = status; + this.code = code; + } + + HttpStatus status() { + return status; + } + + String code() { + return code; + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java new file mode 100644 index 000000000..85b3aad70 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java @@ -0,0 +1,102 @@ +package com.celements.navigation.api; + +import javax.inject.Inject; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +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.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +@RestController +@RequestMapping("/v1/navigation") +public class NavigationController { + + static final String CACHE_CONTROL = "private, no-store"; + + private final NavigationRequestResolver requestResolver; + private final NavigationTreeBuilder treeBuilder; + + @Inject + NavigationController(NavigationRequestResolver requestResolver, + NavigationTreeBuilder treeBuilder) { + this.requestResolver = requestResolver; + this.treeBuilder = treeBuilder; + } + + @GetMapping(path = "/{nodeSpace}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("permitAll()") + @Operation(summary = "Get the current wiki's navigation tree", description = """ + Public endpoint returning a caller-relative, rights-filtered navigation tree. + Input and output references are canonical and local to the wiki handling the request. + Responses are private and not cacheable by shared or browser caches. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", + description = "The rights-filtered segmented navigation tree", + content = @Content(schema = @Schema(implementation = NavigationTreeResponse.class), + examples = @ExampleObject(value = """ + { + "nodeSpace": "Content", + "currentNode": "Content.MyPage", + "language": "de", + "partName": null, + "showInactiveToLevel": 2, + "segments": [] + } + """))), + @ApiResponse(responseCode = "400", + description = "Invalid reference or parameter, or unsupported language", + content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class), + examples = @ExampleObject(value = """ + {"code":"invalid_reference","message":"The reference is invalid."} + """))), + @ApiResponse(responseCode = "404", + description = "The active node is absent, inaccessible, out of root, or part-excluded", + content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class), + examples = @ExampleObject(value = """ + { + "code": "navigation_node_not_found", + "message": "The navigation node was not found." + } + """))), + @ApiResponse(responseCode = "500", description = "Navigation infrastructure is unavailable", + content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class), + examples = @ExampleObject(value = """ + { + "code": "navigation_unavailable", + "message": "Navigation is currently unavailable." + } + """))) }) + public ResponseEntity getNavigation( + @Parameter(description = "Canonical local space reference identifying the navigation root", + example = "Content", required = true) @PathVariable String nodeSpace, + @Parameter(description = "Canonical local document reference identifying the active node", + example = "Content.MyPage") @RequestParam(required = false) String currentNode, + @Parameter(description = "Allowed wiki language; defaults to the current request language", + example = "de") @RequestParam(required = false) String language, + @Parameter( + description = "Case-sensitive root part filter; missing or blank returns all parts", + example = "main") @RequestParam(required = false) String partName, + @Parameter(description = "Inactive expansion threshold from 0 through 100", example = "2", + schema = @Schema(defaultValue = "0", minimum = "0", maximum = "100")) @RequestParam( + name = "show_inactive_to_level", defaultValue = "0") int showInactiveToLevel) { + var request = requestResolver.resolve(nodeSpace, currentNode, language, partName, + showInactiveToLevel); + var response = treeBuilder.build(request); + return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL).body(response); + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java new file mode 100644 index 000000000..fbef7d2fd --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java @@ -0,0 +1,12 @@ +package com.celements.navigation.api; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Stable, safe navigation API error.") +public record NavigationErrorResponse( + @Schema(description = "Stable machine-readable error code", + example = "invalid_reference") String code, + @Schema(description = "Safe error message", + example = "The reference is invalid.") String message) { + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java new file mode 100644 index 000000000..97e3aa2b0 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java @@ -0,0 +1,44 @@ +package com.celements.navigation.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestControllerAdvice(assignableTypes = NavigationController.class) +public final class NavigationExceptionHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(NavigationExceptionHandler.class); + + @ExceptionHandler(NavigationApiException.class) + public ResponseEntity handleNavigationApiException( + NavigationApiException exception) { + return response(exception.status(), + new NavigationErrorResponse(exception.code(), exception.getMessage())); + } + + @ExceptionHandler({ MethodArgumentTypeMismatchException.class, ConversionFailedException.class }) + public ResponseEntity handleInvalidParameter() { + return response(HttpStatus.BAD_REQUEST, + new NavigationErrorResponse("invalid_parameter", "The parameter is invalid.")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpectedException(Exception exception) { + LOGGER.error("Navigation REST request failed.", exception); + return response(HttpStatus.INTERNAL_SERVER_ERROR, new NavigationErrorResponse( + "navigation_unavailable", "Navigation is currently unavailable.")); + } + + private ResponseEntity response(HttpStatus status, + NavigationErrorResponse body) { + return ResponseEntity.status(status) + .header(HttpHeaders.CACHE_CONTROL, NavigationController.CACHE_CONTROL).body(body); + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java new file mode 100644 index 000000000..7e2afc07c --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java @@ -0,0 +1,29 @@ +package com.celements.navigation.api; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "One rights-visible navigation node.") +public record NavigationNodeDto( + @Schema(description = "Canonical local document reference", + example = "Content.Home") String docRef, + @Schema(description = "Relative view URL containing the resolved language", + example = "/Content/Home?language=de") String url, + @Schema(description = "Localized navigation title", example = "Startseite") String title, + @JsonProperty("isLeaf") @Schema( + description = "Whether the node has no valid, rights-visible children") boolean isLeaf, + @JsonProperty("isActive") @Schema( + description = "Whether this node is the requested current node") boolean isActive, + @JsonProperty("isOpen") @Schema( + description = "Whether this node's children are expanded") boolean isOpen, + @Schema( + description = "Expanded child nodes in effective navigation position order") List children) { + + public NavigationNodeDto { + children = List.copyOf(children); + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java new file mode 100644 index 000000000..1278aafe6 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java @@ -0,0 +1,13 @@ +package com.celements.navigation.api; + +import org.xwiki.model.reference.DocumentReference; + +interface NavigationNodeValueResolver { + + String serialize(DocumentReference docRef); + + String resolveTitle(DocumentReference docRef, String language); + + String resolveUrl(DocumentReference docRef, String language); + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequest.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequest.java new file mode 100644 index 000000000..4a1b16a35 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequest.java @@ -0,0 +1,12 @@ +package com.celements.navigation.api; + +import java.util.Optional; + +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.SpaceReference; + +record NavigationRequest(SpaceReference nodeSpace, String serializedNodeSpace, + Optional currentNode, Optional serializedCurrentNode, + String language, Optional partName, int showInactiveToLevel) { + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequestResolver.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequestResolver.java new file mode 100644 index 000000000..6b84e0f62 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequestResolver.java @@ -0,0 +1,105 @@ +package com.celements.navigation.api; + +import java.util.Optional; + +import javax.inject.Inject; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.EntityReference; +import org.xwiki.model.reference.SpaceReference; + +import com.celements.model.context.ModelContext; +import com.celements.model.util.ModelUtils; +import com.celements.web.service.IWebUtilsService; + +@Component +class NavigationRequestResolver { + + private static final String INVALID_REFERENCE_MESSAGE = "The reference is invalid."; + private static final String INVALID_PARAMETER_MESSAGE = "The parameter is invalid."; + private static final String UNSUPPORTED_LANGUAGE_MESSAGE = "The language is not supported."; + + private final ModelUtils modelUtils; + private final ModelContext modelContext; + private final IWebUtilsService webUtilsService; + + @Inject + NavigationRequestResolver(ModelUtils modelUtils, ModelContext modelContext, + IWebUtilsService webUtilsService) { + this.modelUtils = modelUtils; + this.modelContext = modelContext; + this.webUtilsService = webUtilsService; + } + + NavigationRequest resolve(String nodeSpace, String currentNode, String language, String partName, + int showInactiveToLevel) { + if (showInactiveToLevel < 0 || showInactiveToLevel > 100) { + throw invalidParameter(); + } + var spaceRef = resolveCanonicalLocal(nodeSpace, SpaceReference.class); + var serializedCurrentNode = Optional.ofNullable(currentNode); + var currentNodeRef = serializedCurrentNode + .map(value -> resolveCanonicalLocal(value, DocumentReference.class)); + var resolvedLanguage = resolveLanguage(language, spaceRef); + var normalizedPartName = normalizeOptional(partName); + return new NavigationRequest(spaceRef, nodeSpace, currentNodeRef, serializedCurrentNode, + resolvedLanguage, normalizedPartName, showInactiveToLevel); + } + + private T resolveCanonicalLocal(String value, Class type) { + if (value == null || value.isBlank() || !value.equals(value.trim())) { + throw invalidReference(); + } + var wikiRef = modelContext.getWikiRef(); + final T reference; + try { + reference = modelUtils.resolveRef(value, type, wikiRef); + } catch (IllegalArgumentException exception) { + throw invalidReference(); + } + if (reference == null || !value.equals(modelUtils.serializeRefLocal(reference))) { + throw invalidReference(); + } + return reference; + } + + private String resolveLanguage(String requestedLanguage, SpaceReference nodeSpace) { + var explicitLanguage = normalizeOptional(requestedLanguage); + if (explicitLanguage.isEmpty()) { + return modelContext.getLanguage().orElseGet(modelContext::getDefaultLanguage); + } + final String normalizedLanguage; + try { + normalizedLanguage = modelUtils.normalizeLang(explicitLanguage.orElseThrow()); + } catch (IllegalArgumentException exception) { + throw unsupportedLanguage(); + } + if (normalizedLanguage.isBlank() + || !webUtilsService.getAllowedLanguages(nodeSpace).contains(normalizedLanguage)) { + throw unsupportedLanguage(); + } + return normalizedLanguage; + } + + private Optional normalizeOptional(String value) { + return Optional.ofNullable(value).map(String::trim).filter(normalized -> !normalized.isEmpty()); + } + + private NavigationApiException invalidReference() { + return new NavigationApiException(HttpStatus.BAD_REQUEST, "invalid_reference", + INVALID_REFERENCE_MESSAGE); + } + + private NavigationApiException invalidParameter() { + return new NavigationApiException(HttpStatus.BAD_REQUEST, "invalid_parameter", + INVALID_PARAMETER_MESSAGE); + } + + private NavigationApiException unsupportedLanguage() { + return new NavigationApiException(HttpStatus.BAD_REQUEST, "unsupported_language", + UNSUPPORTED_LANGUAGE_MESSAGE); + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java new file mode 100644 index 000000000..d90e19d2b --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java @@ -0,0 +1,20 @@ +package com.celements.navigation.api; + +import java.util.List; + +import javax.annotation.Nullable; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "A case-sensitive root navigation part and its nodes.") +public record NavigationSegmentDto( + @Nullable @Schema(description = "Root part name; null represents the unnamed part", + example = "main", nullable = true) String partName, + @Schema( + description = "Root nodes in effective navigation position order") List nodes) { + + public NavigationSegmentDto { + nodes = List.copyOf(nodes); + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java new file mode 100644 index 000000000..f0d54851d --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java @@ -0,0 +1,178 @@ +package com.celements.navigation.api; + +import static java.util.Comparator.*; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import javax.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.xwiki.model.reference.DocumentReference; + +import com.celements.navigation.TreeNode; +import com.celements.navigation.filter.InternalRightsFilter; +import com.celements.navigation.service.ITreeNodeService; +import com.celements.parents.IDocumentParentsListerRole; + +@Component +class NavigationTreeBuilder { + + private static final Logger LOGGER = LoggerFactory.getLogger(NavigationTreeBuilder.class); + private static final Comparator PART_NAME_COMPARATOR = comparing( + (String value) -> value.toLowerCase(Locale.ROOT)).thenComparing(naturalOrder()); + + private final ITreeNodeService treeNodeService; + private final IDocumentParentsListerRole parentsLister; + private final NavigationNodeValueResolver valueResolver; + + @Inject + NavigationTreeBuilder(ITreeNodeService treeNodeService, IDocumentParentsListerRole parentsLister, + NavigationNodeValueResolver valueResolver) { + this.treeNodeService = treeNodeService; + this.parentsLister = parentsLister; + this.valueResolver = valueResolver; + } + + NavigationTreeResponse build(NavigationRequest request) { + var rootFilter = new InternalRightsFilter(); + rootFilter.setMenuPart(request.partName().orElse("")); + var descendantFilter = new InternalRightsFilter(); + Set activePath = resolveActivePath(request); + List roots = readNodes( + treeNodeService.getSubNodesForParent(request.nodeSpace(), rootFilter), descendantFilter, + request.language(), new HashSet<>()); + if (request.currentNode().isPresent() + && roots.stream().noneMatch(root -> root.contains(request.currentNode().orElseThrow()))) { + throw nodeNotFound(); + } + return new NavigationTreeResponse(request.serializedNodeSpace(), + request.serializedCurrentNode().orElse(null), request.language(), + request.partName().orElse(null), request.showInactiveToLevel(), + buildSegments(roots, activePath, request)); + } + + private Set resolveActivePath(NavigationRequest request) { + return request.currentNode().map(currentNode -> { + var path = new HashSet<>( + Objects.requireNonNull(parentsLister.getDocumentParentsList(currentNode, true))); + path.add(currentNode); + return Set.copyOf(path); + }).orElseGet(Set::of); + } + + private List buildSegments(List roots, + Set activePath, NavigationRequest request) { + if (request.partName().isPresent()) { + String partName = request.partName().orElseThrow(); + return List.of(new NavigationSegmentDto(partName, + roots.stream().filter(root -> partName.equals(root.partName())) + .map(root -> toDto(root, 1, activePath, request)).toList())); + } + Map> groupedRoots = new LinkedHashMap<>(); + roots.forEach(root -> groupedRoots + .computeIfAbsent(root.partName(), ignored -> new ArrayList<>()).add(root)); + return groupedRoots.entrySet().stream().sorted(Map.Entry.comparingByKey(PART_NAME_COMPARATOR)) + .map(entry -> new NavigationSegmentDto(emptyToNull(entry.getKey()), + entry.getValue().stream().map(root -> toDto(root, 1, activePath, request)).toList())) + .toList(); + } + + private NavigationNodeDto toDto(InternalNode node, int level, Set activePath, + NavigationRequest request) { + boolean isActive = request.currentNode().filter(node.docRef()::equals).isPresent(); + boolean onActivePath = activePath.contains(node.docRef()); + boolean expandForInactiveLevel = level < request.showInactiveToLevel(); + boolean isOpen = onActivePath || (expandForInactiveLevel && !node.children().isEmpty()); + List children = isOpen + ? node.children().stream().map(child -> toDto(child, level + 1, activePath, request)) + .toList() + : List.of(); + return new NavigationNodeDto(node.serializedDocRef(), node.url(), node.title(), + node.children().isEmpty(), isActive, isOpen, children); + } + + private List readNodes(List sourceNodes, InternalRightsFilter filter, + String language, Set ancestors) { + return Objects.requireNonNull(sourceNodes).stream() + .map(node -> readNode(node, filter, language, ancestors)).flatMap(Optional::stream) + .toList(); + } + + private Optional readNode(TreeNode sourceNode, InternalRightsFilter filter, + String language, Set ancestors) { + DocumentReference docRef = sourceNode.getDocumentReference(); + if (!ancestors.add(docRef)) { + throw new IllegalStateException("Navigation tree contains a cycle at " + docRef); + } + try { + String serializedDocRef = valueResolver.serialize(docRef); + if (isBlank(serializedDocRef)) { + return invalidNode(docRef, "docRef"); + } + String title = valueResolver.resolveTitle(docRef, language); + if (isBlank(title)) { + return invalidNode(docRef, "title"); + } + String url = valueResolver.resolveUrl(docRef, language); + if (isBlank(url)) { + return invalidNode(docRef, "url"); + } + List sourceChildren = treeNodeService.getSubNodesForParent(docRef, filter); + if (sourceChildren == null) { + return invalidNode(docRef, "children"); + } + List children = readNodes(sourceChildren, filter, language, ancestors); + return Optional.of(new InternalNode(docRef, serializedDocRef, sourceNode.getPartName(), url, + title, children)); + } finally { + ancestors.remove(docRef); + } + } + + private Optional invalidNode(DocumentReference docRef, String field) { + LOGGER.warn("Omitting navigation node [{}] because mandatory field [{}] is invalid.", docRef, + field); + return Optional.empty(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String emptyToNull(String value) { + return value.isEmpty() ? null : value; + } + + private NavigationApiException nodeNotFound() { + return new NavigationApiException(HttpStatus.NOT_FOUND, "navigation_node_not_found", + "The navigation node was not found."); + } + + private record InternalNode(DocumentReference docRef, String serializedDocRef, String partName, + String url, String title, List children) { + + private InternalNode { + partName = Objects.requireNonNullElse(partName, ""); + children = List.copyOf(children); + } + + boolean contains(DocumentReference reference) { + return docRef.equals(reference) + || children.stream().anyMatch(child -> child.contains(reference)); + } + + } + +} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java new file mode 100644 index 000000000..a42cdf358 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java @@ -0,0 +1,26 @@ +package com.celements.navigation.api; + +import java.util.List; + +import javax.annotation.Nullable; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Caller-relative, localized, segmented navigation tree.") +public record NavigationTreeResponse( + @Schema(description = "Canonical local navigation root", example = "Content") String nodeSpace, + @Nullable @Schema(description = "Canonical local active document reference", + example = "Content.MyPage", nullable = true) String currentNode, + @Schema(description = "Resolved language", example = "de") String language, + @Nullable @Schema(description = "Requested case-sensitive root part filter", example = "main", + nullable = true) String partName, + @Schema(description = "Inactive expansion threshold", minimum = "0", maximum = "100", + example = "2") int showInactiveToLevel, + @Schema( + description = "Navigation parts; the unnamed part is first") List segments) { + + public NavigationTreeResponse { + segments = List.copyOf(segments); + } + +} diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java new file mode 100644 index 000000000..c57dd5750 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java @@ -0,0 +1,42 @@ +package com.celements.navigation.api; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import org.junit.Test; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.SpaceReference; +import org.xwiki.model.reference.WikiReference; + +import com.celements.model.context.ModelContext; +import com.celements.model.util.ModelUtils; +import com.celements.navigation.cmd.MultilingualMenuNameCommand; +import com.celements.url.UrlService; +import com.xpn.xwiki.XWikiContext; + +public class DefaultNavigationNodeValueResolverTest { + + @Test + public void resolvesCanonicalTitleAndLanguageStableViewUrl() { + ModelUtils modelUtils = createMock(ModelUtils.class); + ModelContext modelContext = createMock(ModelContext.class); + UrlService urlService = createMock(UrlService.class); + MultilingualMenuNameCommand menuNameCommand = createMock(MultilingualMenuNameCommand.class); + XWikiContext xwikiContext = createMock(XWikiContext.class); + var docRef = new DocumentReference("Home", + new SpaceReference("Content", new WikiReference("xwiki"))); + expect(modelUtils.serializeRefLocal(docRef)).andReturn("Content.Home").times(2); + expect(modelContext.getXWikiContext()).andReturn(xwikiContext); + expect(menuNameCommand.getMultilingualMenuName("Content.Home", "de", xwikiContext)) + .andReturn("Startseite"); + expect(urlService.getURL(docRef, "view", "language=de")).andReturn("/Content/Home?language=de"); + replay(modelUtils, modelContext, urlService, menuNameCommand, xwikiContext); + var resolver = new DefaultNavigationNodeValueResolver(modelUtils, modelContext, urlService, + menuNameCommand); + assertEquals("Content.Home", resolver.serialize(docRef)); + assertEquals("Startseite", resolver.resolveTitle(docRef, "de")); + assertEquals("/Content/Home?language=de", resolver.resolveUrl(docRef, "de")); + verify(modelUtils, modelContext, urlService, menuNameCommand, xwikiContext); + } + +} diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java new file mode 100644 index 000000000..5d3a3048b --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java @@ -0,0 +1,206 @@ +package com.celements.navigation.api; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.xwiki.model.reference.SpaceReference; +import org.xwiki.model.reference.WikiReference; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class NavigationControllerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private NavigationRequestResolver requestResolver; + private NavigationTreeBuilder treeBuilder; + private MockMvc mockMvc; + + @Before + public void prepare() { + requestResolver = createMock(NavigationRequestResolver.class); + treeBuilder = createMock(NavigationTreeBuilder.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new NavigationController(requestResolver, treeBuilder)) + .setControllerAdvice(new NavigationExceptionHandler()).build(); + } + + @Test + public void getNavigation_isPublicThroughMethodSecurityForGuestAndAuthenticatedCaller() + throws Exception { + var request = request(); + var response = response(); + expect(requestResolver.resolve("Content", null, "de", null, 0)).andReturn(request).times(2); + expect(treeBuilder.build(request)).andReturn(response).times(2); + replay(requestResolver, treeBuilder); + var context = new AnnotationConfigApplicationContext(); + context.register(MethodSecurityTestConfig.class); + context.registerBean(NavigationController.class, + () -> new NavigationController(requestResolver, treeBuilder)); + context.refresh(); + var securedController = context.getBean(NavigationController.class); + try { + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken( + "guest-key", "guest", List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + assertEquals(response, + securedController.getNavigation("Content", null, "de", null, 0).getBody()); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + "authenticated-user", "n/a", List.of(new SimpleGrantedAuthority("ROLE_USER")))); + assertEquals(response, + securedController.getNavigation("Content", null, "de", null, 0).getBody()); + } finally { + SecurityContextHolder.clearContext(); + context.close(); + } + verify(requestResolver, treeBuilder); + Method method = NavigationController.class.getMethod("getNavigation", String.class, + String.class, String.class, String.class, int.class); + assertEquals("permitAll()", method.getAnnotation(PreAuthorize.class).value()); + } + + @Test + public void getNavigation_returnsExactJsonAndPrivateNoStoreHeader() throws Exception { + var request = request(); + expect(requestResolver.resolve("Content", null, "de", null, 0)).andReturn(request); + expect(treeBuilder.build(request)).andReturn(response()); + replay(requestResolver, treeBuilder); + var result = mockMvc + .perform(get("/v1/navigation/Content").param("language", "de") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "private, no-store")) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)).andReturn(); + verify(requestResolver, treeBuilder); + JsonNode json = readJson(result.getResponse().getContentAsByteArray()); + assertEquals("Content", json.get("nodeSpace").asText()); + assertTrue(json.get("currentNode").isNull()); + assertTrue(json.get("partName").isNull()); + assertTrue(json.at("/segments/0/partName").isNull()); + assertTrue(json.at("/segments/0/nodes/0/isLeaf").asBoolean()); + assertFalse(json.at("/segments/0/nodes/0/isActive").asBoolean()); + assertFalse(json.at("/segments/0/nodes/0/isOpen").asBoolean()); + assertTrue(json.at("/segments/0/nodes/0/children").isEmpty()); + } + + @Test + public void getNavigation_rejectsNonnumericInactiveLevel() throws Exception { + replay(requestResolver, treeBuilder); + var result = mockMvc + .perform(get("/v1/navigation/Content").param("show_inactive_to_level", "not-a-number")) + .andExpect(status().isBadRequest()) + .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); + verify(requestResolver, treeBuilder); + assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter", + "The parameter is invalid."); + } + + @Test + public void getNavigation_returnsStableSafeApiError() throws Exception { + expect(requestResolver.resolve("Content", null, null, null, 101)) + .andThrow(new NavigationApiException(HttpStatus.BAD_REQUEST, "invalid_parameter", + "The parameter is invalid.")); + replay(requestResolver, treeBuilder); + var result = mockMvc + .perform(get("/v1/navigation/Content").param("show_inactive_to_level", "101")) + .andExpect(status().isBadRequest()) + .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); + verify(requestResolver, treeBuilder); + assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter", + "The parameter is invalid."); + } + + @Test + public void getNavigation_returnsPrivateNoStoreHeaderForNotFound() throws Exception { + var request = request(); + expect(requestResolver.resolve("Content", "Content.Missing", null, null, 0)).andReturn(request); + expect(treeBuilder.build(request)).andThrow(new NavigationApiException(HttpStatus.NOT_FOUND, + "navigation_node_not_found", "The navigation node was not found.")); + replay(requestResolver, treeBuilder); + var result = mockMvc + .perform(get("/v1/navigation/Content").param("currentNode", "Content.Missing")) + .andExpect(status().isNotFound()) + .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); + verify(requestResolver, treeBuilder); + assertError(result.getResponse().getContentAsByteArray(), "navigation_node_not_found", + "The navigation node was not found."); + } + + @Test + public void getNavigation_mapsUnexpectedFailureWithoutLeakingDetails() throws Exception { + var request = request(); + expect(requestResolver.resolve("Content", null, null, null, 0)).andReturn(request); + expect(treeBuilder.build(request)).andThrow(new IllegalStateException("secret backend detail")); + replay(requestResolver, treeBuilder); + var result = mockMvc.perform(get("/v1/navigation/Content")) + .andExpect(status().isInternalServerError()) + .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); + verify(requestResolver, treeBuilder); + assertFalse(result.getResponse().getContentAsString().contains("secret backend detail")); + assertError(result.getResponse().getContentAsByteArray(), "navigation_unavailable", + "Navigation is currently unavailable."); + } + + @Test + public void getNavigation_mapsRequestInfrastructureNullPointerExceptionToGenericFailure() + throws Exception { + expect(requestResolver.resolve("Content", null, null, null, 0)) + .andThrow(new NullPointerException("secret reference backend detail")); + replay(requestResolver, treeBuilder); + var result = mockMvc.perform(get("/v1/navigation/Content")) + .andExpect(status().isInternalServerError()) + .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); + verify(requestResolver, treeBuilder); + assertFalse( + result.getResponse().getContentAsString().contains("secret reference backend detail")); + assertError(result.getResponse().getContentAsByteArray(), "navigation_unavailable", + "Navigation is currently unavailable."); + } + + private NavigationRequest request() { + return new NavigationRequest(new SpaceReference("Content", new WikiReference("xwiki")), + "Content", Optional.empty(), Optional.empty(), "de", Optional.empty(), 0); + } + + private NavigationTreeResponse response() { + var node = new NavigationNodeDto("Content.Home", "/Content/Home?language=de", "Startseite", + true, false, false, List.of()); + return new NavigationTreeResponse("Content", null, "de", null, 0, + List.of(new NavigationSegmentDto(null, List.of(node)))); + } + + private JsonNode readJson(byte[] content) throws Exception { + return objectMapper.readTree(content); + } + + private void assertError(byte[] content, String code, String message) throws Exception { + JsonNode json = readJson(content); + assertEquals(code, json.get("code").asText()); + assertEquals(message, json.get("message").asText()); + } + + @Configuration + @EnableGlobalMethodSecurity(prePostEnabled = true) + static class MethodSecurityTestConfig { + + } + +} diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java new file mode 100644 index 000000000..1646c88f6 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java @@ -0,0 +1,49 @@ +package com.celements.navigation.api; + +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class NavigationJsonContractTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void serializesExplicitNullsAndExactBooleanPropertyNames() throws Exception { + var node = new NavigationNodeDto("Content.Home", "/Content/Home?language=de", "Startseite", + true, false, true, List.of()); + var response = new NavigationTreeResponse("Content", null, "de", null, 2, + List.of(new NavigationSegmentDto(null, List.of(node)))); + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsBytes(response)); + assertTrue(json.has("currentNode")); + assertTrue(json.get("currentNode").isNull()); + assertTrue(json.has("partName")); + assertTrue(json.get("partName").isNull()); + assertTrue(json.at("/segments/0/partName").isNull()); + JsonNode nodeJson = json.at("/segments/0/nodes/0"); + assertTrue(nodeJson.has("isLeaf")); + assertTrue(nodeJson.has("isActive")); + assertTrue(nodeJson.has("isOpen")); + assertFalse(nodeJson.has("leaf")); + assertFalse(nodeJson.has("active")); + assertFalse(nodeJson.has("open")); + assertTrue(nodeJson.get("children").isArray()); + } + + @Test + public void defensivelyCopiesAllDtoLists() { + var mutableChildren = new ArrayList(); + var node = new NavigationNodeDto("Content.Home", "/Content/Home", "Home", true, false, false, + mutableChildren); + mutableChildren.add(node); + assertTrue(node.children().isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> node.children().add(node)); + } + +} diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java new file mode 100644 index 000000000..062165186 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java @@ -0,0 +1,150 @@ +package com.celements.navigation.api; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springdoc.core.SpringDocConfigProperties; +import org.springdoc.core.SpringDocConfiguration; +import org.springdoc.webmvc.core.SpringDocWebMvcConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class NavigationOpenApiContractTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private AnnotationConfigWebApplicationContext context; + private MockMvc mockMvc; + + @Before + public void prepare() { + context = new AnnotationConfigWebApplicationContext(); + context.setServletContext(new MockServletContext()); + context.register(OpenApiTestConfig.class); + context.refresh(); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @After + public void cleanup() { + context.close(); + } + + @Test + public void apiDocsExposeNavigationOperationParametersResponsesAndSchemas() throws Exception { + var result = mockMvc.perform(get("/api/v3/api-docs").servletPath("/api")) + .andExpect(status().isOk()).andReturn(); + JsonNode json = objectMapper.readTree(result.getResponse().getContentAsByteArray()); + JsonNode operation = json.at("/paths/~1v1~1navigation~1{nodeSpace}/get"); + assertFalse(operation.isMissingNode()); + assertTrue(operation.get("description").asText().contains("rights-filtered")); + assertTrue(operation.get("description").asText().contains("private")); + assertEquals( + Set.of("nodeSpace", "currentNode", "language", "partName", "show_inactive_to_level"), + parameterNames(operation.get("parameters"))); + operation.get("parameters") + .forEach(parameter -> assertFalse(parameter.get("description").asText().isBlank())); + assertTrue(findParameter(operation, "nodeSpace").get("required").asBoolean()); + JsonNode inactiveLevelSchema = findParameter(operation, "show_inactive_to_level").get("schema"); + assertEquals("0", inactiveLevelSchema.get("default").asText()); + assertEquals(0, inactiveLevelSchema.get("minimum").asInt()); + assertEquals(100, inactiveLevelSchema.get("maximum").asInt()); + assertEquals(Set.of("200", "400", "404", "500"), + iterableFieldNames(operation.get("responses").fieldNames())); + for (String status : Set.of("200", "400", "404", "500")) { + JsonNode response = operation.get("responses").get(status); + assertFalse(response.get("description").asText().isBlank()); + JsonNode content = response.at("/content/application~1json"); + assertTrue(content.get("schema").isObject()); + assertTrue(content.has("example") || content.at("/examples").size() > 0); + } + JsonNode schemas = json.at("/components/schemas"); + JsonNode treeSchema = findSchema(schemas, "NavigationTreeResponse"); + JsonNode nodeSchema = findSchema(schemas, "NavigationNodeDto"); + JsonNode segmentSchema = findSchema(schemas, "NavigationSegmentDto"); + JsonNode errorSchema = findSchema(schemas, "NavigationErrorResponse"); + assertNotNull(treeSchema); + assertNotNull(nodeSchema); + assertNotNull(segmentSchema); + assertNotNull(errorSchema); + assertTrue(treeSchema.at("/properties/segments").isObject()); + assertTrue(treeSchema.at("/properties/currentNode/nullable").asBoolean()); + assertTrue(treeSchema.at("/properties/partName/nullable").asBoolean()); + assertTrue(segmentSchema.at("/properties/partName/nullable").asBoolean()); + assertTrue(nodeSchema.at("/properties/isLeaf").isObject()); + assertTrue(nodeSchema.at("/properties/isActive").isObject()); + assertTrue(nodeSchema.at("/properties/isOpen").isObject()); + assertTrue(nodeSchema.at("/properties/children").isObject()); + assertTrue(errorSchema.at("/properties/code").isObject()); + assertTrue(errorSchema.at("/properties/message").isObject()); + } + + private Set parameterNames(JsonNode parameters) { + var names = new HashSet(); + parameters.forEach(parameter -> names.add(parameter.get("name").asText())); + return names; + } + + private Set iterableFieldNames(Iterator fields) { + var names = new HashSet(); + fields.forEachRemaining(names::add); + return names; + } + + private JsonNode findParameter(JsonNode operation, String name) { + for (JsonNode parameter : operation.get("parameters")) { + if (name.equals(parameter.get("name").asText())) { + return parameter; + } + } + fail("Missing OpenAPI parameter " + name); + return null; + } + + private JsonNode findSchema(JsonNode schemas, String simpleName) { + Iterator names = schemas.fieldNames(); + while (names.hasNext()) { + String name = names.next(); + if (name.endsWith(simpleName)) { + return schemas.get(name); + } + } + return null; + } + + @Configuration + @EnableWebMvc + @Import({ SpringDocConfiguration.class, SpringDocWebMvcConfiguration.class }) + static class OpenApiTestConfig { + + @Bean + SpringDocConfigProperties springDocConfigProperties() { + return new SpringDocConfigProperties(); + } + + @Bean + NavigationController navigationController() { + return new NavigationController(createNiceMock(NavigationRequestResolver.class), + createNiceMock(NavigationTreeBuilder.class)); + } + + } + +} diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java new file mode 100644 index 000000000..ff7190e13 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java @@ -0,0 +1,225 @@ +package com.celements.navigation.api; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpStatus; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.SpaceReference; +import org.xwiki.model.reference.WikiReference; + +import com.celements.model.context.ModelContext; +import com.celements.model.util.ModelUtils; +import com.celements.web.service.IWebUtilsService; + +public class NavigationRequestResolverTest { + + private final WikiReference wikiRef = new WikiReference("xwiki"); + private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef); + private ModelUtils modelUtils; + private ModelContext modelContext; + private IWebUtilsService webUtilsService; + private NavigationRequestResolver resolver; + + @Before + public void prepare() { + modelUtils = createMock(ModelUtils.class); + modelContext = createMock(ModelContext.class); + webUtilsService = createMock(IWebUtilsService.class); + resolver = new NavigationRequestResolver(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_acceptsCanonicalLocalReferencesAndRequestedLanguage() { + var currentRef = new DocumentReference("MyPage", spaceRef); + expectCanonicalSpace("Content"); + expectCanonicalDocument("Content.MyPage", currentRef); + expect(modelUtils.normalizeLang("DE")).andReturn("de"); + expect(webUtilsService.getAllowedLanguages(spaceRef)).andReturn(List.of("en", "de")); + replay(modelUtils, modelContext, webUtilsService); + var request = resolver.resolve("Content", "Content.MyPage", "DE", "main", 2); + verify(modelUtils, modelContext, webUtilsService); + assertEquals(spaceRef, request.nodeSpace()); + assertEquals(Optional.of(currentRef), request.currentNode()); + assertEquals("de", request.language()); + assertEquals(Optional.of("main"), request.partName()); + assertEquals(2, request.showInactiveToLevel()); + } + + @Test + public void resolve_defaultsLanguageFromCurrentRequestAndNormalizesBlankPart() { + expectCanonicalSpace("Content"); + expect(modelContext.getLanguage()).andReturn(Optional.of("fr")); + replay(modelUtils, modelContext, webUtilsService); + var request = resolver.resolve("Content", null, null, " ", 0); + verify(modelUtils, modelContext, webUtilsService); + assertEquals("fr", request.language()); + assertEquals(Optional.empty(), request.partName()); + assertEquals(Optional.empty(), request.serializedCurrentNode()); + } + + @Test + public void resolve_defaultsLanguageFromWikiWhenRequestLanguageIsAbsent() { + expectCanonicalSpace("Content"); + expect(modelContext.getLanguage()).andReturn(Optional.empty()); + expect(modelContext.getDefaultLanguage()).andReturn("en"); + replay(modelUtils, modelContext, webUtilsService); + assertEquals("en", resolver.resolve("Content", null, null, null, 0).language()); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsCurrentWikiQualifiedSpace() { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("xwiki:Content", SpaceReference.class, wikiRef)) + .andReturn(spaceRef); + expect(modelUtils.serializeRefLocal(spaceRef)).andReturn("Content"); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", + () -> resolver.resolve("xwiki:Content", null, null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsOtherWikiQualifiedCurrentNode() { + var otherRef = new DocumentReference("MyPage", + new SpaceReference("Content", new WikiReference("other"))); + expectCanonicalSpace("Content"); + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("other:Content.MyPage", DocumentReference.class, wikiRef)) + .andReturn(otherRef); + expect(modelUtils.serializeRefLocal(otherRef)).andReturn("Content.MyPage"); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", + () -> resolver.resolve("Content", "other:Content.MyPage", null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsCurrentWikiQualifiedCurrentNode() { + var currentRef = new DocumentReference("MyPage", spaceRef); + expectCanonicalSpace("Content"); + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("xwiki:Content.MyPage", DocumentReference.class, wikiRef)) + .andReturn(currentRef); + expect(modelUtils.serializeRefLocal(currentRef)).andReturn("Content.MyPage"); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", + () -> resolver.resolve("Content", "xwiki:Content.MyPage", null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsBlankAndNoncanonicalCurrentNode() { + expectCanonicalSpace("Content"); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", () -> resolver.resolve("Content", " ", null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + reset(modelUtils, modelContext, webUtilsService); + expectCanonicalSpace("Content"); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", + () -> resolver.resolve("Content", " Content.MyPage ", null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsNoncanonicalNodeSpace() { + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", () -> resolver.resolve(" Content ", null, null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsMalformedReference() { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("[", SpaceReference.class, wikiRef)) + .andThrow(new IllegalArgumentException("malformed")); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", () -> resolver.resolve("[", null, null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_doesNotMisclassifyUnexpectedNullPointerException() { + expect(modelContext.getWikiRef()).andThrow(new NullPointerException("backend failure")); + replay(modelUtils, modelContext, webUtilsService); + var exception = assertThrows(NullPointerException.class, + () -> resolver.resolve("Content", null, null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + assertEquals("backend failure", exception.getMessage()); + } + + @Test + public void resolve_doesNotMisclassifyModelUtilsNullPointerException() { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("Content", SpaceReference.class, wikiRef)).andReturn(spaceRef); + expect(modelUtils.serializeRefLocal(spaceRef)) + .andThrow(new NullPointerException("serialization failure")); + replay(modelUtils, modelContext, webUtilsService); + var exception = assertThrows(NullPointerException.class, + () -> resolver.resolve("Content", null, null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + assertEquals("serialization failure", exception.getMessage()); + } + + @Test + public void resolve_rejectsNullParseResult() { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("Content", SpaceReference.class, wikiRef)).andReturn(null); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_reference", () -> resolver.resolve("Content", null, null, null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsUnsupportedAndInvalidLanguage() { + expectCanonicalSpace("Content"); + expect(modelUtils.normalizeLang("it")).andReturn("it"); + expect(webUtilsService.getAllowedLanguages(spaceRef)).andReturn(List.of("en", "de")); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("unsupported_language", () -> resolver.resolve("Content", null, "it", null, 0)); + verify(modelUtils, modelContext, webUtilsService); + reset(modelUtils, modelContext, webUtilsService); + expectCanonicalSpace("Content"); + expect(modelUtils.normalizeLang("invalid")) + .andThrow(new IllegalArgumentException("invalid language")); + replay(modelUtils, modelContext, webUtilsService); + assertApiError("unsupported_language", + () -> resolver.resolve("Content", null, "invalid", null, 0)); + verify(modelUtils, modelContext, webUtilsService); + } + + @Test + public void resolve_rejectsInactiveLevelsOutsideRange() { + replay(modelUtils, modelContext, webUtilsService); + assertApiError("invalid_parameter", () -> resolver.resolve("Content", null, null, null, -1)); + assertApiError("invalid_parameter", () -> resolver.resolve("Content", null, null, null, 101)); + verify(modelUtils, modelContext, webUtilsService); + } + + private void expectCanonicalSpace(String serialized) { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef(serialized, SpaceReference.class, wikiRef)).andReturn(spaceRef); + expect(modelUtils.serializeRefLocal(spaceRef)).andReturn(serialized); + } + + private void expectCanonicalDocument(String serialized, DocumentReference documentRef) { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef(serialized, DocumentReference.class, wikiRef)) + .andReturn(documentRef); + expect(modelUtils.serializeRefLocal(documentRef)).andReturn(serialized); + } + + private void assertApiError(String code, Runnable invocation) { + var exception = assertThrows(NavigationApiException.class, invocation::run); + assertEquals(HttpStatus.BAD_REQUEST, exception.status()); + assertEquals(code, exception.code()); + } + +} diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java new file mode 100644 index 000000000..f1f0d34ac --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java @@ -0,0 +1,127 @@ +package com.celements.navigation.api; + +import static com.celements.rights.access.EAccessLevel.*; +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.easymock.IAnswer; +import org.junit.Test; +import org.xwiki.component.manager.ComponentManager; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.SpaceReference; +import org.xwiki.model.reference.WikiReference; + +import com.celements.navigation.TreeNode; +import com.celements.navigation.filter.InternalRightsFilter; +import com.celements.navigation.service.ITreeNodeService; +import com.celements.parents.IDocumentParentsListerRole; +import com.celements.rights.access.IRightsAccessFacadeRole; +import com.xpn.xwiki.XWikiContext; +import com.xpn.xwiki.user.api.XWikiUser; +import com.xpn.xwiki.web.Utils; + +public class NavigationRightsFilteringTest { + + private final WikiReference wikiRef = new WikiReference("xwiki"); + private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef); + private final List observedFilters = new ArrayList<>(); + + @Test + public void guestRightsAreAppliedAtRootAndNestedLevelsWithoutLeafDisclosure() throws Exception { + var response = buildForCaller("XWiki.XWikiGuest", false); + var root = response.segments().get(0).nodes().get(0); + assertTrue(root.isLeaf()); + assertTrue(root.children().isEmpty()); + assertEquals(2, observedFilters.size()); + assertEquals("main", observedFilters.get(0).getMenuPart()); + assertTrue(observedFilters.get(1).getMenuPart().isEmpty()); + } + + @Test + public void authenticatedRightsAreAppliedAtRootAndEveryReturnedNestedLevel() throws Exception { + var response = buildForCaller("XWiki.Authenticated", true); + var root = response.segments().get(0).nodes().get(0); + assertFalse(root.isLeaf()); + assertEquals(1, root.children().size()); + assertTrue(root.children().get(0).isLeaf()); + assertEquals(3, observedFilters.size()); + assertSame(observedFilters.get(1), observedFilters.get(2)); + assertEquals("main", observedFilters.get(0).getMenuPart()); + assertTrue(observedFilters.get(1).getMenuPart().isEmpty()); + } + + private NavigationTreeResponse buildForCaller(String userName, boolean childVisible) + throws Exception { + var rootNode = new TreeNode(new DocumentReference("Root", spaceRef), null, 1, "main"); + var childNode = new TreeNode(new DocumentReference("Restricted", spaceRef), + rootNode.getDocumentReference(), 1, "main"); + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + IRightsAccessFacadeRole rightsAccess = createMock(IRightsAccessFacadeRole.class); + ComponentManager componentManager = createMock(ComponentManager.class); + XWikiContext xwikiContext = createMock(XWikiContext.class); + var xwikiUser = new XWikiUser(userName); + expect(componentManager.lookup(IRightsAccessFacadeRole.class, "default")) + .andReturn(rightsAccess).anyTimes(); + expect(xwikiContext.getXWikiUser()).andReturn(xwikiUser).anyTimes(); + expect( + rightsAccess.hasAccessLevel(eq(rootNode.getDocumentReference()), eq(VIEW), same(xwikiUser))) + .andReturn(true); + expect(rightsAccess.hasAccessLevel(eq(childNode.getDocumentReference()), eq(VIEW), + same(xwikiUser))).andReturn(childVisible); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andAnswer(rightsFiltered(List.of(rootNode), xwikiContext)); + expectValues(values, rootNode); + expect(treeService.getSubNodesForParent(eq(rootNode.getDocumentReference()), + isA(InternalRightsFilter.class))) + .andAnswer(rightsFiltered(List.of(childNode), xwikiContext)); + if (childVisible) { + expectValues(values, childNode); + expect(treeService.getSubNodesForParent(eq(childNode.getDocumentReference()), + isA(InternalRightsFilter.class))).andAnswer(rightsFiltered(List.of(), xwikiContext)); + } + replay(treeService, parentsLister, values, rightsAccess, componentManager, xwikiContext); + ComponentManager previousComponentManager = previousComponentManager(); + Utils.setComponentManager(componentManager); + try { + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(new NavigationRequest(spaceRef, "Content", Optional.empty(), Optional.empty(), + "de", Optional.of("main"), 100)); + verify(treeService, parentsLister, values, rightsAccess, componentManager, xwikiContext); + return response; + } finally { + Utils.setComponentManager(previousComponentManager); + } + } + + private ComponentManager previousComponentManager() { + try { + return Utils.getComponentManager(); + } catch (IllegalStateException exception) { + return null; + } + } + + private IAnswer> rightsFiltered(List nodes, XWikiContext xwikiContext) { + return () -> { + var filter = (InternalRightsFilter) getCurrentArguments()[1]; + observedFilters.add(filter); + return nodes.stream().filter(node -> filter.includeTreeNode(node, xwikiContext)).toList(); + }; + } + + private void expectValues(NavigationNodeValueResolver values, TreeNode node) { + String localRef = "Content." + node.getDocumentReference().getName(); + expect(values.serialize(node.getDocumentReference())).andReturn(localRef); + expect(values.resolveTitle(node.getDocumentReference(), "de")) + .andReturn(node.getDocumentReference().getName()); + expect(values.resolveUrl(node.getDocumentReference(), "de")) + .andReturn("/Content/" + node.getDocumentReference().getName() + "?language=de"); + } + +} diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java new file mode 100644 index 000000000..7bc573fa5 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java @@ -0,0 +1,452 @@ +package com.celements.navigation.api; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.Test; +import org.springframework.http.HttpStatus; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.SpaceReference; +import org.xwiki.model.reference.WikiReference; + +import com.celements.navigation.TreeNode; +import com.celements.navigation.filter.InternalRightsFilter; +import com.celements.navigation.service.ITreeNodeService; +import com.celements.parents.IDocumentParentsListerRole; + +public class NavigationTreeBuilderTest { + + private final WikiReference wikiRef = new WikiReference("xwiki"); + private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef); + + @Test + public void build_groupsAndOrdersSegmentsWithoutReorderingNodes() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var unnamed = node("Unnamed", null, 1); + var mainSecond = node("MainSecond", "main", 2); + var upperCase = node("Upper", "A", 1); + var lowerCase = node("Lower", "a", 1); + var mainFirst = node("MainFirst", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(unnamed, mainSecond, upperCase, lowerCase, mainFirst)); + for (var node : List.of(unnamed, mainSecond, upperCase, lowerCase, mainFirst)) { + expectNode(treeService, values, node, List.of()); + } + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.empty(), Optional.empty(), 0)); + verify(treeService, parentsLister, values); + assertEquals(4, response.segments().size()); + assertNull(response.segments().get(0).partName()); + assertEquals("A", response.segments().get(1).partName()); + assertEquals("a", response.segments().get(2).partName()); + assertEquals("main", response.segments().get(3).partName()); + assertEquals(List.of("Content.MainSecond", "Content.MainFirst"), + response.segments().get(3).nodes().stream().map(NavigationNodeDto::docRef).toList()); + assertTrue(response.segments().get(0).nodes().get(0).isLeaf()); + assertFalse(response.segments().get(0).nodes().get(0).isOpen()); + } + + @Test + public void build_expandsActiveAncestorsNodeAndDirectChildren() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var root = node("Root", "main", 1); + var active = childNode("Active", root, 1); + var child = childNode("Child", active, 1); + var grandchild = childNode("Grandchild", child, 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(root)); + expectNode(treeService, values, root, List.of(active)); + expectNode(treeService, values, active, List.of(child)); + expectNode(treeService, values, child, List.of(grandchild)); + expectNode(treeService, values, grandchild, List.of()); + expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) + .andReturn(List.of(active.getDocumentReference(), root.getDocumentReference())); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0)); + verify(treeService, parentsLister, values); + var rootDto = response.segments().get(0).nodes().get(0); + var activeDto = rootDto.children().get(0); + var childDto = activeDto.children().get(0); + assertTrue(rootDto.isOpen()); + assertFalse(rootDto.isActive()); + assertTrue(activeDto.isOpen()); + assertTrue(activeDto.isActive()); + assertEquals(1, activeDto.children().size()); + assertFalse(childDto.isOpen()); + assertTrue(childDto.children().isEmpty()); + assertFalse(childDto.isLeaf()); + } + + @Test + public void build_combinesActivePathAndInactiveThresholdExpansion() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var activeRoot = node("ActiveRoot", "main", 1); + var active = childNode("Active", activeRoot, 1); + var activeChild = childNode("ActiveChild", active, 1); + var activeGrandchild = childNode("ActiveGrandchild", activeChild, 1); + var inactiveRoot = node("InactiveRoot", "main", 2); + var inactiveChild = childNode("InactiveChild", inactiveRoot, 1); + var inactiveGrandchild = childNode("InactiveGrandchild", inactiveChild, 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(activeRoot, inactiveRoot)); + expectNode(treeService, values, activeRoot, List.of(active)); + expectNode(treeService, values, active, List.of(activeChild)); + expectNode(treeService, values, activeChild, List.of(activeGrandchild)); + expectNode(treeService, values, activeGrandchild, List.of()); + expectNode(treeService, values, inactiveRoot, List.of(inactiveChild)); + expectNode(treeService, values, inactiveChild, List.of(inactiveGrandchild)); + expectNode(treeService, values, inactiveGrandchild, List.of()); + expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) + .andReturn(List.of(active.getDocumentReference(), activeRoot.getDocumentReference())); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 2)); + verify(treeService, parentsLister, values); + var activeRootDto = response.segments().get(0).nodes().get(0); + var activeDto = activeRootDto.children().get(0); + var activeChildDto = activeDto.children().get(0); + var inactiveRootDto = response.segments().get(0).nodes().get(1); + var inactiveChildDto = inactiveRootDto.children().get(0); + assertTrue(activeRootDto.isOpen()); + assertTrue(activeDto.isOpen()); + assertTrue(activeDto.isActive()); + assertFalse(activeChildDto.isOpen()); + assertTrue(activeChildDto.children().isEmpty()); + assertTrue(inactiveRootDto.isOpen()); + assertFalse(inactiveChildDto.isOpen()); + assertTrue(inactiveChildDto.children().isEmpty()); + } + + @Test + public void build_appliesInactiveExpansionThresholds() { + assertEquals(1, expandedDepth(0)); + assertEquals(1, expandedDepth(1)); + assertEquals(2, expandedDepth(2)); + assertEquals(3, expandedDepth(3)); + assertEquals(4, expandedDepth(100)); + } + + @Test + public void build_unfilteredEmptyNodeSpaceReturnsEmptySegments() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of()); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.empty(), Optional.empty(), 0)); + verify(treeService, parentsLister, values); + assertTrue(response.segments().isEmpty()); + } + + @Test + public void build_omittedInvalidChildDoesNotDiscloseNonLeafState() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var root = node("Root", "main", 1); + var invalid = childNode("HiddenByInvalidTitle", root, 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(root)); + expectValues(values, root); + expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()), + isA(InternalRightsFilter.class))).andReturn(List.of(invalid)); + expect(values.serialize(invalid.getDocumentReference())).andReturn(local(invalid)); + expect(values.resolveTitle(invalid.getDocumentReference(), "de")).andReturn(" "); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.empty(), Optional.empty(), 100)); + verify(treeService, parentsLister, values); + var rootDto = response.segments().get(0).nodes().get(0); + assertTrue(rootDto.isLeaf()); + assertFalse(rootDto.isOpen()); + assertTrue(rootDto.children().isEmpty()); + } + + @Test + public void build_omitsNullAndBlankMandatoryNodeValues() { + for (String field : List.of("docRef", "title", "url")) { + assertInvalidNodeOmitted(field, null); + assertInvalidNodeOmitted(field, " "); + } + } + + @Test + public void build_omittedActiveNodeReturnsNotFound() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var active = node("InvalidActive", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(active)); + expect(values.serialize(active.getDocumentReference())).andReturn(local(active)); + expect(values.resolveTitle(active.getDocumentReference(), "de")).andReturn(null); + expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) + .andReturn(List.of(active.getDocumentReference())); + replay(treeService, parentsLister, values); + var exception = assertThrows(NavigationApiException.class, + () -> new NavigationTreeBuilder(treeService, parentsLister, values).build( + request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0))); + verify(treeService, parentsLister, values); + assertEquals("navigation_node_not_found", exception.code()); + } + + @Test + public void build_omitsInactiveNodeWithNullChildren() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var root = node("InvalidRoot", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(root)); + expectValues(values, root); + expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()), + isA(InternalRightsFilter.class))).andReturn(null); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.empty(), Optional.empty(), 100)); + verify(treeService, parentsLister, values); + assertTrue(response.segments().isEmpty()); + } + + @Test + public void build_activeNodeWithNullChildrenReturnsNotFound() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var active = node("InvalidActive", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(active)); + expectValues(values, active); + expect(treeService.getSubNodesForParent(eq(active.getDocumentReference()), + isA(InternalRightsFilter.class))).andReturn(null); + expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) + .andReturn(List.of(active.getDocumentReference())); + replay(treeService, parentsLister, values); + var exception = assertThrows(NavigationApiException.class, + () -> new NavigationTreeBuilder(treeService, parentsLister, values).build( + request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0))); + verify(treeService, parentsLister, values); + assertEquals(HttpStatus.NOT_FOUND, exception.status()); + assertEquals("navigation_node_not_found", exception.code()); + } + + @Test + public void build_invalidActivePathReturnsSafeNotFound() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var current = node("Missing", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of()); + expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true)) + .andReturn(List.of(current.getDocumentReference())); + replay(treeService, parentsLister, values); + var exception = assertThrows(NavigationApiException.class, + () -> new NavigationTreeBuilder(treeService, parentsLister, values).build( + request(Optional.of(current.getDocumentReference()), Optional.of(local(current)), 0))); + verify(treeService, parentsLister, values); + assertEquals(HttpStatus.NOT_FOUND, exception.status()); + assertEquals("navigation_node_not_found", exception.code()); + } + + @Test + public void build_inaccessibleCurrentNodeReturnsSafeNotFound() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var current = node("Restricted", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of()); + expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true)) + .andReturn(List.of(current.getDocumentReference())); + replay(treeService, parentsLister, values); + assertNodeNotFound(treeService, parentsLister, values, + request(Optional.of(current.getDocumentReference()), Optional.of(local(current)), 0)); + } + + @Test + public void build_outOfRootCurrentNodeReturnsSafeNotFound() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var root = node("Root", "main", 1); + var otherSpace = new SpaceReference("Other", wikiRef); + var current = new DocumentReference("Current", otherSpace); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(root)); + expectNode(treeService, values, root, List.of()); + expect(parentsLister.getDocumentParentsList(current, true)).andReturn(List.of(current)); + replay(treeService, parentsLister, values); + assertNodeNotFound(treeService, parentsLister, values, + request(Optional.of(current), Optional.of("Other.Current"), 0)); + } + + @Test + public void build_partExcludedCurrentNodeReturnsSafeNotFound() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var current = node("OtherPart", "other", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andAnswer(() -> { + var filter = (InternalRightsFilter) getCurrentArguments()[1]; + return "main".equals(filter.getMenuPart()) ? List.of() : List.of(current); + }); + expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true)) + .andReturn(List.of(current.getDocumentReference())); + replay(treeService, parentsLister, values); + var request = new NavigationRequest(spaceRef, "Content", + Optional.of(current.getDocumentReference()), Optional.of(local(current)), "de", + Optional.of("main"), 0); + assertNodeNotFound(treeService, parentsLister, values, request); + } + + @Test + public void build_requestedPartAlwaysReturnsExactlyOneSegment() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of()); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(new NavigationRequest(spaceRef, "Content", Optional.empty(), Optional.empty(), "de", + Optional.of("Main"), 0)); + verify(treeService, parentsLister, values); + assertEquals(1, response.segments().size()); + assertEquals("Main", response.segments().get(0).partName()); + assertTrue(response.segments().get(0).nodes().isEmpty()); + } + + @Test + public void build_partFilterIsCaseSensitive() { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var lowerCasePart = node("Root", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(lowerCasePart)); + expectNode(treeService, values, lowerCasePart, List.of()); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(new NavigationRequest(spaceRef, "Content", Optional.empty(), Optional.empty(), "de", + Optional.of("Main"), 0)); + verify(treeService, parentsLister, values); + assertEquals("Main", response.segments().get(0).partName()); + assertTrue(response.segments().get(0).nodes().isEmpty()); + } + + private void assertInvalidNodeOmitted(String field, String invalidValue) { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var root = node("Root", "main", 1); + var invalid = childNode("Invalid", root, 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(root)); + expectValues(values, root); + expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()), + isA(InternalRightsFilter.class))).andReturn(List.of(invalid)); + expect(values.serialize(invalid.getDocumentReference())) + .andReturn("docRef".equals(field) ? invalidValue : local(invalid)); + if (!"docRef".equals(field)) { + expect(values.resolveTitle(invalid.getDocumentReference(), "de")) + .andReturn("title".equals(field) ? invalidValue : "Invalid"); + } + if ("url".equals(field)) { + expect(values.resolveUrl(invalid.getDocumentReference(), "de")).andReturn(invalidValue); + } + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.empty(), Optional.empty(), 100)); + verify(treeService, parentsLister, values); + var rootDto = response.segments().get(0).nodes().get(0); + assertTrue(rootDto.isLeaf()); + assertTrue(rootDto.children().isEmpty()); + } + + private void assertNodeNotFound(ITreeNodeService treeService, + IDocumentParentsListerRole parentsLister, NavigationNodeValueResolver values, + NavigationRequest request) { + var exception = assertThrows(NavigationApiException.class, + () -> new NavigationTreeBuilder(treeService, parentsLister, values).build(request)); + verify(treeService, parentsLister, values); + assertEquals(HttpStatus.NOT_FOUND, exception.status()); + assertEquals("navigation_node_not_found", exception.code()); + } + + private int expandedDepth(int threshold) { + ITreeNodeService treeService = createMock(ITreeNodeService.class); + IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); + NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + var level1 = node("Level1", "main", 1); + var level2 = childNode("Level2", level1, 1); + var level3 = childNode("Level3", level2, 1); + var level4 = childNode("Level4", level3, 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(level1)); + expectNode(treeService, values, level1, List.of(level2)); + expectNode(treeService, values, level2, List.of(level3)); + expectNode(treeService, values, level3, List.of(level4)); + expectNode(treeService, values, level4, List.of()); + replay(treeService, parentsLister, values); + var response = new NavigationTreeBuilder(treeService, parentsLister, values) + .build(request(Optional.empty(), Optional.empty(), threshold)); + verify(treeService, parentsLister, values); + int depth = 1; + var current = response.segments().get(0).nodes().get(0); + while (!current.children().isEmpty()) { + current = current.children().get(0); + depth++; + } + return depth; + } + + private void expectNode(ITreeNodeService treeService, NavigationNodeValueResolver values, + TreeNode node, List children) { + expectValues(values, node); + expect(treeService.getSubNodesForParent(eq(node.getDocumentReference()), + isA(InternalRightsFilter.class))).andReturn(children); + } + + private void expectValues(NavigationNodeValueResolver values, TreeNode node) { + expect(values.serialize(node.getDocumentReference())).andReturn(local(node)); + expect(values.resolveTitle(node.getDocumentReference(), "de")) + .andReturn(node.getDocumentReference().getName()); + expect(values.resolveUrl(node.getDocumentReference(), "de")) + .andReturn("/Content/" + node.getDocumentReference().getName() + "?language=de"); + } + + private NavigationRequest request(Optional currentNode, + Optional serializedCurrentNode, int threshold) { + return new NavigationRequest(spaceRef, "Content", currentNode, serializedCurrentNode, "de", + Optional.empty(), threshold); + } + + private TreeNode node(String name, String partName, int position) { + return new TreeNode(new DocumentReference(name, spaceRef), null, position, partName); + } + + private TreeNode childNode(String name, TreeNode parent, int position) { + return new TreeNode(new DocumentReference(name, spaceRef), parent.getDocumentReference(), + position, parent.getPartName()); + } + + private String local(TreeNode node) { + return "Content." + node.getDocumentReference().getName(); + } + +} From bb7c413a2a9df160d4649c6ca3e71f73683c0378 Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Sat, 1 Aug 2026 00:45:24 +0200 Subject: [PATCH 2/3] ponytail reduce implementation --- .../DefaultNavigationNodeValueResolver.java | 5 +- .../api/NavigationNodeValueResolver.java | 13 ---- .../navigation/api/NavigationTreeBuilder.java | 4 +- .../api/NavigationControllerTest.java | 62 +------------------ .../api/NavigationJsonContractTest.java | 27 -------- .../api/NavigationRightsFilteringTest.java | 4 +- .../api/NavigationTreeBuilderTest.java | 55 ++++++---------- 7 files changed, 26 insertions(+), 144 deletions(-) delete mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java index 951354fae..7b9c0dfad 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java @@ -11,7 +11,7 @@ import com.celements.url.UrlService; @Component -final class DefaultNavigationNodeValueResolver implements NavigationNodeValueResolver { +class DefaultNavigationNodeValueResolver { private final ModelUtils modelUtils; private final ModelContext modelContext; @@ -32,18 +32,15 @@ final class DefaultNavigationNodeValueResolver implements NavigationNodeValueRes this.menuNameCommand = menuNameCommand; } - @Override public String serialize(DocumentReference docRef) { return modelUtils.serializeRefLocal(docRef); } - @Override public String resolveTitle(DocumentReference docRef, String language) { return menuNameCommand.getMultilingualMenuName(modelUtils.serializeRefLocal(docRef), language, modelContext.getXWikiContext()); } - @Override public String resolveUrl(DocumentReference docRef, String language) { return urlService.getURL(docRef, "view", "language=" + language); } diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java deleted file mode 100644 index 1278aafe6..000000000 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeValueResolver.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.celements.navigation.api; - -import org.xwiki.model.reference.DocumentReference; - -interface NavigationNodeValueResolver { - - String serialize(DocumentReference docRef); - - String resolveTitle(DocumentReference docRef, String language); - - String resolveUrl(DocumentReference docRef, String language); - -} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java index f0d54851d..fb9e39eb5 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java @@ -35,11 +35,11 @@ class NavigationTreeBuilder { private final ITreeNodeService treeNodeService; private final IDocumentParentsListerRole parentsLister; - private final NavigationNodeValueResolver valueResolver; + private final DefaultNavigationNodeValueResolver valueResolver; @Inject NavigationTreeBuilder(ITreeNodeService treeNodeService, IDocumentParentsListerRole parentsLister, - NavigationNodeValueResolver valueResolver) { + DefaultNavigationNodeValueResolver valueResolver) { this.treeNodeService = treeNodeService; this.parentsLister = parentsLister; this.valueResolver = valueResolver; diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java index 5d3a3048b..8d7885770 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java @@ -5,22 +5,14 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import java.lang.reflect.Method; import java.util.List; import java.util.Optional; import org.junit.Before; import org.junit.Test; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.http.MediaType; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.xwiki.model.reference.SpaceReference; @@ -46,34 +38,8 @@ public void prepare() { } @Test - public void getNavigation_isPublicThroughMethodSecurityForGuestAndAuthenticatedCaller() - throws Exception { - var request = request(); - var response = response(); - expect(requestResolver.resolve("Content", null, "de", null, 0)).andReturn(request).times(2); - expect(treeBuilder.build(request)).andReturn(response).times(2); - replay(requestResolver, treeBuilder); - var context = new AnnotationConfigApplicationContext(); - context.register(MethodSecurityTestConfig.class); - context.registerBean(NavigationController.class, - () -> new NavigationController(requestResolver, treeBuilder)); - context.refresh(); - var securedController = context.getBean(NavigationController.class); - try { - SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken( - "guest-key", "guest", List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); - assertEquals(response, - securedController.getNavigation("Content", null, "de", null, 0).getBody()); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( - "authenticated-user", "n/a", List.of(new SimpleGrantedAuthority("ROLE_USER")))); - assertEquals(response, - securedController.getNavigation("Content", null, "de", null, 0).getBody()); - } finally { - SecurityContextHolder.clearContext(); - context.close(); - } - verify(requestResolver, treeBuilder); - Method method = NavigationController.class.getMethod("getNavigation", String.class, + public void getNavigation_isPublicThroughMethodSecurity() throws Exception { + var method = NavigationController.class.getMethod("getNavigation", String.class, String.class, String.class, String.class, int.class); assertEquals("permitAll()", method.getAnnotation(PreAuthorize.class).value()); } @@ -159,22 +125,6 @@ public void getNavigation_mapsUnexpectedFailureWithoutLeakingDetails() throws Ex "Navigation is currently unavailable."); } - @Test - public void getNavigation_mapsRequestInfrastructureNullPointerExceptionToGenericFailure() - throws Exception { - expect(requestResolver.resolve("Content", null, null, null, 0)) - .andThrow(new NullPointerException("secret reference backend detail")); - replay(requestResolver, treeBuilder); - var result = mockMvc.perform(get("/v1/navigation/Content")) - .andExpect(status().isInternalServerError()) - .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); - verify(requestResolver, treeBuilder); - assertFalse( - result.getResponse().getContentAsString().contains("secret reference backend detail")); - assertError(result.getResponse().getContentAsByteArray(), "navigation_unavailable", - "Navigation is currently unavailable."); - } - private NavigationRequest request() { return new NavigationRequest(new SpaceReference("Content", new WikiReference("xwiki")), "Content", Optional.empty(), Optional.empty(), "de", Optional.empty(), 0); @@ -197,10 +147,4 @@ private void assertError(byte[] content, String code, String message) throws Exc assertEquals(message, json.get("message").asText()); } - @Configuration - @EnableGlobalMethodSecurity(prePostEnabled = true) - static class MethodSecurityTestConfig { - - } - } diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java index 1646c88f6..d12fb6928 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java @@ -7,35 +7,8 @@ import org.junit.Test; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - public class NavigationJsonContractTest { - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Test - public void serializesExplicitNullsAndExactBooleanPropertyNames() throws Exception { - var node = new NavigationNodeDto("Content.Home", "/Content/Home?language=de", "Startseite", - true, false, true, List.of()); - var response = new NavigationTreeResponse("Content", null, "de", null, 2, - List.of(new NavigationSegmentDto(null, List.of(node)))); - JsonNode json = objectMapper.readTree(objectMapper.writeValueAsBytes(response)); - assertTrue(json.has("currentNode")); - assertTrue(json.get("currentNode").isNull()); - assertTrue(json.has("partName")); - assertTrue(json.get("partName").isNull()); - assertTrue(json.at("/segments/0/partName").isNull()); - JsonNode nodeJson = json.at("/segments/0/nodes/0"); - assertTrue(nodeJson.has("isLeaf")); - assertTrue(nodeJson.has("isActive")); - assertTrue(nodeJson.has("isOpen")); - assertFalse(nodeJson.has("leaf")); - assertFalse(nodeJson.has("active")); - assertFalse(nodeJson.has("open")); - assertTrue(nodeJson.get("children").isArray()); - } - @Test public void defensivelyCopiesAllDtoLists() { var mutableChildren = new ArrayList(); diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java index f1f0d34ac..bf3883442 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java @@ -61,7 +61,7 @@ private NavigationTreeResponse buildForCaller(String userName, boolean childVisi rootNode.getDocumentReference(), 1, "main"); ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); IRightsAccessFacadeRole rightsAccess = createMock(IRightsAccessFacadeRole.class); ComponentManager componentManager = createMock(ComponentManager.class); XWikiContext xwikiContext = createMock(XWikiContext.class); @@ -115,7 +115,7 @@ private IAnswer> rightsFiltered(List nodes, XWikiContex }; } - private void expectValues(NavigationNodeValueResolver values, TreeNode node) { + private void expectValues(DefaultNavigationNodeValueResolver values, TreeNode node) { String localRef = "Content." + node.getDocumentReference().getName(); expect(values.serialize(node.getDocumentReference())).andReturn(localRef); expect(values.resolveTitle(node.getDocumentReference(), "de")) diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java index 7bc573fa5..cd506a3ef 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java @@ -26,7 +26,7 @@ public class NavigationTreeBuilderTest { public void build_groupsAndOrdersSegmentsWithoutReorderingNodes() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var unnamed = node("Unnamed", null, 1); var mainSecond = node("MainSecond", "main", 2); var upperCase = node("Upper", "A", 1); @@ -56,7 +56,7 @@ public void build_groupsAndOrdersSegmentsWithoutReorderingNodes() { public void build_expandsActiveAncestorsNodeAndDirectChildren() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var root = node("Root", "main", 1); var active = childNode("Active", root, 1); var child = childNode("Child", active, 1); @@ -90,7 +90,7 @@ public void build_expandsActiveAncestorsNodeAndDirectChildren() { public void build_combinesActivePathAndInactiveThresholdExpansion() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var activeRoot = node("ActiveRoot", "main", 1); var active = childNode("Active", activeRoot, 1); var activeChild = childNode("ActiveChild", active, 1); @@ -141,7 +141,7 @@ public void build_appliesInactiveExpansionThresholds() { public void build_unfilteredEmptyNodeSpaceReturnsEmptySegments() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of()); replay(treeService, parentsLister, values); @@ -155,7 +155,7 @@ public void build_unfilteredEmptyNodeSpaceReturnsEmptySegments() { public void build_omittedInvalidChildDoesNotDiscloseNonLeafState() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var root = node("Root", "main", 1); var invalid = childNode("HiddenByInvalidTitle", root, 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) @@ -187,7 +187,7 @@ public void build_omitsNullAndBlankMandatoryNodeValues() { public void build_omittedActiveNodeReturnsNotFound() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var active = node("InvalidActive", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(active)); @@ -207,7 +207,7 @@ public void build_omittedActiveNodeReturnsNotFound() { public void build_omitsInactiveNodeWithNullChildren() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var root = node("InvalidRoot", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(root)); @@ -225,7 +225,7 @@ public void build_omitsInactiveNodeWithNullChildren() { public void build_activeNodeWithNullChildrenReturnsNotFound() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var active = node("InvalidActive", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(active)); @@ -243,30 +243,11 @@ public void build_activeNodeWithNullChildrenReturnsNotFound() { assertEquals("navigation_node_not_found", exception.code()); } - @Test - public void build_invalidActivePathReturnsSafeNotFound() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); - var current = node("Missing", "main", 1); - expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) - .andReturn(List.of()); - expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true)) - .andReturn(List.of(current.getDocumentReference())); - replay(treeService, parentsLister, values); - var exception = assertThrows(NavigationApiException.class, - () -> new NavigationTreeBuilder(treeService, parentsLister, values).build( - request(Optional.of(current.getDocumentReference()), Optional.of(local(current)), 0))); - verify(treeService, parentsLister, values); - assertEquals(HttpStatus.NOT_FOUND, exception.status()); - assertEquals("navigation_node_not_found", exception.code()); - } - @Test public void build_inaccessibleCurrentNodeReturnsSafeNotFound() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var current = node("Restricted", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of()); @@ -281,7 +262,7 @@ public void build_inaccessibleCurrentNodeReturnsSafeNotFound() { public void build_outOfRootCurrentNodeReturnsSafeNotFound() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var root = node("Root", "main", 1); var otherSpace = new SpaceReference("Other", wikiRef); var current = new DocumentReference("Current", otherSpace); @@ -298,7 +279,7 @@ public void build_outOfRootCurrentNodeReturnsSafeNotFound() { public void build_partExcludedCurrentNodeReturnsSafeNotFound() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var current = node("OtherPart", "other", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andAnswer(() -> { @@ -318,7 +299,7 @@ public void build_partExcludedCurrentNodeReturnsSafeNotFound() { public void build_requestedPartAlwaysReturnsExactlyOneSegment() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of()); replay(treeService, parentsLister, values); @@ -335,7 +316,7 @@ public void build_requestedPartAlwaysReturnsExactlyOneSegment() { public void build_partFilterIsCaseSensitive() { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var lowerCasePart = node("Root", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(lowerCasePart)); @@ -352,7 +333,7 @@ public void build_partFilterIsCaseSensitive() { private void assertInvalidNodeOmitted(String field, String invalidValue) { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var root = node("Root", "main", 1); var invalid = childNode("Invalid", root, 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) @@ -379,7 +360,7 @@ private void assertInvalidNodeOmitted(String field, String invalidValue) { } private void assertNodeNotFound(ITreeNodeService treeService, - IDocumentParentsListerRole parentsLister, NavigationNodeValueResolver values, + IDocumentParentsListerRole parentsLister, DefaultNavigationNodeValueResolver values, NavigationRequest request) { var exception = assertThrows(NavigationApiException.class, () -> new NavigationTreeBuilder(treeService, parentsLister, values).build(request)); @@ -391,7 +372,7 @@ private void assertNodeNotFound(ITreeNodeService treeService, private int expandedDepth(int threshold) { ITreeNodeService treeService = createMock(ITreeNodeService.class); IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - NavigationNodeValueResolver values = createMock(NavigationNodeValueResolver.class); + DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var level1 = node("Level1", "main", 1); var level2 = childNode("Level2", level1, 1); var level3 = childNode("Level3", level2, 1); @@ -415,14 +396,14 @@ private int expandedDepth(int threshold) { return depth; } - private void expectNode(ITreeNodeService treeService, NavigationNodeValueResolver values, + private void expectNode(ITreeNodeService treeService, DefaultNavigationNodeValueResolver values, TreeNode node, List children) { expectValues(values, node); expect(treeService.getSubNodesForParent(eq(node.getDocumentReference()), isA(InternalRightsFilter.class))).andReturn(children); } - private void expectValues(NavigationNodeValueResolver values, TreeNode node) { + private void expectValues(DefaultNavigationNodeValueResolver values, TreeNode node) { expect(values.serialize(node.getDocumentReference())).andReturn(local(node)); expect(values.resolveTitle(node.getDocumentReference(), "de")) .andReturn(node.getDocumentReference().getName()); From 7fd681970a8d15b3b15edf5447cb0145237fb7af Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Tue, 4 Aug 2026 07:33:24 +0200 Subject: [PATCH 3/3] CELDEV-1334 Address review feedback --- celements-navigation-rest/pom.xml | 24 +- .../navigation/api/NavigationController.java | 94 ++++--- .../api/NavigationErrorResponse.java | 9 +- .../api/NavigationExceptionHandler.java | 44 --- .../navigation/api/NavigationNodeDto.java | 21 +- .../navigation/api/NavigationSegmentDto.java | 7 +- .../navigation/api/NavigationTreeBuilder.java | 55 ++-- .../api/NavigationTreeResponse.java | 17 +- ...efaultNavigationNodeValueResolverTest.java | 58 ++-- .../api/NavigationControllerTest.java | 99 ++++--- .../api/NavigationOpenApiContractTest.java | 45 +-- .../api/NavigationRequestResolverTest.java | 110 ++++---- .../api/NavigationRightsFilteringTest.java | 93 +++--- .../api/NavigationTreeBuilderTest.java | 264 ++++++++---------- 14 files changed, 464 insertions(+), 476 deletions(-) delete mode 100644 celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java diff --git a/celements-navigation-rest/pom.xml b/celements-navigation-rest/pom.xml index b8cf2898e..0c24d994f 100644 --- a/celements-navigation-rest/pom.xml +++ b/celements-navigation-rest/pom.xml @@ -33,6 +33,11 @@ celements-xwiki-core provided + + com.celements + celements-servlet + provided + org.xwiki.platform xwiki-core-model @@ -119,6 +124,21 @@ celements-observation test + + com.celements + celements-shared-tests + test + + + com.celements + celements-subsystem-migration-manager + test + + + com.celements + celements-xwiki-velocity + test + org.springframework spring-test @@ -170,10 +190,12 @@ - com.celements:celements-spring-security + com.celements:celements-servlet com.celements:celements-commons com.celements:celements-reference com.celements:celements-observation + com.celements:celements-subsystem-migration-manager + com.celements:celements-xwiki-velocity javax.xml.bind:jaxb-api diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java index 85b3aad70..5af643f1f 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java @@ -2,20 +2,25 @@ import javax.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.convert.ConversionFailedException; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; 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.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -24,6 +29,7 @@ @RequestMapping("/v1/navigation") public class NavigationController { + private static final Logger LOGGER = LoggerFactory.getLogger(NavigationController.class); static final String CACHE_CONTROL = "private, no-store"; private final NavigationRequestResolver requestResolver; @@ -38,59 +44,28 @@ public class NavigationController { @GetMapping(path = "/{nodeSpace}", produces = MediaType.APPLICATION_JSON_VALUE) @PreAuthorize("permitAll()") - @Operation(summary = "Get the current wiki's navigation tree", description = """ - Public endpoint returning a caller-relative, rights-filtered navigation tree. - Input and output references are canonical and local to the wiki handling the request. - Responses are private and not cacheable by shared or browser caches. - """) + @Operation(summary = "Get the current wiki's navigation tree") @ApiResponses({ - @ApiResponse(responseCode = "200", - description = "The rights-filtered segmented navigation tree", - content = @Content(schema = @Schema(implementation = NavigationTreeResponse.class), - examples = @ExampleObject(value = """ - { - "nodeSpace": "Content", - "currentNode": "Content.MyPage", - "language": "de", - "partName": null, - "showInactiveToLevel": 2, - "segments": [] - } - """))), + @ApiResponse(responseCode = "200", description = "Navigation tree"), @ApiResponse(responseCode = "400", description = "Invalid reference or parameter, or unsupported language", - content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class), - examples = @ExampleObject(value = """ - {"code":"invalid_reference","message":"The reference is invalid."} - """))), + content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class))), @ApiResponse(responseCode = "404", description = "The active node is absent, inaccessible, out of root, or part-excluded", - content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class), - examples = @ExampleObject(value = """ - { - "code": "navigation_node_not_found", - "message": "The navigation node was not found." - } - """))), + content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class))), @ApiResponse(responseCode = "500", description = "Navigation infrastructure is unavailable", - content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class), - examples = @ExampleObject(value = """ - { - "code": "navigation_unavailable", - "message": "Navigation is currently unavailable." - } - """))) }) + content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class))) }) public ResponseEntity getNavigation( - @Parameter(description = "Canonical local space reference identifying the navigation root", - example = "Content", required = true) @PathVariable String nodeSpace, - @Parameter(description = "Canonical local document reference identifying the active node", - example = "Content.MyPage") @RequestParam(required = false) String currentNode, - @Parameter(description = "Allowed wiki language; defaults to the current request language", - example = "de") @RequestParam(required = false) String language, + @Parameter(description = "Canonical local space reference") @PathVariable String nodeSpace, + @Parameter(description = "Canonical local document reference") @RequestParam( + required = false) String currentNode, @Parameter( - description = "Case-sensitive root part filter; missing or blank returns all parts", - example = "main") @RequestParam(required = false) String partName, - @Parameter(description = "Inactive expansion threshold from 0 through 100", example = "2", + description = "Allowed wiki language; defaults to the request language") @RequestParam( + required = false) String language, + @Parameter( + description = "Case-sensitive root part filter; missing or blank returns all parts") + @RequestParam(required = false) String partName, + @Parameter(description = "Inactive expansion threshold from 0 through 100", schema = @Schema(defaultValue = "0", minimum = "0", maximum = "100")) @RequestParam( name = "show_inactive_to_level", defaultValue = "0") int showInactiveToLevel) { var request = requestResolver.resolve(nodeSpace, currentNode, language, partName, @@ -99,4 +74,31 @@ public ResponseEntity getNavigation( return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL).body(response); } + @ExceptionHandler(NavigationApiException.class) + public ResponseEntity handleNavigationApiException( + NavigationApiException exception) { + return errorResponse(exception.status(), + new NavigationErrorResponse(exception.code(), exception.getMessage())); + } + + @ExceptionHandler({ MethodArgumentTypeMismatchException.class, ConversionFailedException.class }) + public ResponseEntity handleInvalidParameter() { + return errorResponse(HttpStatus.BAD_REQUEST, + new NavigationErrorResponse("invalid_parameter", "The parameter is invalid.")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpectedException(Exception exception) { + LOGGER.error("Navigation REST request failed.", exception); + return errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, new NavigationErrorResponse( + "navigation_unavailable", "Navigation is currently unavailable.")); + } + + private ResponseEntity errorResponse(HttpStatus status, + NavigationErrorResponse body) { + return ResponseEntity.status(status) + .header(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL) + .body(body); + } + } diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java index fbef7d2fd..782595eaf 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java @@ -1,12 +1,5 @@ package com.celements.navigation.api; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "Stable, safe navigation API error.") -public record NavigationErrorResponse( - @Schema(description = "Stable machine-readable error code", - example = "invalid_reference") String code, - @Schema(description = "Safe error message", - example = "The reference is invalid.") String message) { +public record NavigationErrorResponse(String code, String message) { } diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java deleted file mode 100644 index 97e3aa2b0..000000000 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationExceptionHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.celements.navigation.api; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.core.convert.ConversionFailedException; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -@RestControllerAdvice(assignableTypes = NavigationController.class) -public final class NavigationExceptionHandler { - - private static final Logger LOGGER = LoggerFactory.getLogger(NavigationExceptionHandler.class); - - @ExceptionHandler(NavigationApiException.class) - public ResponseEntity handleNavigationApiException( - NavigationApiException exception) { - return response(exception.status(), - new NavigationErrorResponse(exception.code(), exception.getMessage())); - } - - @ExceptionHandler({ MethodArgumentTypeMismatchException.class, ConversionFailedException.class }) - public ResponseEntity handleInvalidParameter() { - return response(HttpStatus.BAD_REQUEST, - new NavigationErrorResponse("invalid_parameter", "The parameter is invalid.")); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleUnexpectedException(Exception exception) { - LOGGER.error("Navigation REST request failed.", exception); - return response(HttpStatus.INTERNAL_SERVER_ERROR, new NavigationErrorResponse( - "navigation_unavailable", "Navigation is currently unavailable.")); - } - - private ResponseEntity response(HttpStatus status, - NavigationErrorResponse body) { - return ResponseEntity.status(status) - .header(HttpHeaders.CACHE_CONTROL, NavigationController.CACHE_CONTROL).body(body); - } - -} diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java index 7e2afc07c..28bfa15f4 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java @@ -6,21 +6,14 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "One rights-visible navigation node.") public record NavigationNodeDto( - @Schema(description = "Canonical local document reference", - example = "Content.Home") String docRef, - @Schema(description = "Relative view URL containing the resolved language", - example = "/Content/Home?language=de") String url, - @Schema(description = "Localized navigation title", example = "Startseite") String title, - @JsonProperty("isLeaf") @Schema( - description = "Whether the node has no valid, rights-visible children") boolean isLeaf, - @JsonProperty("isActive") @Schema( - description = "Whether this node is the requested current node") boolean isActive, - @JsonProperty("isOpen") @Schema( - description = "Whether this node's children are expanded") boolean isOpen, - @Schema( - description = "Expanded child nodes in effective navigation position order") List children) { + String docRef, + String url, + String title, + @JsonProperty("isLeaf") boolean isLeaf, + @JsonProperty("isActive") boolean isActive, + @JsonProperty("isOpen") boolean isOpen, + @Schema(description = "Expanded rights-visible child nodes") List children) { public NavigationNodeDto { children = List.copyOf(children); diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java index d90e19d2b..c0854be67 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java @@ -6,12 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "A case-sensitive root navigation part and its nodes.") public record NavigationSegmentDto( - @Nullable @Schema(description = "Root part name; null represents the unnamed part", - example = "main", nullable = true) String partName, - @Schema( - description = "Root nodes in effective navigation position order") List nodes) { + @Nullable @Schema(nullable = true) String partName, + List nodes) { public NavigationSegmentDto { nodes = List.copyOf(nodes); diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java index fb9e39eb5..bc2f3803c 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java @@ -31,7 +31,8 @@ class NavigationTreeBuilder { private static final Logger LOGGER = LoggerFactory.getLogger(NavigationTreeBuilder.class); private static final Comparator PART_NAME_COMPARATOR = comparing( - (String value) -> value.toLowerCase(Locale.ROOT)).thenComparing(naturalOrder()); + (String value) -> value.toLowerCase(Locale.ROOT)) + .thenComparing(naturalOrder()); private final ITreeNodeService treeNodeService; private final IDocumentParentsListerRole parentsLister; @@ -53,8 +54,8 @@ NavigationTreeResponse build(NavigationRequest request) { List roots = readNodes( treeNodeService.getSubNodesForParent(request.nodeSpace(), rootFilter), descendantFilter, request.language(), new HashSet<>()); - if (request.currentNode().isPresent() - && roots.stream().noneMatch(root -> root.contains(request.currentNode().orElseThrow()))) { + if (request.currentNode().isPresent() && roots.stream() + .noneMatch(root -> root.contains(request.currentNode().orElseThrow()))) { throw nodeNotFound(); } return new NavigationTreeResponse(request.serializedNodeSpace(), @@ -64,12 +65,14 @@ NavigationTreeResponse build(NavigationRequest request) { } private Set resolveActivePath(NavigationRequest request) { - return request.currentNode().map(currentNode -> { - var path = new HashSet<>( - Objects.requireNonNull(parentsLister.getDocumentParentsList(currentNode, true))); - path.add(currentNode); - return Set.copyOf(path); - }).orElseGet(Set::of); + return request.currentNode() + .map(currentNode -> { + var path = new HashSet<>( + Objects.requireNonNull(parentsLister.getDocumentParentsList(currentNode, true))); + path.add(currentNode); + return Set.copyOf(path); + }) + .orElseGet(Set::of); } private List buildSegments(List roots, @@ -77,15 +80,23 @@ private List buildSegments(List roots, if (request.partName().isPresent()) { String partName = request.partName().orElseThrow(); return List.of(new NavigationSegmentDto(partName, - roots.stream().filter(root -> partName.equals(root.partName())) - .map(root -> toDto(root, 1, activePath, request)).toList())); + roots.stream() + .filter(root -> partName.equals(root.partName())) + .map(root -> toDto(root, 1, activePath, request)) + .toList())); } Map> groupedRoots = new LinkedHashMap<>(); - roots.forEach(root -> groupedRoots - .computeIfAbsent(root.partName(), ignored -> new ArrayList<>()).add(root)); - return groupedRoots.entrySet().stream().sorted(Map.Entry.comparingByKey(PART_NAME_COMPARATOR)) + roots.forEach(root -> groupedRoots.computeIfAbsent(root.partName(), + ignored -> new ArrayList<>()) + .add(root)); + return groupedRoots.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey(PART_NAME_COMPARATOR)) .map(entry -> new NavigationSegmentDto(emptyToNull(entry.getKey()), - entry.getValue().stream().map(root -> toDto(root, 1, activePath, request)).toList())) + entry.getValue() + .stream() + .map(root -> toDto(root, 1, activePath, request)) + .toList())) .toList(); } @@ -96,7 +107,9 @@ private NavigationNodeDto toDto(InternalNode node, int level, Set children = isOpen - ? node.children().stream().map(child -> toDto(child, level + 1, activePath, request)) + ? node.children() + .stream() + .map(child -> toDto(child, level + 1, activePath, request)) .toList() : List.of(); return new NavigationNodeDto(node.serializedDocRef(), node.url(), node.title(), @@ -105,8 +118,10 @@ private NavigationNodeDto toDto(InternalNode node, int level, Set readNodes(List sourceNodes, InternalRightsFilter filter, String language, Set ancestors) { - return Objects.requireNonNull(sourceNodes).stream() - .map(node -> readNode(node, filter, language, ancestors)).flatMap(Optional::stream) + return Objects.requireNonNull(sourceNodes) + .stream() + .map(node -> readNode(node, filter, language, ancestors)) + .flatMap(Optional::stream) .toList(); } @@ -169,8 +184,8 @@ private record InternalNode(DocumentReference docRef, String serializedDocRef, S } boolean contains(DocumentReference reference) { - return docRef.equals(reference) - || children.stream().anyMatch(child -> child.contains(reference)); + return docRef.equals(reference) || children.stream() + .anyMatch(child -> child.contains(reference)); } } diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java index a42cdf358..7217afe65 100644 --- a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java @@ -6,18 +6,13 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "Caller-relative, localized, segmented navigation tree.") public record NavigationTreeResponse( - @Schema(description = "Canonical local navigation root", example = "Content") String nodeSpace, - @Nullable @Schema(description = "Canonical local active document reference", - example = "Content.MyPage", nullable = true) String currentNode, - @Schema(description = "Resolved language", example = "de") String language, - @Nullable @Schema(description = "Requested case-sensitive root part filter", example = "main", - nullable = true) String partName, - @Schema(description = "Inactive expansion threshold", minimum = "0", maximum = "100", - example = "2") int showInactiveToLevel, - @Schema( - description = "Navigation parts; the unnamed part is first") List segments) { + String nodeSpace, + @Nullable @Schema(nullable = true) String currentNode, + String language, + @Nullable @Schema(nullable = true) String partName, + int showInactiveToLevel, + List segments) { public NavigationTreeResponse { segments = List.copyOf(segments); diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java index c57dd5750..9f8107d6e 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java @@ -3,40 +3,66 @@ import static org.easymock.EasyMock.*; import static org.junit.Assert.*; +import java.util.Optional; + +import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.xwiki.model.reference.DocumentReference; import org.xwiki.model.reference.SpaceReference; import org.xwiki.model.reference.WikiReference; +import com.celements.common.test.AbstractComponentTest; +import com.celements.model.access.IModelAccessFacade; import com.celements.model.context.ModelContext; import com.celements.model.util.ModelUtils; -import com.celements.navigation.cmd.MultilingualMenuNameCommand; import com.celements.url.UrlService; -import com.xpn.xwiki.XWikiContext; +import com.xpn.xwiki.doc.XWikiDocument; + +public class DefaultNavigationNodeValueResolverTest extends AbstractComponentTest { -public class DefaultNavigationNodeValueResolverTest { + private ModelUtils modelUtils; + private ModelContext modelContext; + private UrlService urlService; + private IModelAccessFacade modelAccess; + private DefaultNavigationNodeValueResolver resolver; + + @Before + public void prepareTest() throws Exception { + registerComponentMocks(ModelUtils.class, ModelContext.class, UrlService.class, + IModelAccessFacade.class); + modelUtils = getMock(ModelUtils.class); + modelContext = getMock(ModelContext.class); + urlService = getMock(UrlService.class); + modelAccess = getMock(IModelAccessFacade.class); + var beanFactory = (DefaultListableBeanFactory) getBeanFactory(); + beanFactory.destroySingleton(DefaultNavigationNodeValueResolver.class.getName()); + beanFactory.registerResolvableDependency(ModelUtils.class, modelUtils); + beanFactory.registerResolvableDependency(ModelContext.class, modelContext); + beanFactory.registerResolvableDependency(UrlService.class, urlService); + resolver = getBeanFactory().getBean(DefaultNavigationNodeValueResolver.class.getName(), + DefaultNavigationNodeValueResolver.class); + } @Test - public void resolvesCanonicalTitleAndLanguageStableViewUrl() { - ModelUtils modelUtils = createMock(ModelUtils.class); - ModelContext modelContext = createMock(ModelContext.class); - UrlService urlService = createMock(UrlService.class); - MultilingualMenuNameCommand menuNameCommand = createMock(MultilingualMenuNameCommand.class); - XWikiContext xwikiContext = createMock(XWikiContext.class); + public void test_resolvesCanonicalTitleAndLanguageStableViewUrl() { var docRef = new DocumentReference("Home", new SpaceReference("Content", new WikiReference("xwiki"))); + XWikiDocument document = createDefaultMock(XWikiDocument.class); expect(modelUtils.serializeRefLocal(docRef)).andReturn("Content.Home").times(2); - expect(modelContext.getXWikiContext()).andReturn(xwikiContext); - expect(menuNameCommand.getMultilingualMenuName("Content.Home", "de", xwikiContext)) - .andReturn("Startseite"); + expect(modelContext.getXWikiContext()).andReturn(getXContext()); + expect(modelUtils.resolveRef("Content.Home", DocumentReference.class)).andReturn(docRef); + expect(modelAccess.getOrCreateDocument(docRef)).andReturn(document); + expect(getXContext().getWiki().isMultiLingual(getXContext())).andReturn(false); + expect(document.getObject("Celements2.MenuName")).andReturn(null); + expect(modelAccess.getDocumentOpt(docRef, "de")).andReturn(Optional.of(document)); + expect(document.getTitle()).andReturn("Startseite"); expect(urlService.getURL(docRef, "view", "language=de")).andReturn("/Content/Home?language=de"); - replay(modelUtils, modelContext, urlService, menuNameCommand, xwikiContext); - var resolver = new DefaultNavigationNodeValueResolver(modelUtils, modelContext, urlService, - menuNameCommand); + replayDefault(); assertEquals("Content.Home", resolver.serialize(docRef)); assertEquals("Startseite", resolver.resolveTitle(docRef, "de")); assertEquals("/Content/Home?language=de", resolver.resolveUrl(docRef, "de")); - verify(modelUtils, modelContext, urlService, menuNameCommand, xwikiContext); + verifyDefault(); } } diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java index 8d7885770..05eb301e8 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java @@ -10,52 +10,74 @@ import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.AopTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.xwiki.model.reference.SpaceReference; import org.xwiki.model.reference.WikiReference; +import com.celements.common.test.AbstractComponentTest; +import com.celements.spring.security.oauth2.IdentityService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -public class NavigationControllerTest { +public class NavigationControllerTest extends AbstractComponentTest { private final ObjectMapper objectMapper = new ObjectMapper(); private NavigationRequestResolver requestResolver; private NavigationTreeBuilder treeBuilder; private MockMvc mockMvc; + @Override + protected void beforeSpringContextRefresh(ConfigurableApplicationContext context) { + super.beforeSpringContextRefresh(context); + context.addBeanFactoryPostProcessor(beanFactory -> beanFactory.registerSingleton( + "testIdentityService", createNiceMock(IdentityService.class))); + } + @Before - public void prepare() { - requestResolver = createMock(NavigationRequestResolver.class); - treeBuilder = createMock(NavigationTreeBuilder.class); - mockMvc = MockMvcBuilders - .standaloneSetup(new NavigationController(requestResolver, treeBuilder)) - .setControllerAdvice(new NavigationExceptionHandler()).build(); + public void prepareTest() throws Exception { + registerComponentMocks(NavigationRequestResolver.class, NavigationTreeBuilder.class); + requestResolver = getMock(NavigationRequestResolver.class); + treeBuilder = getMock(NavigationTreeBuilder.class); + var beanFactory = (DefaultListableBeanFactory) getBeanFactory(); + beanFactory.destroySingleton(NavigationController.class.getName()); + beanFactory.registerResolvableDependency(NavigationRequestResolver.class, requestResolver); + beanFactory.registerResolvableDependency(NavigationTreeBuilder.class, treeBuilder); + var controllerProxy = getBeanFactory().getBean(NavigationController.class.getName(), + NavigationController.class); + var controller = AopTestUtils.getTargetObject(controllerProxy); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } @Test - public void getNavigation_isPublicThroughMethodSecurity() throws Exception { + public void test_getNavigation_isPublicThroughMethodSecurity() throws Exception { var method = NavigationController.class.getMethod("getNavigation", String.class, String.class, String.class, String.class, int.class); assertEquals("permitAll()", method.getAnnotation(PreAuthorize.class).value()); } @Test - public void getNavigation_returnsExactJsonAndPrivateNoStoreHeader() throws Exception { + public void test_getNavigation_returnsExactJsonAndPrivateNoStoreHeader() throws Exception { var request = request(); expect(requestResolver.resolve("Content", null, "de", null, 0)).andReturn(request); expect(treeBuilder.build(request)).andReturn(response()); - replay(requestResolver, treeBuilder); + + replayDefault(); var result = mockMvc .perform(get("/v1/navigation/Content").param("language", "de") .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "private, no-store")) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)).andReturn(); - verify(requestResolver, treeBuilder); + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "private, no-store")) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn(); + verifyDefault(); + JsonNode json = readJson(result.getResponse().getContentAsByteArray()); assertEquals("Content", json.get("nodeSpace").asText()); assertTrue(json.get("currentNode").isNull()); @@ -68,58 +90,71 @@ public void getNavigation_returnsExactJsonAndPrivateNoStoreHeader() throws Excep } @Test - public void getNavigation_rejectsNonnumericInactiveLevel() throws Exception { - replay(requestResolver, treeBuilder); + public void test_getNavigation_rejectsNonnumericInactiveLevel() throws Exception { + replayDefault(); var result = mockMvc .perform(get("/v1/navigation/Content").param("show_inactive_to_level", "not-a-number")) .andExpect(status().isBadRequest()) - .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); - verify(requestResolver, treeBuilder); + .andExpect(header().string("Cache-Control", "private, no-store")) + .andReturn(); + verifyDefault(); + assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter", "The parameter is invalid."); } @Test - public void getNavigation_returnsStableSafeApiError() throws Exception { + public void test_getNavigation_returnsStableSafeApiError() throws Exception { expect(requestResolver.resolve("Content", null, null, null, 101)) .andThrow(new NavigationApiException(HttpStatus.BAD_REQUEST, "invalid_parameter", "The parameter is invalid.")); - replay(requestResolver, treeBuilder); + + replayDefault(); var result = mockMvc .perform(get("/v1/navigation/Content").param("show_inactive_to_level", "101")) .andExpect(status().isBadRequest()) - .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); - verify(requestResolver, treeBuilder); + .andExpect(header().string("Cache-Control", "private, no-store")) + .andReturn(); + verifyDefault(); + assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter", "The parameter is invalid."); } @Test - public void getNavigation_returnsPrivateNoStoreHeaderForNotFound() throws Exception { + public void test_getNavigation_returnsPrivateNoStoreHeaderForNotFound() throws Exception { var request = request(); expect(requestResolver.resolve("Content", "Content.Missing", null, null, 0)).andReturn(request); - expect(treeBuilder.build(request)).andThrow(new NavigationApiException(HttpStatus.NOT_FOUND, - "navigation_node_not_found", "The navigation node was not found.")); - replay(requestResolver, treeBuilder); + expect(treeBuilder.build(request)) + .andThrow(new NavigationApiException(HttpStatus.NOT_FOUND, "navigation_node_not_found", + "The navigation node was not found.")); + + replayDefault(); var result = mockMvc .perform(get("/v1/navigation/Content").param("currentNode", "Content.Missing")) .andExpect(status().isNotFound()) - .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); - verify(requestResolver, treeBuilder); + .andExpect(header().string("Cache-Control", "private, no-store")) + .andReturn(); + verifyDefault(); + assertError(result.getResponse().getContentAsByteArray(), "navigation_node_not_found", "The navigation node was not found."); } @Test - public void getNavigation_mapsUnexpectedFailureWithoutLeakingDetails() throws Exception { + public void test_getNavigation_mapsUnexpectedFailureWithoutLeakingDetails() throws Exception { var request = request(); expect(requestResolver.resolve("Content", null, null, null, 0)).andReturn(request); - expect(treeBuilder.build(request)).andThrow(new IllegalStateException("secret backend detail")); - replay(requestResolver, treeBuilder); + expect(treeBuilder.build(request)) + .andThrow(new IllegalStateException("secret backend detail")); + + replayDefault(); var result = mockMvc.perform(get("/v1/navigation/Content")) .andExpect(status().isInternalServerError()) - .andExpect(header().string("Cache-Control", "private, no-store")).andReturn(); - verify(requestResolver, treeBuilder); + .andExpect(header().string("Cache-Control", "private, no-store")) + .andReturn(); + verifyDefault(); + assertFalse(result.getResponse().getContentAsString().contains("secret backend detail")); assertError(result.getResponse().getContentAsByteArray(), "navigation_unavailable", "Navigation is currently unavailable."); diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java index 062165186..9209fff7a 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java @@ -15,14 +15,11 @@ import org.springdoc.core.SpringDocConfigProperties; import org.springdoc.core.SpringDocConfiguration; import org.springdoc.webmvc.core.SpringDocWebMvcConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; import org.springframework.mock.web.MockServletContext; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -37,7 +34,15 @@ public class NavigationOpenApiContractTest { public void prepare() { context = new AnnotationConfigWebApplicationContext(); context.setServletContext(new MockServletContext()); - context.register(OpenApiTestConfig.class); + context.register(DelegatingWebMvcConfiguration.class, SpringDocConfiguration.class, + SpringDocWebMvcConfiguration.class); + context.addBeanFactoryPostProcessor(beanFactory -> { + beanFactory.registerSingleton(SpringDocConfigProperties.class.getName(), + new SpringDocConfigProperties()); + beanFactory.registerSingleton(NavigationController.class.getName(), + new NavigationController(createNiceMock(NavigationRequestResolver.class), + createNiceMock(NavigationTreeBuilder.class))); + }); context.refresh(); mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); } @@ -48,19 +53,17 @@ public void cleanup() { } @Test - public void apiDocsExposeNavigationOperationParametersResponsesAndSchemas() throws Exception { + public void apiDocsExposeNavigationContract() throws Exception { var result = mockMvc.perform(get("/api/v3/api-docs").servletPath("/api")) - .andExpect(status().isOk()).andReturn(); + .andExpect(status().isOk()) + .andReturn(); JsonNode json = objectMapper.readTree(result.getResponse().getContentAsByteArray()); JsonNode operation = json.at("/paths/~1v1~1navigation~1{nodeSpace}/get"); assertFalse(operation.isMissingNode()); - assertTrue(operation.get("description").asText().contains("rights-filtered")); - assertTrue(operation.get("description").asText().contains("private")); + assertEquals("Get the current wiki's navigation tree", operation.get("summary").asText()); assertEquals( Set.of("nodeSpace", "currentNode", "language", "partName", "show_inactive_to_level"), parameterNames(operation.get("parameters"))); - operation.get("parameters") - .forEach(parameter -> assertFalse(parameter.get("description").asText().isBlank())); assertTrue(findParameter(operation, "nodeSpace").get("required").asBoolean()); JsonNode inactiveLevelSchema = findParameter(operation, "show_inactive_to_level").get("schema"); assertEquals("0", inactiveLevelSchema.get("default").asText()); @@ -70,10 +73,8 @@ public void apiDocsExposeNavigationOperationParametersResponsesAndSchemas() thro iterableFieldNames(operation.get("responses").fieldNames())); for (String status : Set.of("200", "400", "404", "500")) { JsonNode response = operation.get("responses").get(status); - assertFalse(response.get("description").asText().isBlank()); JsonNode content = response.at("/content/application~1json"); assertTrue(content.get("schema").isObject()); - assertTrue(content.has("example") || content.at("/examples").size() > 0); } JsonNode schemas = json.at("/components/schemas"); JsonNode treeSchema = findSchema(schemas, "NavigationTreeResponse"); @@ -129,22 +130,4 @@ private JsonNode findSchema(JsonNode schemas, String simpleName) { return null; } - @Configuration - @EnableWebMvc - @Import({ SpringDocConfiguration.class, SpringDocWebMvcConfiguration.class }) - static class OpenApiTestConfig { - - @Bean - SpringDocConfigProperties springDocConfigProperties() { - return new SpringDocConfigProperties(); - } - - @Bean - NavigationController navigationController() { - return new NavigationController(createNiceMock(NavigationRequestResolver.class), - createNiceMock(NavigationTreeBuilder.class)); - } - - } - } diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java index ff7190e13..36df4217e 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java @@ -13,11 +13,12 @@ import org.xwiki.model.reference.SpaceReference; import org.xwiki.model.reference.WikiReference; +import com.celements.common.test.AbstractComponentTest; import com.celements.model.context.ModelContext; import com.celements.model.util.ModelUtils; import com.celements.web.service.IWebUtilsService; -public class NavigationRequestResolverTest { +public class NavigationRequestResolverTest extends AbstractComponentTest { private final WikiReference wikiRef = new WikiReference("xwiki"); private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef); @@ -27,23 +28,24 @@ public class NavigationRequestResolverTest { private NavigationRequestResolver resolver; @Before - public void prepare() { - modelUtils = createMock(ModelUtils.class); - modelContext = createMock(ModelContext.class); - webUtilsService = createMock(IWebUtilsService.class); - resolver = new NavigationRequestResolver(modelUtils, modelContext, webUtilsService); + public void prepareTest() throws Exception { + registerComponentMocks(ModelUtils.class, ModelContext.class, IWebUtilsService.class); + modelUtils = getMock(ModelUtils.class); + modelContext = getMock(ModelContext.class); + webUtilsService = getMock(IWebUtilsService.class); + resolver = getBeanFactory().getBean(NavigationRequestResolver.class); } @Test - public void resolve_acceptsCanonicalLocalReferencesAndRequestedLanguage() { + public void test_resolve_acceptsCanonicalLocalReferencesAndRequestedLanguage() { var currentRef = new DocumentReference("MyPage", spaceRef); expectCanonicalSpace("Content"); expectCanonicalDocument("Content.MyPage", currentRef); expect(modelUtils.normalizeLang("DE")).andReturn("de"); expect(webUtilsService.getAllowedLanguages(spaceRef)).andReturn(List.of("en", "de")); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); var request = resolver.resolve("Content", "Content.MyPage", "DE", "main", 2); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); assertEquals(spaceRef, request.nodeSpace()); assertEquals(Optional.of(currentRef), request.currentNode()); assertEquals("de", request.language()); @@ -52,41 +54,41 @@ public void resolve_acceptsCanonicalLocalReferencesAndRequestedLanguage() { } @Test - public void resolve_defaultsLanguageFromCurrentRequestAndNormalizesBlankPart() { + public void test_resolve_defaultsLanguageFromCurrentRequestAndNormalizesBlankPart() { expectCanonicalSpace("Content"); expect(modelContext.getLanguage()).andReturn(Optional.of("fr")); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); var request = resolver.resolve("Content", null, null, " ", 0); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); assertEquals("fr", request.language()); assertEquals(Optional.empty(), request.partName()); assertEquals(Optional.empty(), request.serializedCurrentNode()); } @Test - public void resolve_defaultsLanguageFromWikiWhenRequestLanguageIsAbsent() { + public void test_resolve_defaultsLanguageFromWikiWhenRequestLanguageIsAbsent() { expectCanonicalSpace("Content"); expect(modelContext.getLanguage()).andReturn(Optional.empty()); expect(modelContext.getDefaultLanguage()).andReturn("en"); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertEquals("en", resolver.resolve("Content", null, null, null, 0).language()); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsCurrentWikiQualifiedSpace() { + public void test_resolve_rejectsCurrentWikiQualifiedSpace() { expect(modelContext.getWikiRef()).andReturn(wikiRef); expect(modelUtils.resolveRef("xwiki:Content", SpaceReference.class, wikiRef)) .andReturn(spaceRef); expect(modelUtils.serializeRefLocal(spaceRef)).andReturn("Content"); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve("xwiki:Content", null, null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsOtherWikiQualifiedCurrentNode() { + public void test_resolve_rejectsOtherWikiQualifiedCurrentNode() { var otherRef = new DocumentReference("MyPage", new SpaceReference("Content", new WikiReference("other"))); expectCanonicalSpace("Content"); @@ -94,113 +96,113 @@ public void resolve_rejectsOtherWikiQualifiedCurrentNode() { expect(modelUtils.resolveRef("other:Content.MyPage", DocumentReference.class, wikiRef)) .andReturn(otherRef); expect(modelUtils.serializeRefLocal(otherRef)).andReturn("Content.MyPage"); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve("Content", "other:Content.MyPage", null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsCurrentWikiQualifiedCurrentNode() { + public void test_resolve_rejectsCurrentWikiQualifiedCurrentNode() { var currentRef = new DocumentReference("MyPage", spaceRef); expectCanonicalSpace("Content"); expect(modelContext.getWikiRef()).andReturn(wikiRef); expect(modelUtils.resolveRef("xwiki:Content.MyPage", DocumentReference.class, wikiRef)) .andReturn(currentRef); expect(modelUtils.serializeRefLocal(currentRef)).andReturn("Content.MyPage"); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve("Content", "xwiki:Content.MyPage", null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsBlankAndNoncanonicalCurrentNode() { + public void test_resolve_rejectsBlankAndNoncanonicalCurrentNode() { expectCanonicalSpace("Content"); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve("Content", " ", null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); - reset(modelUtils, modelContext, webUtilsService); + verifyDefault(); + resetDefault(); expectCanonicalSpace("Content"); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve("Content", " Content.MyPage ", null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsNoncanonicalNodeSpace() { - replay(modelUtils, modelContext, webUtilsService); + public void test_resolve_rejectsNoncanonicalNodeSpace() { + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve(" Content ", null, null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsMalformedReference() { + public void test_resolve_rejectsMalformedReference() { expect(modelContext.getWikiRef()).andReturn(wikiRef); expect(modelUtils.resolveRef("[", SpaceReference.class, wikiRef)) .andThrow(new IllegalArgumentException("malformed")); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve("[", null, null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_doesNotMisclassifyUnexpectedNullPointerException() { + public void test_resolve_doesNotMisclassifyUnexpectedNullPointerException() { expect(modelContext.getWikiRef()).andThrow(new NullPointerException("backend failure")); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); var exception = assertThrows(NullPointerException.class, () -> resolver.resolve("Content", null, null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); assertEquals("backend failure", exception.getMessage()); } @Test - public void resolve_doesNotMisclassifyModelUtilsNullPointerException() { + public void test_resolve_doesNotMisclassifyModelUtilsNullPointerException() { expect(modelContext.getWikiRef()).andReturn(wikiRef); expect(modelUtils.resolveRef("Content", SpaceReference.class, wikiRef)).andReturn(spaceRef); expect(modelUtils.serializeRefLocal(spaceRef)) .andThrow(new NullPointerException("serialization failure")); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); var exception = assertThrows(NullPointerException.class, () -> resolver.resolve("Content", null, null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); assertEquals("serialization failure", exception.getMessage()); } @Test - public void resolve_rejectsNullParseResult() { + public void test_resolve_rejectsNullParseResult() { expect(modelContext.getWikiRef()).andReturn(wikiRef); expect(modelUtils.resolveRef("Content", SpaceReference.class, wikiRef)).andReturn(null); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("invalid_reference", () -> resolver.resolve("Content", null, null, null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsUnsupportedAndInvalidLanguage() { + public void test_resolve_rejectsUnsupportedAndInvalidLanguage() { expectCanonicalSpace("Content"); expect(modelUtils.normalizeLang("it")).andReturn("it"); expect(webUtilsService.getAllowedLanguages(spaceRef)).andReturn(List.of("en", "de")); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("unsupported_language", () -> resolver.resolve("Content", null, "it", null, 0)); - verify(modelUtils, modelContext, webUtilsService); - reset(modelUtils, modelContext, webUtilsService); + verifyDefault(); + resetDefault(); expectCanonicalSpace("Content"); expect(modelUtils.normalizeLang("invalid")) .andThrow(new IllegalArgumentException("invalid language")); - replay(modelUtils, modelContext, webUtilsService); + replayDefault(); assertApiError("unsupported_language", () -> resolver.resolve("Content", null, "invalid", null, 0)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } @Test - public void resolve_rejectsInactiveLevelsOutsideRange() { - replay(modelUtils, modelContext, webUtilsService); + public void test_resolve_rejectsInactiveLevelsOutsideRange() { + replayDefault(); assertApiError("invalid_parameter", () -> resolver.resolve("Content", null, null, null, -1)); assertApiError("invalid_parameter", () -> resolver.resolve("Content", null, null, null, 101)); - verify(modelUtils, modelContext, webUtilsService); + verifyDefault(); } private void expectCanonicalSpace(String serialized) { diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java index bf3883442..05353c872 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java @@ -9,29 +9,52 @@ import java.util.Optional; import org.easymock.IAnswer; +import org.junit.Before; import org.junit.Test; -import org.xwiki.component.manager.ComponentManager; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.xwiki.model.reference.DocumentReference; import org.xwiki.model.reference.SpaceReference; import org.xwiki.model.reference.WikiReference; +import com.celements.common.test.AbstractComponentTest; import com.celements.navigation.TreeNode; import com.celements.navigation.filter.InternalRightsFilter; import com.celements.navigation.service.ITreeNodeService; import com.celements.parents.IDocumentParentsListerRole; import com.celements.rights.access.IRightsAccessFacadeRole; -import com.xpn.xwiki.XWikiContext; import com.xpn.xwiki.user.api.XWikiUser; -import com.xpn.xwiki.web.Utils; -public class NavigationRightsFilteringTest { +public class NavigationRightsFilteringTest extends AbstractComponentTest { private final WikiReference wikiRef = new WikiReference("xwiki"); private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef); private final List observedFilters = new ArrayList<>(); + private ITreeNodeService treeService; + private IDocumentParentsListerRole parentsLister; + private DefaultNavigationNodeValueResolver values; + private IRightsAccessFacadeRole rightsAccess; + private NavigationTreeBuilder builder; + + @Before + public void prepareTest() throws Exception { + registerComponentMocks(ITreeNodeService.class, IDocumentParentsListerRole.class, + DefaultNavigationNodeValueResolver.class, IRightsAccessFacadeRole.class); + treeService = getMock(ITreeNodeService.class); + parentsLister = getMock(IDocumentParentsListerRole.class); + values = getMock(DefaultNavigationNodeValueResolver.class); + rightsAccess = getMock(IRightsAccessFacadeRole.class); + var beanFactory = (DefaultListableBeanFactory) getBeanFactory(); + beanFactory.destroySingleton(NavigationTreeBuilder.class.getName()); + beanFactory.registerResolvableDependency(ITreeNodeService.class, treeService); + beanFactory.registerResolvableDependency(IDocumentParentsListerRole.class, parentsLister); + beanFactory.registerResolvableDependency(DefaultNavigationNodeValueResolver.class, values); + builder = getBeanFactory().getBean(NavigationTreeBuilder.class.getName(), + NavigationTreeBuilder.class); + } @Test - public void guestRightsAreAppliedAtRootAndNestedLevelsWithoutLeafDisclosure() throws Exception { + public void test_guestRightsAreAppliedAtRootAndNestedLevelsWithoutLeafDisclosure() + throws Exception { var response = buildForCaller("XWiki.XWikiGuest", false); var root = response.segments().get(0).nodes().get(0); assertTrue(root.isLeaf()); @@ -42,7 +65,8 @@ public void guestRightsAreAppliedAtRootAndNestedLevelsWithoutLeafDisclosure() th } @Test - public void authenticatedRightsAreAppliedAtRootAndEveryReturnedNestedLevel() throws Exception { + public void test_authenticatedRightsAreAppliedAtRootAndEveryReturnedNestedLevel() + throws Exception { var response = buildForCaller("XWiki.Authenticated", true); var root = response.segments().get(0).nodes().get(0); assertFalse(root.isLeaf()); @@ -59,63 +83,40 @@ private NavigationTreeResponse buildForCaller(String userName, boolean childVisi var rootNode = new TreeNode(new DocumentReference("Root", spaceRef), null, 1, "main"); var childNode = new TreeNode(new DocumentReference("Restricted", spaceRef), rootNode.getDocumentReference(), 1, "main"); - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); - IRightsAccessFacadeRole rightsAccess = createMock(IRightsAccessFacadeRole.class); - ComponentManager componentManager = createMock(ComponentManager.class); - XWikiContext xwikiContext = createMock(XWikiContext.class); - var xwikiUser = new XWikiUser(userName); - expect(componentManager.lookup(IRightsAccessFacadeRole.class, "default")) - .andReturn(rightsAccess).anyTimes(); - expect(xwikiContext.getXWikiUser()).andReturn(xwikiUser).anyTimes(); + getXContext().setUser(userName); expect( - rightsAccess.hasAccessLevel(eq(rootNode.getDocumentReference()), eq(VIEW), same(xwikiUser))) + rightsAccess.hasAccessLevel(eq(rootNode.getDocumentReference()), eq(VIEW), + isA(XWikiUser.class))) .andReturn(true); expect(rightsAccess.hasAccessLevel(eq(childNode.getDocumentReference()), eq(VIEW), - same(xwikiUser))).andReturn(childVisible); + isA(XWikiUser.class))).andReturn(childVisible); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) - .andAnswer(rightsFiltered(List.of(rootNode), xwikiContext)); - expectValues(values, rootNode); + .andAnswer(rightsFiltered(List.of(rootNode))); + expectValues(rootNode); expect(treeService.getSubNodesForParent(eq(rootNode.getDocumentReference()), isA(InternalRightsFilter.class))) - .andAnswer(rightsFiltered(List.of(childNode), xwikiContext)); + .andAnswer(rightsFiltered(List.of(childNode))); if (childVisible) { - expectValues(values, childNode); + expectValues(childNode); expect(treeService.getSubNodesForParent(eq(childNode.getDocumentReference()), - isA(InternalRightsFilter.class))).andAnswer(rightsFiltered(List.of(), xwikiContext)); - } - replay(treeService, parentsLister, values, rightsAccess, componentManager, xwikiContext); - ComponentManager previousComponentManager = previousComponentManager(); - Utils.setComponentManager(componentManager); - try { - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(new NavigationRequest(spaceRef, "Content", Optional.empty(), Optional.empty(), - "de", Optional.of("main"), 100)); - verify(treeService, parentsLister, values, rightsAccess, componentManager, xwikiContext); - return response; - } finally { - Utils.setComponentManager(previousComponentManager); - } - } - - private ComponentManager previousComponentManager() { - try { - return Utils.getComponentManager(); - } catch (IllegalStateException exception) { - return null; + isA(InternalRightsFilter.class))).andAnswer(rightsFiltered(List.of())); } + replayDefault(); + var response = builder.build(new NavigationRequest(spaceRef, "Content", Optional.empty(), + Optional.empty(), "de", Optional.of("main"), 100)); + verifyDefault(); + return response; } - private IAnswer> rightsFiltered(List nodes, XWikiContext xwikiContext) { + private IAnswer> rightsFiltered(List nodes) { return () -> { var filter = (InternalRightsFilter) getCurrentArguments()[1]; observedFilters.add(filter); - return nodes.stream().filter(node -> filter.includeTreeNode(node, xwikiContext)).toList(); + return nodes.stream().filter(node -> filter.includeTreeNode(node, getXContext())).toList(); }; } - private void expectValues(DefaultNavigationNodeValueResolver values, TreeNode node) { + private void expectValues(TreeNode node) { String localRef = "Content." + node.getDocumentReference().getName(); expect(values.serialize(node.getDocumentReference())).andReturn(localRef); expect(values.resolveTitle(node.getDocumentReference(), "de")) diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java index cd506a3ef..575df3b98 100644 --- a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java @@ -6,27 +6,47 @@ import java.util.List; import java.util.Optional; +import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.http.HttpStatus; import org.xwiki.model.reference.DocumentReference; import org.xwiki.model.reference.SpaceReference; import org.xwiki.model.reference.WikiReference; +import com.celements.common.test.AbstractComponentTest; import com.celements.navigation.TreeNode; import com.celements.navigation.filter.InternalRightsFilter; import com.celements.navigation.service.ITreeNodeService; import com.celements.parents.IDocumentParentsListerRole; -public class NavigationTreeBuilderTest { +public class NavigationTreeBuilderTest extends AbstractComponentTest { private final WikiReference wikiRef = new WikiReference("xwiki"); private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef); + private ITreeNodeService treeService; + private IDocumentParentsListerRole parentsLister; + private DefaultNavigationNodeValueResolver values; + private NavigationTreeBuilder builder; + + @Before + public void prepareTest() throws Exception { + registerComponentMocks(ITreeNodeService.class, IDocumentParentsListerRole.class, + DefaultNavigationNodeValueResolver.class); + treeService = getMock(ITreeNodeService.class); + parentsLister = getMock(IDocumentParentsListerRole.class); + values = getMock(DefaultNavigationNodeValueResolver.class); + var beanFactory = (DefaultListableBeanFactory) getBeanFactory(); + beanFactory.destroySingleton(NavigationTreeBuilder.class.getName()); + beanFactory.registerResolvableDependency(ITreeNodeService.class, treeService); + beanFactory.registerResolvableDependency(IDocumentParentsListerRole.class, parentsLister); + beanFactory.registerResolvableDependency(DefaultNavigationNodeValueResolver.class, values); + builder = getBeanFactory().getBean(NavigationTreeBuilder.class.getName(), + NavigationTreeBuilder.class); + } @Test - public void build_groupsAndOrdersSegmentsWithoutReorderingNodes() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_groupsAndOrdersSegmentsWithoutReorderingNodes() { var unnamed = node("Unnamed", null, 1); var mainSecond = node("MainSecond", "main", 2); var upperCase = node("Upper", "A", 1); @@ -35,12 +55,11 @@ public void build_groupsAndOrdersSegmentsWithoutReorderingNodes() { expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(unnamed, mainSecond, upperCase, lowerCase, mainFirst)); for (var node : List.of(unnamed, mainSecond, upperCase, lowerCase, mainFirst)) { - expectNode(treeService, values, node, List.of()); + expectNode(node, List.of()); } - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.empty(), Optional.empty(), 0)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), 0)); + verifyDefault(); assertEquals(4, response.segments().size()); assertNull(response.segments().get(0).partName()); assertEquals("A", response.segments().get(1).partName()); @@ -53,26 +72,23 @@ public void build_groupsAndOrdersSegmentsWithoutReorderingNodes() { } @Test - public void build_expandsActiveAncestorsNodeAndDirectChildren() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_expandsActiveAncestorsNodeAndDirectChildren() { var root = node("Root", "main", 1); var active = childNode("Active", root, 1); var child = childNode("Child", active, 1); var grandchild = childNode("Grandchild", child, 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(root)); - expectNode(treeService, values, root, List.of(active)); - expectNode(treeService, values, active, List.of(child)); - expectNode(treeService, values, child, List.of(grandchild)); - expectNode(treeService, values, grandchild, List.of()); + expectNode(root, List.of(active)); + expectNode(active, List.of(child)); + expectNode(child, List.of(grandchild)); + expectNode(grandchild, List.of()); expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) .andReturn(List.of(active.getDocumentReference(), root.getDocumentReference())); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build( + request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0)); + verifyDefault(); var rootDto = response.segments().get(0).nodes().get(0); var activeDto = rootDto.children().get(0); var childDto = activeDto.children().get(0); @@ -87,10 +103,7 @@ public void build_expandsActiveAncestorsNodeAndDirectChildren() { } @Test - public void build_combinesActivePathAndInactiveThresholdExpansion() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_combinesActivePathAndInactiveThresholdExpansion() { var activeRoot = node("ActiveRoot", "main", 1); var active = childNode("Active", activeRoot, 1); var activeChild = childNode("ActiveChild", active, 1); @@ -100,19 +113,19 @@ public void build_combinesActivePathAndInactiveThresholdExpansion() { var inactiveGrandchild = childNode("InactiveGrandchild", inactiveChild, 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(activeRoot, inactiveRoot)); - expectNode(treeService, values, activeRoot, List.of(active)); - expectNode(treeService, values, active, List.of(activeChild)); - expectNode(treeService, values, activeChild, List.of(activeGrandchild)); - expectNode(treeService, values, activeGrandchild, List.of()); - expectNode(treeService, values, inactiveRoot, List.of(inactiveChild)); - expectNode(treeService, values, inactiveChild, List.of(inactiveGrandchild)); - expectNode(treeService, values, inactiveGrandchild, List.of()); + expectNode(activeRoot, List.of(active)); + expectNode(active, List.of(activeChild)); + expectNode(activeChild, List.of(activeGrandchild)); + expectNode(activeGrandchild, List.of()); + expectNode(inactiveRoot, List.of(inactiveChild)); + expectNode(inactiveChild, List.of(inactiveGrandchild)); + expectNode(inactiveGrandchild, List.of()); expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) .andReturn(List.of(active.getDocumentReference(), activeRoot.getDocumentReference())); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 2)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build( + request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 2)); + verifyDefault(); var activeRootDto = response.segments().get(0).nodes().get(0); var activeDto = activeRootDto.children().get(0); var activeChildDto = activeDto.children().get(0); @@ -129,7 +142,7 @@ public void build_combinesActivePathAndInactiveThresholdExpansion() { } @Test - public void build_appliesInactiveExpansionThresholds() { + public void test_build_appliesInactiveExpansionThresholds() { assertEquals(1, expandedDepth(0)); assertEquals(1, expandedDepth(1)); assertEquals(2, expandedDepth(2)); @@ -138,37 +151,29 @@ public void build_appliesInactiveExpansionThresholds() { } @Test - public void build_unfilteredEmptyNodeSpaceReturnsEmptySegments() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_unfilteredEmptyNodeSpaceReturnsEmptySegments() { expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of()); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.empty(), Optional.empty(), 0)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), 0)); + verifyDefault(); assertTrue(response.segments().isEmpty()); } @Test - public void build_omittedInvalidChildDoesNotDiscloseNonLeafState() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_omittedInvalidChildDoesNotDiscloseNonLeafState() { var root = node("Root", "main", 1); var invalid = childNode("HiddenByInvalidTitle", root, 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(root)); - expectValues(values, root); + expectValues(root); expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()), isA(InternalRightsFilter.class))).andReturn(List.of(invalid)); expect(values.serialize(invalid.getDocumentReference())).andReturn(local(invalid)); expect(values.resolveTitle(invalid.getDocumentReference(), "de")).andReturn(" "); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.empty(), Optional.empty(), 100)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), 100)); + verifyDefault(); var rootDto = response.segments().get(0).nodes().get(0); assertTrue(rootDto.isLeaf()); assertFalse(rootDto.isOpen()); @@ -176,7 +181,7 @@ public void build_omittedInvalidChildDoesNotDiscloseNonLeafState() { } @Test - public void build_omitsNullAndBlankMandatoryNodeValues() { + public void test_build_omitsNullAndBlankMandatoryNodeValues() { for (String field : List.of("docRef", "title", "url")) { assertInvalidNodeOmitted(field, null); assertInvalidNodeOmitted(field, " "); @@ -184,10 +189,7 @@ public void build_omitsNullAndBlankMandatoryNodeValues() { } @Test - public void build_omittedActiveNodeReturnsNotFound() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_omittedActiveNodeReturnsNotFound() { var active = node("InvalidActive", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(active)); @@ -195,91 +197,74 @@ public void build_omittedActiveNodeReturnsNotFound() { expect(values.resolveTitle(active.getDocumentReference(), "de")).andReturn(null); expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) .andReturn(List.of(active.getDocumentReference())); - replay(treeService, parentsLister, values); + replayDefault(); var exception = assertThrows(NavigationApiException.class, - () -> new NavigationTreeBuilder(treeService, parentsLister, values).build( + () -> builder.build( request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0))); - verify(treeService, parentsLister, values); + verifyDefault(); assertEquals("navigation_node_not_found", exception.code()); } @Test - public void build_omitsInactiveNodeWithNullChildren() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_omitsInactiveNodeWithNullChildren() { var root = node("InvalidRoot", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(root)); - expectValues(values, root); + expectValues(root); expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()), isA(InternalRightsFilter.class))).andReturn(null); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.empty(), Optional.empty(), 100)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), 100)); + verifyDefault(); assertTrue(response.segments().isEmpty()); } @Test - public void build_activeNodeWithNullChildrenReturnsNotFound() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_activeNodeWithNullChildrenReturnsNotFound() { var active = node("InvalidActive", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(active)); - expectValues(values, active); + expectValues(active); expect(treeService.getSubNodesForParent(eq(active.getDocumentReference()), isA(InternalRightsFilter.class))).andReturn(null); expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true)) .andReturn(List.of(active.getDocumentReference())); - replay(treeService, parentsLister, values); + replayDefault(); var exception = assertThrows(NavigationApiException.class, - () -> new NavigationTreeBuilder(treeService, parentsLister, values).build( + () -> builder.build( request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0))); - verify(treeService, parentsLister, values); + verifyDefault(); assertEquals(HttpStatus.NOT_FOUND, exception.status()); assertEquals("navigation_node_not_found", exception.code()); } @Test - public void build_inaccessibleCurrentNodeReturnsSafeNotFound() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_inaccessibleCurrentNodeReturnsSafeNotFound() { var current = node("Restricted", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of()); expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true)) .andReturn(List.of(current.getDocumentReference())); - replay(treeService, parentsLister, values); - assertNodeNotFound(treeService, parentsLister, values, - request(Optional.of(current.getDocumentReference()), Optional.of(local(current)), 0)); + replayDefault(); + assertNodeNotFound(request(Optional.of(current.getDocumentReference()), + Optional.of(local(current)), 0)); } @Test - public void build_outOfRootCurrentNodeReturnsSafeNotFound() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_outOfRootCurrentNodeReturnsSafeNotFound() { var root = node("Root", "main", 1); var otherSpace = new SpaceReference("Other", wikiRef); var current = new DocumentReference("Current", otherSpace); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(root)); - expectNode(treeService, values, root, List.of()); + expectNode(root, List.of()); expect(parentsLister.getDocumentParentsList(current, true)).andReturn(List.of(current)); - replay(treeService, parentsLister, values); - assertNodeNotFound(treeService, parentsLister, values, - request(Optional.of(current), Optional.of("Other.Current"), 0)); + replayDefault(); + assertNodeNotFound(request(Optional.of(current), Optional.of("Other.Current"), 0)); } @Test - public void build_partExcludedCurrentNodeReturnsSafeNotFound() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_partExcludedCurrentNodeReturnsSafeNotFound() { var current = node("OtherPart", "other", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andAnswer(() -> { @@ -288,57 +273,46 @@ public void build_partExcludedCurrentNodeReturnsSafeNotFound() { }); expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true)) .andReturn(List.of(current.getDocumentReference())); - replay(treeService, parentsLister, values); + replayDefault(); var request = new NavigationRequest(spaceRef, "Content", Optional.of(current.getDocumentReference()), Optional.of(local(current)), "de", Optional.of("main"), 0); - assertNodeNotFound(treeService, parentsLister, values, request); + assertNodeNotFound(request); } @Test - public void build_requestedPartAlwaysReturnsExactlyOneSegment() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_requestedPartAlwaysReturnsExactlyOneSegment() { expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of()); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(new NavigationRequest(spaceRef, "Content", Optional.empty(), Optional.empty(), "de", - Optional.of("Main"), 0)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build(new NavigationRequest(spaceRef, "Content", Optional.empty(), + Optional.empty(), "de", Optional.of("Main"), 0)); + verifyDefault(); assertEquals(1, response.segments().size()); assertEquals("Main", response.segments().get(0).partName()); assertTrue(response.segments().get(0).nodes().isEmpty()); } @Test - public void build_partFilterIsCaseSensitive() { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); + public void test_build_partFilterIsCaseSensitive() { var lowerCasePart = node("Root", "main", 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(lowerCasePart)); - expectNode(treeService, values, lowerCasePart, List.of()); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(new NavigationRequest(spaceRef, "Content", Optional.empty(), Optional.empty(), "de", - Optional.of("Main"), 0)); - verify(treeService, parentsLister, values); + expectNode(lowerCasePart, List.of()); + replayDefault(); + var response = builder.build(new NavigationRequest(spaceRef, "Content", Optional.empty(), + Optional.empty(), "de", Optional.of("Main"), 0)); + verifyDefault(); assertEquals("Main", response.segments().get(0).partName()); assertTrue(response.segments().get(0).nodes().isEmpty()); } private void assertInvalidNodeOmitted(String field, String invalidValue) { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var root = node("Root", "main", 1); var invalid = childNode("Invalid", root, 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(root)); - expectValues(values, root); + expectValues(root); expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()), isA(InternalRightsFilter.class))).andReturn(List.of(invalid)); expect(values.serialize(invalid.getDocumentReference())) @@ -350,60 +324,54 @@ private void assertInvalidNodeOmitted(String field, String invalidValue) { if ("url".equals(field)) { expect(values.resolveUrl(invalid.getDocumentReference(), "de")).andReturn(invalidValue); } - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.empty(), Optional.empty(), 100)); - verify(treeService, parentsLister, values); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), 100)); + verifyDefault(); var rootDto = response.segments().get(0).nodes().get(0); assertTrue(rootDto.isLeaf()); assertTrue(rootDto.children().isEmpty()); + resetDefault(); } - private void assertNodeNotFound(ITreeNodeService treeService, - IDocumentParentsListerRole parentsLister, DefaultNavigationNodeValueResolver values, - NavigationRequest request) { + private void assertNodeNotFound(NavigationRequest request) { var exception = assertThrows(NavigationApiException.class, - () -> new NavigationTreeBuilder(treeService, parentsLister, values).build(request)); - verify(treeService, parentsLister, values); + () -> builder.build(request)); + verifyDefault(); assertEquals(HttpStatus.NOT_FOUND, exception.status()); assertEquals("navigation_node_not_found", exception.code()); } private int expandedDepth(int threshold) { - ITreeNodeService treeService = createMock(ITreeNodeService.class); - IDocumentParentsListerRole parentsLister = createMock(IDocumentParentsListerRole.class); - DefaultNavigationNodeValueResolver values = createMock(DefaultNavigationNodeValueResolver.class); var level1 = node("Level1", "main", 1); var level2 = childNode("Level2", level1, 1); var level3 = childNode("Level3", level2, 1); var level4 = childNode("Level4", level3, 1); expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) .andReturn(List.of(level1)); - expectNode(treeService, values, level1, List.of(level2)); - expectNode(treeService, values, level2, List.of(level3)); - expectNode(treeService, values, level3, List.of(level4)); - expectNode(treeService, values, level4, List.of()); - replay(treeService, parentsLister, values); - var response = new NavigationTreeBuilder(treeService, parentsLister, values) - .build(request(Optional.empty(), Optional.empty(), threshold)); - verify(treeService, parentsLister, values); + expectNode(level1, List.of(level2)); + expectNode(level2, List.of(level3)); + expectNode(level3, List.of(level4)); + expectNode(level4, List.of()); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), threshold)); + verifyDefault(); int depth = 1; var current = response.segments().get(0).nodes().get(0); while (!current.children().isEmpty()) { current = current.children().get(0); depth++; } + resetDefault(); return depth; } - private void expectNode(ITreeNodeService treeService, DefaultNavigationNodeValueResolver values, - TreeNode node, List children) { - expectValues(values, node); + private void expectNode(TreeNode node, List children) { + expectValues(node); expect(treeService.getSubNodesForParent(eq(node.getDocumentReference()), isA(InternalRightsFilter.class))).andReturn(children); } - private void expectValues(DefaultNavigationNodeValueResolver values, TreeNode node) { + private void expectValues(TreeNode node) { expect(values.serialize(node.getDocumentReference())).andReturn(local(node)); expect(values.resolveTitle(node.getDocumentReference(), "de")) .andReturn(node.getDocumentReference().getName());