diff --git a/celements-navigation-rest/pom.xml b/celements-navigation-rest/pom.xml new file mode 100644 index 000000000..0c24d994f --- /dev/null +++ b/celements-navigation-rest/pom.xml @@ -0,0 +1,211 @@ + + + + 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 + + + com.celements + celements-servlet + 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 + + + com.celements + celements-shared-tests + test + + + com.celements + celements-subsystem-migration-manager + test + + + com.celements + celements-xwiki-velocity + 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-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 + + + + + + + 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..7b9c0dfad --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java @@ -0,0 +1,48 @@ +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 +class DefaultNavigationNodeValueResolver { + + 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; + } + + public String serialize(DocumentReference docRef) { + return modelUtils.serializeRefLocal(docRef); + } + + public String resolveTitle(DocumentReference docRef, String language) { + return menuNameCommand.getMultilingualMenuName(modelUtils.serializeRefLocal(docRef), language, + modelContext.getXWikiContext()); + } + + 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..5af643f1f --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java @@ -0,0 +1,104 @@ +package com.celements.navigation.api; + +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.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +@RestController +@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; + 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") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Navigation tree"), + @ApiResponse(responseCode = "400", + description = "Invalid reference or parameter, or unsupported language", + 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))), + @ApiResponse(responseCode = "500", description = "Navigation infrastructure is unavailable", + content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class))) }) + public ResponseEntity getNavigation( + @Parameter(description = "Canonical local space reference") @PathVariable String nodeSpace, + @Parameter(description = "Canonical local document reference") @RequestParam( + required = false) String currentNode, + @Parameter( + 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, + showInactiveToLevel); + var response = treeBuilder.build(request); + 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 new file mode 100644 index 000000000..782595eaf --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java @@ -0,0 +1,5 @@ +package com.celements.navigation.api; + +public record NavigationErrorResponse(String code, String message) { + +} 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..28bfa15f4 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java @@ -0,0 +1,22 @@ +package com.celements.navigation.api; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record NavigationNodeDto( + 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/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..c0854be67 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java @@ -0,0 +1,17 @@ +package com.celements.navigation.api; + +import java.util.List; + +import javax.annotation.Nullable; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record NavigationSegmentDto( + @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 new file mode 100644 index 000000000..bc2f3803c --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java @@ -0,0 +1,193 @@ +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 DefaultNavigationNodeValueResolver valueResolver; + + @Inject + NavigationTreeBuilder(ITreeNodeService treeNodeService, IDocumentParentsListerRole parentsLister, + DefaultNavigationNodeValueResolver 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..7217afe65 --- /dev/null +++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java @@ -0,0 +1,21 @@ +package com.celements.navigation.api; + +import java.util.List; + +import javax.annotation.Nullable; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record NavigationTreeResponse( + 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 new file mode 100644 index 000000000..9f8107d6e --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java @@ -0,0 +1,68 @@ +package com.celements.navigation.api; + +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.url.UrlService; +import com.xpn.xwiki.doc.XWikiDocument; + +public class DefaultNavigationNodeValueResolverTest extends AbstractComponentTest { + + 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 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(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"); + replayDefault(); + assertEquals("Content.Home", resolver.serialize(docRef)); + assertEquals("Startseite", resolver.resolveTitle(docRef, "de")); + assertEquals("/Content/Home?language=de", resolver.resolveUrl(docRef, "de")); + 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 new file mode 100644 index 000000000..05eb301e8 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java @@ -0,0 +1,185 @@ +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.List; +import java.util.Optional; + +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 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 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 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 test_getNavigation_returnsExactJsonAndPrivateNoStoreHeader() throws Exception { + var request = request(); + expect(requestResolver.resolve("Content", null, "de", null, 0)).andReturn(request); + expect(treeBuilder.build(request)).andReturn(response()); + + 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(); + verifyDefault(); + + 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 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(); + verifyDefault(); + + assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter", + "The parameter is invalid."); + } + + @Test + 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.")); + + 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(); + verifyDefault(); + + assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter", + "The parameter is invalid."); + } + + @Test + 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.")); + + 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(); + verifyDefault(); + + assertError(result.getResponse().getContentAsByteArray(), "navigation_node_not_found", + "The navigation node was not found."); + } + + @Test + 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")); + + replayDefault(); + var result = mockMvc.perform(get("/v1/navigation/Content")) + .andExpect(status().isInternalServerError()) + .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."); + } + + 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()); + } + +} 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..d12fb6928 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java @@ -0,0 +1,22 @@ +package com.celements.navigation.api; + +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +public class NavigationJsonContractTest { + + @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..9209fff7a --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java @@ -0,0 +1,133 @@ +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.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.DelegatingWebMvcConfiguration; + +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(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(); + } + + @After + public void cleanup() { + context.close(); + } + + @Test + public void apiDocsExposeNavigationContract() 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()); + 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"))); + 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); + JsonNode content = response.at("/content/application~1json"); + assertTrue(content.get("schema").isObject()); + } + 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; + } + +} 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..36df4217e --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java @@ -0,0 +1,227 @@ +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.common.test.AbstractComponentTest; +import com.celements.model.context.ModelContext; +import com.celements.model.util.ModelUtils; +import com.celements.web.service.IWebUtilsService; + +public class NavigationRequestResolverTest extends AbstractComponentTest { + + 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 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 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")); + replayDefault(); + var request = resolver.resolve("Content", "Content.MyPage", "DE", "main", 2); + verifyDefault(); + 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 test_resolve_defaultsLanguageFromCurrentRequestAndNormalizesBlankPart() { + expectCanonicalSpace("Content"); + expect(modelContext.getLanguage()).andReturn(Optional.of("fr")); + replayDefault(); + var request = resolver.resolve("Content", null, null, " ", 0); + verifyDefault(); + assertEquals("fr", request.language()); + assertEquals(Optional.empty(), request.partName()); + assertEquals(Optional.empty(), request.serializedCurrentNode()); + } + + @Test + public void test_resolve_defaultsLanguageFromWikiWhenRequestLanguageIsAbsent() { + expectCanonicalSpace("Content"); + expect(modelContext.getLanguage()).andReturn(Optional.empty()); + expect(modelContext.getDefaultLanguage()).andReturn("en"); + replayDefault(); + assertEquals("en", resolver.resolve("Content", null, null, null, 0).language()); + verifyDefault(); + } + + @Test + 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"); + replayDefault(); + assertApiError("invalid_reference", + () -> resolver.resolve("xwiki:Content", null, null, null, 0)); + verifyDefault(); + } + + @Test + public void test_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"); + replayDefault(); + assertApiError("invalid_reference", + () -> resolver.resolve("Content", "other:Content.MyPage", null, null, 0)); + verifyDefault(); + } + + @Test + 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"); + replayDefault(); + assertApiError("invalid_reference", + () -> resolver.resolve("Content", "xwiki:Content.MyPage", null, null, 0)); + verifyDefault(); + } + + @Test + public void test_resolve_rejectsBlankAndNoncanonicalCurrentNode() { + expectCanonicalSpace("Content"); + replayDefault(); + assertApiError("invalid_reference", () -> resolver.resolve("Content", " ", null, null, 0)); + verifyDefault(); + resetDefault(); + expectCanonicalSpace("Content"); + replayDefault(); + assertApiError("invalid_reference", + () -> resolver.resolve("Content", " Content.MyPage ", null, null, 0)); + verifyDefault(); + } + + @Test + public void test_resolve_rejectsNoncanonicalNodeSpace() { + replayDefault(); + assertApiError("invalid_reference", () -> resolver.resolve(" Content ", null, null, null, 0)); + verifyDefault(); + } + + @Test + public void test_resolve_rejectsMalformedReference() { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("[", SpaceReference.class, wikiRef)) + .andThrow(new IllegalArgumentException("malformed")); + replayDefault(); + assertApiError("invalid_reference", () -> resolver.resolve("[", null, null, null, 0)); + verifyDefault(); + } + + @Test + public void test_resolve_doesNotMisclassifyUnexpectedNullPointerException() { + expect(modelContext.getWikiRef()).andThrow(new NullPointerException("backend failure")); + replayDefault(); + var exception = assertThrows(NullPointerException.class, + () -> resolver.resolve("Content", null, null, null, 0)); + verifyDefault(); + assertEquals("backend failure", exception.getMessage()); + } + + @Test + 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")); + replayDefault(); + var exception = assertThrows(NullPointerException.class, + () -> resolver.resolve("Content", null, null, null, 0)); + verifyDefault(); + assertEquals("serialization failure", exception.getMessage()); + } + + @Test + public void test_resolve_rejectsNullParseResult() { + expect(modelContext.getWikiRef()).andReturn(wikiRef); + expect(modelUtils.resolveRef("Content", SpaceReference.class, wikiRef)).andReturn(null); + replayDefault(); + assertApiError("invalid_reference", () -> resolver.resolve("Content", null, null, null, 0)); + verifyDefault(); + } + + @Test + public void test_resolve_rejectsUnsupportedAndInvalidLanguage() { + expectCanonicalSpace("Content"); + expect(modelUtils.normalizeLang("it")).andReturn("it"); + expect(webUtilsService.getAllowedLanguages(spaceRef)).andReturn(List.of("en", "de")); + replayDefault(); + assertApiError("unsupported_language", () -> resolver.resolve("Content", null, "it", null, 0)); + verifyDefault(); + resetDefault(); + expectCanonicalSpace("Content"); + expect(modelUtils.normalizeLang("invalid")) + .andThrow(new IllegalArgumentException("invalid language")); + replayDefault(); + assertApiError("unsupported_language", + () -> resolver.resolve("Content", null, "invalid", null, 0)); + verifyDefault(); + } + + @Test + 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)); + verifyDefault(); + } + + 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..05353c872 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java @@ -0,0 +1,128 @@ +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.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.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.user.api.XWikiUser; + +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 test_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 test_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"); + getXContext().setUser(userName); + expect( + rightsAccess.hasAccessLevel(eq(rootNode.getDocumentReference()), eq(VIEW), + isA(XWikiUser.class))) + .andReturn(true); + expect(rightsAccess.hasAccessLevel(eq(childNode.getDocumentReference()), eq(VIEW), + isA(XWikiUser.class))).andReturn(childVisible); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andAnswer(rightsFiltered(List.of(rootNode))); + expectValues(rootNode); + expect(treeService.getSubNodesForParent(eq(rootNode.getDocumentReference()), + isA(InternalRightsFilter.class))) + .andAnswer(rightsFiltered(List.of(childNode))); + if (childVisible) { + expectValues(childNode); + expect(treeService.getSubNodesForParent(eq(childNode.getDocumentReference()), + 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) { + return () -> { + var filter = (InternalRightsFilter) getCurrentArguments()[1]; + observedFilters.add(filter); + return nodes.stream().filter(node -> filter.includeTreeNode(node, getXContext())).toList(); + }; + } + + private void expectValues(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..575df3b98 --- /dev/null +++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java @@ -0,0 +1,401 @@ +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.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 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 test_build_groupsAndOrdersSegmentsWithoutReorderingNodes() { + 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(node, List.of()); + } + 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()); + 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 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(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())); + 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); + 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 test_build_combinesActivePathAndInactiveThresholdExpansion() { + 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(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())); + 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); + 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 test_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 test_build_unfilteredEmptyNodeSpaceReturnsEmptySegments() { + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of()); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), 0)); + verifyDefault(); + assertTrue(response.segments().isEmpty()); + } + + @Test + 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(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(" "); + 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()); + assertTrue(rootDto.children().isEmpty()); + } + + @Test + public void test_build_omitsNullAndBlankMandatoryNodeValues() { + for (String field : List.of("docRef", "title", "url")) { + assertInvalidNodeOmitted(field, null); + assertInvalidNodeOmitted(field, " "); + } + } + + @Test + public void test_build_omittedActiveNodeReturnsNotFound() { + 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())); + replayDefault(); + var exception = assertThrows(NavigationApiException.class, + () -> builder.build( + request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0))); + verifyDefault(); + assertEquals("navigation_node_not_found", exception.code()); + } + + @Test + public void test_build_omitsInactiveNodeWithNullChildren() { + var root = node("InvalidRoot", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(root)); + expectValues(root); + expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()), + isA(InternalRightsFilter.class))).andReturn(null); + replayDefault(); + var response = builder.build(request(Optional.empty(), Optional.empty(), 100)); + verifyDefault(); + assertTrue(response.segments().isEmpty()); + } + + @Test + public void test_build_activeNodeWithNullChildrenReturnsNotFound() { + var active = node("InvalidActive", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(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())); + replayDefault(); + var exception = assertThrows(NavigationApiException.class, + () -> builder.build( + request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0))); + verifyDefault(); + assertEquals(HttpStatus.NOT_FOUND, exception.status()); + assertEquals("navigation_node_not_found", exception.code()); + } + + @Test + 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())); + replayDefault(); + assertNodeNotFound(request(Optional.of(current.getDocumentReference()), + Optional.of(local(current)), 0)); + } + + @Test + 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(root, List.of()); + expect(parentsLister.getDocumentParentsList(current, true)).andReturn(List.of(current)); + replayDefault(); + assertNodeNotFound(request(Optional.of(current), Optional.of("Other.Current"), 0)); + } + + @Test + public void test_build_partExcludedCurrentNodeReturnsSafeNotFound() { + 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())); + replayDefault(); + var request = new NavigationRequest(spaceRef, "Content", + Optional.of(current.getDocumentReference()), Optional.of(local(current)), "de", + Optional.of("main"), 0); + assertNodeNotFound(request); + } + + @Test + public void test_build_requestedPartAlwaysReturnsExactlyOneSegment() { + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of()); + 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 test_build_partFilterIsCaseSensitive() { + var lowerCasePart = node("Root", "main", 1); + expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class))) + .andReturn(List.of(lowerCasePart)); + 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) { + 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(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); + } + 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(NavigationRequest request) { + var exception = assertThrows(NavigationApiException.class, + () -> builder.build(request)); + verifyDefault(); + assertEquals(HttpStatus.NOT_FOUND, exception.status()); + assertEquals("navigation_node_not_found", exception.code()); + } + + private int expandedDepth(int threshold) { + 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(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(TreeNode node, List children) { + expectValues(node); + expect(treeService.getSubNodesForParent(eq(node.getDocumentReference()), + isA(InternalRightsFilter.class))).andReturn(children); + } + + private void expectValues(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(); + } + +}