diff --git a/all/pom.xml b/all/pom.xml index 20c1e1d11..96cb45cce 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -7,7 +7,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT etoolbox-authoring-kit-all diff --git a/core/pom.xml b/core/pom.xml index 59400882d..8f515e73b 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -7,7 +7,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT etoolbox-authoring-kit-core @@ -74,11 +74,6 @@ org.apache.maven.plugins maven-surefire-plugin - - - **/AllTests.class - - diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigAccess.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigAccess.java index 4ed8564b3..5cee4d2de 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigAccess.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigAccess.java @@ -13,12 +13,10 @@ */ package com.exadel.aem.toolkit.core.configurator.models.internal; -import java.util.Objects; import javax.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.osgi.framework.BundleContext; -import org.osgi.framework.FrameworkUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.adobe.granite.ui.components.ExpressionCustomizer; @@ -26,6 +24,7 @@ import com.exadel.aem.toolkit.core.configurator.services.ConfigChangeListener; import com.exadel.aem.toolkit.core.configurator.utils.PermissionUtil; import com.exadel.aem.toolkit.core.configurator.utils.RequestUtil; +import com.exadel.aem.toolkit.core.utils.ServiceUtil; /** * Enumerates possible outcomes of a configuration access request @@ -132,16 +131,10 @@ private static ConfigAccess pick(HttpServletRequest request) { * @return True or false */ static boolean isGrantable(HttpServletRequest request) { - try { - BundleContext context = (BundleContext) request.getAttribute(BundleContext.class.getName()); - if (context == null) { - context = Objects.requireNonNull(FrameworkUtil.getBundle(ConfigAccess.class).getBundleContext()); - } - ConfigChangeListener listener = Objects.requireNonNull(context.getService(context.getServiceReference(ConfigChangeListener.class))); - return listener.isEnabled(); - } catch (RuntimeException e) { - LOG.error("Could not acquire an OSGi entity", e); - return false; + BundleContext context = (BundleContext) request.getAttribute(BundleContext.class.getName()); + if (context != null) { + return ServiceUtil.withService(ConfigChangeListener.class, context, ConfigChangeListener::isEnabled, false); } + return ServiceUtil.withService(ConfigChangeListener.class, ConfigChangeListener::isEnabled, false); } } diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigDefinition.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigDefinition.java index c389b5942..1669972db 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigDefinition.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/models/internal/ConfigDefinition.java @@ -42,6 +42,7 @@ import com.exadel.aem.toolkit.core.configurator.ConfiguratorConstants; import com.exadel.aem.toolkit.core.configurator.utils.PermissionUtil; import com.exadel.aem.toolkit.core.configurator.utils.RequestUtil; +import com.exadel.aem.toolkit.core.utils.ServiceUtil; /** * Represents a configuration definition, i.e., a set of configuration attributes united by the same PID together @@ -293,17 +294,15 @@ public static ConfigDefinition from(HttpServletRequest request) { * does not exist */ private static ConfigDefinition from(String pid, BundleContext context) { - ConfigurationAdmin configurationAdmin; - MetaTypeService metaTypeService; - try { - configurationAdmin = Objects.requireNonNull(context.getService(context.getServiceReference(ConfigurationAdmin.class))); - metaTypeService = Objects.requireNonNull(context.getService(context.getServiceReference(MetaTypeService.class))); - } catch (RuntimeException e) { - LOG.error("Could not acquire OSGi entity", e); + if (context == null) { + LOG.error("Cannot retrieve configuration for {}: no bundle context available", pid); return EMPTY; } - - Configuration configuration = getConfigurationObject(configurationAdmin, pid); + Configuration configuration = ServiceUtil.withService( + ConfigurationAdmin.class, + context, + ca -> getConfigurationObject(ca, pid), + null); if (configuration == null) { return EMPTY; } @@ -312,25 +311,31 @@ private static ConfigDefinition from(String pid, BundleContext context) { && !StringUtils.equals(configuration.getPid(), configuration.getFactoryPid()); String metatypePid = isFactoryInstance ? configuration.getFactoryPid() : configuration.getPid(); - for (Bundle bundle : context.getBundles()) { - MetaTypeInformation metaTypeInformation = metaTypeService.getMetaTypeInformation(bundle); - if (metaTypeInformation == null) { - continue; - } - ObjectClassDefinition ocd; - try { - ocd = Objects.requireNonNull(metaTypeInformation.getObjectClassDefinition(metatypePid, null)); - } catch (IllegalArgumentException | NullPointerException e) { - // Not an error: this actually happens if the configuration is not present in the current bundle - continue; - } - ConfigDefinition result = from(configuration, ocd); - result.isFactory = ArrayUtils.contains(metaTypeInformation.getFactoryPids(), pid); - result.pid = pid; - result.factoryPid = configuration.getFactoryPid(); - return result; - } - return EMPTY; + return ServiceUtil.withService( + MetaTypeService.class, + context, + mts -> { + for (Bundle bundle : context.getBundles()) { + MetaTypeInformation metaTypeInformation = mts.getMetaTypeInformation(bundle); + if (metaTypeInformation == null) { + continue; + } + ObjectClassDefinition ocd; + try { + ocd = Objects.requireNonNull(metaTypeInformation.getObjectClassDefinition(metatypePid, null)); + } catch (IllegalArgumentException | NullPointerException e) { + // Not an error: this actually happens if the configuration is not present in the current bundle + continue; + } + ConfigDefinition result = from(configuration, ocd); + result.isFactory = ArrayUtils.contains(metaTypeInformation.getFactoryPids(), pid); + result.pid = pid; + result.factoryPid = configuration.getFactoryPid(); + return result; + } + return EMPTY; + }, + EMPTY); } /** diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/services/ConfigChangeListener.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/services/ConfigChangeListener.java index e5882bb75..6bb6d8c5e 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/services/ConfigChangeListener.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/services/ConfigChangeListener.java @@ -51,6 +51,7 @@ import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.core.configurator.ConfiguratorConstants; +import com.exadel.aem.toolkit.core.utils.ResolverUtil; import com.exadel.aem.toolkit.core.utils.ValueMapUtil; /** @@ -96,7 +97,7 @@ public class ConfigChangeListener implements ResourceChangeListener, ExternalRes @Activate void activate(BundleContext context, ConfigChangeListenerConfiguration config) { LOG.info("Configuration change listener is {}", config.enabled() ? "enabled" : "disabled"); - try (ResourceResolver resolver = newResolver()) { + try (ResourceResolver resolver = ResolverUtil.newResolver(resourceResolverFactory)) { if (ArrayUtils.isNotEmpty(config.cleanUp())) { activateWithCleanUp(resolver, config.cleanUp()); } @@ -225,7 +226,7 @@ public void onChange(List list) { return; } asyncExecutor.submit(() -> { - try (ResourceResolver resolver = newResolver()) { + try (ResourceResolver resolver = ResolverUtil.newResolver(resourceResolverFactory)) { for (String path : configsToUpdate) { Resource resource = resolver.getResource(path); if (resource == null) { @@ -247,21 +248,6 @@ public void onChange(List list) { }); } - /* -------------------- - Sling instance logic - -------------------- */ - - /** - * Creates a new {@link ResourceResolver} instance for accessing repository resources - * @return New instance of {@code ResourceResolver} - * @throws LoginException If the resolver cannot be created - */ - private ResourceResolver newResolver() throws LoginException { - return resourceResolverFactory.getServiceResourceResolver( - Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "eak-service") - ); - } - /* ------------------- Configuration logic ------------------- */ diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java index d771d1a10..abd331be9 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java @@ -14,13 +14,13 @@ package com.exadel.aem.toolkit.core.configurator.servlets.form; +import javax.annotation.Nonnull; import javax.servlet.Servlet; import org.apache.sling.api.SlingHttpServletRequest; import org.apache.sling.api.SlingHttpServletResponse; import org.apache.sling.api.servlets.HttpConstants; import org.apache.sling.api.servlets.SlingSafeMethodsServlet; -import org.jetbrains.annotations.NotNull; import org.osgi.service.component.annotations.Component; import com.exadel.aem.toolkit.core.configurator.models.internal.ConfigDefinition; @@ -45,7 +45,7 @@ public class ConfigDataSource extends SlingSafeMethodsServlet { * @param response The HTTP response */ @Override - protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) { + protected void doGet(@Nonnull SlingHttpServletRequest request, @Nonnull SlingHttpServletResponse response) { ConfigDefinition config = ConfigDefinition.from(request); if (config == null || !config.isValid()) { return; diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java index 5443a185b..dfe011c16 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.util.Objects; +import javax.annotation.Nonnull; import javax.jcr.Session; import javax.servlet.Servlet; @@ -25,7 +26,6 @@ import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.api.servlets.HttpConstants; import org.apache.sling.api.servlets.SlingAllMethodsServlet; -import org.jetbrains.annotations.NotNull; import org.osgi.framework.BundleContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; @@ -100,8 +100,8 @@ private void deactivate() { */ @Override protected void doPost( - @NotNull SlingHttpServletRequest request, - @NotNull SlingHttpServletResponse response) throws IOException { + @Nonnull SlingHttpServletRequest request, + @Nonnull SlingHttpServletResponse response) throws IOException { if (!configChangeListener.isEnabled()) { sendError(response, SlingHttpServletResponse.SC_SERVICE_UNAVAILABLE, ConfigAccess.DISABLED.getError()); diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java new file mode 100644 index 000000000..f442c6518 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java @@ -0,0 +1,95 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a change announcement entry defining a JCR path or XPath to report as changed when the relay is enabled or + * disabled. + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +public class ChangeSample { + private final String path; + private final int limit; + private final String user; + + /** + * Creates a new {@code ChangeSample} with the provided path, limit and user values + * @param path JCR path or XPath expression to report as changed + * @param limit Optional limit of resources to report as changed under the provided path + * @param user Optional user identifier to use for reporting changes + */ + @JsonCreator + ChangeSample( + @JsonProperty("path") String path, + @JsonProperty("limit") int limit, + @JsonProperty("user") String user) { + this.path = path; + this.limit = limit; + this.user = user; + } + + /** + * Gets the JCR path or XPath expression defined in this announcement + * @return String value + */ + public String getPath() { + return path; + } + + /** + * Gets the optional limit of resources to report as changed under the provided path + * @return Integer limit value + */ + public int getLimit() { + return limit; + } + + /** + * Gets the optional user identifier to use for reporting changes + * @return A nullable user identifier string + */ + public String getUser() { + return user; + } + + /** + * {@inheritDoc} + *

The equality check is based solely on the {@code path} property since we do not want to report the + * same path as changed multiple times

+ */ + @Override + public final boolean equals(Object other) { + if (!(other instanceof ChangeSample)) { + return false; + } + ChangeSample announcement = (ChangeSample) other; + return Objects.equals(path, announcement.path); + } + + /** + * {@inheritDoc} + *

The hash code is based solely on the {@code path} property since we do not want to report the same path as + * changed multiple times

+ */ + @Override + public int hashCode() { + return Objects.hashCode(path); + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java new file mode 100644 index 000000000..08f1d824a --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java @@ -0,0 +1,97 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import java.util.Collection; +import java.util.Collections; +import javax.annotation.Nonnull; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +/** + * Represents the set of data required to configure and operate a {@code RelayProvider}. Every entry must at least have + * a valid path mapping for path translation. Mapped paths should not be the same or parent/child of each other to avoid + * traversal ambiguity. + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +public class RelayInfo { + + private final RelayMapping pathMapping; + + private final Collection userMappings; + + private final Collection changeSamples; + + /** + * Creates a new {@code RelayInfo} with the provided path mapping, user mappings and change samples + * @param pathMapping A non-null source-to-target mapping entry for path translation + * @param userMappings A collection of source-to-target mapping entries for user identity translation + * @param changeSamples A collection of change announcement entries defining JCR paths or XPaths to report as + * changed when the relay is enabled or disabled + */ + public RelayInfo( + @Nonnull RelayMapping pathMapping, + Collection userMappings, + Collection changeSamples) { + this.pathMapping = pathMapping; + this.userMappings = userMappings; + this.changeSamples = changeSamples; + } + + /** + * Gets the source JCR path used for path translation in this relay + * @return A non-null string value + */ + @Nonnull + public String getSource() { + return StringUtils.defaultString(pathMapping.getFrom()); + } + + /** + * Gets the target JCR path used for path translation in this relay + * @return A non-null string value + */ + @Nonnull + public String getTarget() { + return StringUtils.defaultString(pathMapping.getTo()); + } + + /** + * Retrieves the mapped user ID per the collection of user mappings defined in this relay + * @param source The source user ID to translate + * @return A nullable target user ID + */ + public String getUserMapping(String source) { + if (CollectionUtils.isEmpty(userMappings)) { + return null; + } + return userMappings + .stream() + .filter(mapping -> StringUtils.equals(source, mapping.getFrom())) + .map(RelayMapping::getTo) + .findFirst() + .orElse(null); + } + + /** + * Gets the collection of change announcement entries defining JCR paths or XPaths to report as changed when the + * relay is enabled or disabled + * @return A collection of {@link ChangeSample} instances + */ + public Collection getChangeSamples() { + return Collections.unmodifiableCollection(changeSamples); + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java new file mode 100644 index 000000000..9e4225dac --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java @@ -0,0 +1,92 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; + +/** + * Represents a source-to-target mapping entry used for path or user identity translation. Mappings are designed so that + * a set of mappings can only contain entries with different sources ({@code from}-s) to avoid ambiguity as to which + * mapping to apply for a given source value. + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +public class RelayMapping { + + private final String from; + private final String to; + + /** + * Creates a new {@code Mapping} with the provided source and target values + * @param from Source value + * @param to Target value + */ + @JsonCreator + RelayMapping(@JsonProperty("from") String from, @JsonProperty("to") String to) { + this.from = from; + this.to = to; + } + + /** + * Gets the source value of this mapping + * @return A nullable source string + */ + public String getFrom() { + return from; + } + + /** + * Gets the target value of this mapping + * @return A nullable target string + */ + public String getTo() { + return to; + } + + /** + * Gets whether the mapping has non-blank source and target values + * @return True or false + */ + public boolean isValid() { + return StringUtils.isNoneBlank(getFrom(), getTo()) + && !RelayPathHelper.isSamePathOrSubpath(getFrom(), getTo()) + && !RelayPathHelper.isSamePathOrSubpath(getTo(), getFrom()); + } + + /** + * {@inheritDoc} + */ + @Override + public final boolean equals(Object other) { + if (!(other instanceof RelayMapping)) { + return false; + } + RelayMapping mapping = (RelayMapping) other; + return Objects.equals(from, mapping.from); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hashCode(from); + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java new file mode 100644 index 000000000..885058d41 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java @@ -0,0 +1,115 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import java.util.Iterator; +import javax.annotation.Nonnull; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceMetadata; +import org.apache.sling.api.resource.ResourceWrapper; + +import com.exadel.aem.toolkit.core.CoreConstants; +import com.exadel.aem.toolkit.core.relay.utils.RelayResourceHelper; + +/** + * An implementation of {@link Resource} used by the relay provider to expose resources at a mapped source path. + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +public class RelayResource extends ResourceWrapper { + + private final String path; + private final ResourceMetadata resourceMetadata; + + /** + * Creates a new {@code RelayResource} wrapping the provided resource and overriding its path + * @param original A non-null original {@link Resource} to wrap + * @param path A non-null JCR path to expose for this resource + */ + public RelayResource(@Nonnull Resource original, @Nonnull String path) { + super(original); + this.path = StringUtils.defaultIfEmpty( + StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH), + CoreConstants.SEPARATOR_SLASH); + // We must create a copy of the resource metadata to avoid the "{@code JcrNodeResourceMetadata is locked}" exception + // because the {@code original} resource is already locked by Sling + this.resourceMetadata = new ResourceMetadata(); + this.resourceMetadata.putAll((ResourceMetadata) original.getResourceMetadata().clone()); + this.resourceMetadata.put(ResourceMetadata.RESOLUTION_PATH, this.path); + } + + /** + * {@inheritDoc} + */ + @Override + public Resource getChild(@Nonnull String relativePath) { + String fullPath = path + CoreConstants.SEPARATOR_SLASH + StringUtils.strip(relativePath, CoreConstants.SEPARATOR_SLASH); + return getResourceResolver().getResource(fullPath); + } + + /** + * {@inheritDoc} + */ + @Override + @Nonnull + public String getName() { + return path.contains(CoreConstants.SEPARATOR_SLASH) + ? StringUtils.substringAfterLast(path, CoreConstants.SEPARATOR_SLASH) + : path; + } + + /** + * {@inheritDoc} + */ + @Override + public Resource getParent() { + if (!StringUtils.contains(path, CoreConstants.SEPARATOR_SLASH)) { + return null; + } + String parentPath = StringUtils.substringBeforeLast(path, CoreConstants.SEPARATOR_SLASH); + if (parentPath.isEmpty()) { + parentPath = CoreConstants.SEPARATOR_SLASH; + } + return getResourceResolver().getResource(parentPath); + } + + /** + * {@inheritDoc} + */ + @Override + @Nonnull + public String getPath() { + return path; + } + + /** + * {@inheritDoc} + */ + @Override + @Nonnull + public ResourceMetadata getResourceMetadata() { + return resourceMetadata; + } + + /** + * {@inheritDoc} + */ + @Override + public @Nonnull Iterator listChildren() { + return RelayResourceHelper.listChildren(getResource(), path); + } +} + diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java new file mode 100644 index 000000000..2fbf6c1c2 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java @@ -0,0 +1,267 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.services; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.jcr.RepositoryException; +import javax.jcr.Session; +import javax.jcr.query.Query; +import javax.jcr.query.QueryResult; +import javax.jcr.query.Row; +import javax.jcr.query.RowIterator; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.observation.ResourceChange; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.exadel.aem.toolkit.core.CoreConstants; +import com.exadel.aem.toolkit.core.relay.models.ChangeSample; +import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; +import com.exadel.aem.toolkit.core.utils.ResolverUtil; + +/** + * Resolves a list of JCR paths or XPath expressions to concrete JCR paths and produces + * {@link ResourceChange} notifications for use during relay provider start and stop + */ +class PathSampler { + + private static final Logger LOG = LoggerFactory.getLogger(PathSampler.class); + + private static final String PROPERTY_USER_ID = "userId"; + + private ResourceResolverFactory resolverFactory; + private Collection samples; + private String source; + private String target; + + /** + * Default (instantiation-restricting) constructor + */ + private PathSampler() { + } + + /** + * Resolves the configured path samples and produces a collection of {@link ResourceChange} notifications + * @return A non-null, possibly empty collection of {@code ResourceChange} instances + */ + Collection createChanges() { + Set paths = new HashSet<>(); + + ResourceResolver resolver = null; + try { + for (ChangeSample sample : CollectionUtils.emptyIfNull(samples)) { + if (isXpath(sample.getPath())) { + resolver = rotateResolver(resolver, sample.getUser()); + if (resolver == null) { + // Exception is already logged + continue; + } + Session session = resolver.adaptTo(Session.class); + if (session == null) { + LOG.error("Failed to adapt a session from the resolver for user {}", resolver.getUserID()); + continue; + } + paths.addAll(resolveXpath(sample, session)); + } else if (isJcrPath(sample.getPath())) { + paths.add(sample.getPath()); + } + } + } finally { + if (resolver != null) { + resolver.close(); + } + } + // All resolved paths must point to source instead of target + return paths + .stream() + .map(p -> RelayPathHelper.isSamePathOrSubpath(p, target) ? RelayPathHelper.replace(p, target, source) : p) + .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) + .collect(Collectors.toList()); + } + + /** + * Gets whether the provided value represents a plain JCR path + * @param value Arbitrary string to check + * @return True or false + */ + private static boolean isJcrPath(String value) { + return StringUtils.startsWith(value, CoreConstants.SEPARATOR_SLASH); + } + + /** + * Gets whether the provided value represents an XPath expression + * @param value Arbitrary string to check + * @return True or false + */ + private static boolean isXpath(String value) { + if (StringUtils.isBlank(value)) { + return false; + } + return StringUtils.startsWithAny(value.trim(), "/jcr:root", "//") + || StringUtils.containsAny(value, CoreConstants.ARRAY_OPENING, CoreConstants.SEPARATOR_AT, "(*"); + } + + /** + * Executes the provided XPath expression against the given session and collects the resulting JCR paths + * @param sample {@link ChangeSample} instance containing the XPath expression to execute and the optional limit of + * results + * @param session JCR {@link Session} used for query execution + * @return A non-null, possibly empty list of JCR path strings matching the expression + */ + private static List resolveXpath(ChangeSample sample, Session session) { + try { + @SuppressWarnings("deprecation") + Query query = session.getWorkspace().getQueryManager().createQuery(sample.getPath(), Query.XPATH); + if (sample.getLimit() > 0) { + query.setLimit(sample.getLimit()); + } + QueryResult queryResult = query.execute(); + List result = new ArrayList<>(); + RowIterator rowIterator = queryResult.getRows(); + while (rowIterator.hasNext()) { + Row next = rowIterator.nextRow(); + result.add(next.getPath()); + } + return result; + } catch (RepositoryException e) { + LOG.error("Failed to execute the XPath expression {}", sample.getPath(), e); + return Collections.emptyList(); + } + } + + /** + * Called by {@link #createChanges()} to obtain a {@link ResourceResolver} for the provided user ID, closing the + * preexisting resolver if the associated user ID differs + * @param existing Existing resolver, or {@code null} + * @param userId User ID to obtain a resolver for; might be {@code null} or empty for the default resolver + * @return A {@code ResourceResolver} instance for the provided user ID, a pre-existent resolver if still valid for + * the current user, or {@code null} if the resolver cannot be created + */ + private ResourceResolver rotateResolver(ResourceResolver existing, String userId) { + if (existing != null) { + String existingUserId = (String) existing.getPropertyMap().get(PROPERTY_USER_ID); + boolean isMatch = StringUtils.equals(userId, existingUserId) + || (StringUtils.isEmpty(userId) && ResolverUtil.SERVICE_USER_ID.equals(existingUserId)); + if (isMatch) { + return existing; + } + } + try { + ResourceResolver newResolver = ResolverUtil.newResolver(resolverFactory, userId); + newResolver.getPropertyMap().put(PROPERTY_USER_ID, userId); + return newResolver; + } catch (LoginException e) { + LOG.error("Failed to create a resource resolver for {}", userId, e); + return null; + } finally { + if (existing != null) { + existing.close(); + } + } + } + + /* ------- + Builder + ------- */ + + /** + * Creates a new {@link Builder} for instantiating a {@link PathSampler} + * @return A new {@code Builder} instance + */ + static Builder builder() { + return new Builder(); + } + + /** + * Constructs {@link PathSampler} instances with the required configuration + */ + @SuppressWarnings({"UnusedReturnValue"}) + static class Builder { + + private ResourceResolverFactory resolverFactory; + private String source; + private String target; + private Collection samples; + + /** + * Default (instantiation-restricting) constructor + */ + private Builder() { + } + + /** + * Sets the {@link ResourceResolverFactory} used to create user-mapped resolvers + * @param value {@code ResourceResolverFactory} instance + * @return This builder + */ + Builder resolverFactory(ResourceResolverFactory value) { + this.resolverFactory = value; + return this; + } + + /** + * Sets the collection of path samples to resolve and report as changed when the relay is enabled or disabled + * @param value Collection of {@link ChangeSample} instances + * @return This builder + */ + Builder samples(Collection value) { + samples = value; + return this; + } + + /** + * Sets the JCR source path prefix to be replaced in the resolved paths + * @param value Source JCR path prefix + * @return This builder + */ + Builder source(String value) { + this.source = value; + return this; + } + + /** + * Sets the JCR target path prefix to replace the source prefix with in the resolved paths + * @param value Target JCR path prefix + * @return This builder + */ + Builder target(String value) { + this.target = value; + return this; + } + + /** + * Creates a configured {@link PathSampler} from the current builder state + * @return A new {@link PathSampler} instance + */ + PathSampler build() { + PathSampler result = new PathSampler(); + result.resolverFactory = resolverFactory; + result.source = source; + result.target = target; + result.samples = samples; + return result; + } + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java new file mode 100644 index 000000000..5c2d344f7 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java @@ -0,0 +1,70 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.services; + +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.AttributeType; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; + +/** + * OSGi Metatype configuration for the relay provider host. Defines path and user mappings and optional + * change-reporting settings + */ +@ObjectClassDefinition(name = "EToolbox Authoring Kit - Relay") +public @interface RelayConfig { + + /** + * Gets whether this relay configuration is active + * @return True or false + */ + @AttributeDefinition( + name = "Enable", + description = "Is the relay enabled?", + type = AttributeType.BOOLEAN + ) + boolean enabled(); + + /** + * Gets the list of JCR path mapping rules + * @return A non-null array of path mapping rule strings; might be empty + */ + @AttributeDefinition( + name = "Path mappings", + description = "List of path mapping rules." + ) + String[] pathMappings(); + + /** + * Gets the optional list of user mapping rules + * @return A non-null array of user mapping rule strings; might be empty + */ + @AttributeDefinition( + name = "User mappings", + description = "List of user mapping rules (optional). " + + "For mapping targets, we support registered subservice names with optional \"@\" suffix." + ) + String[] userMappings(); + + /** + * Gets the optional list of JCR paths or XPath expressions whose matching resources are reported as changed + * @return A non-null array of path or XPath expression strings; might be empty + */ + @AttributeDefinition( + name = "Announce changes", + description = "Optional list of JCR paths or XPath expressions. " + + "Target paths matching the list will be announced as changed as the relay is enabled or disabled." + ) + String[] announcedPaths(); +} + diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java new file mode 100644 index 000000000..4d6c0019f --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java @@ -0,0 +1,225 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.services; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.observation.ExternalResourceChangeListener; +import org.apache.sling.api.resource.observation.ResourceChange; +import org.apache.sling.api.resource.observation.ResourceChangeListener; +import org.apache.sling.spi.resource.provider.ProviderContext; +import org.apache.sling.spi.resource.provider.ResolveContext; +import org.apache.sling.spi.resource.provider.ResourceContext; +import org.apache.sling.spi.resource.provider.ResourceProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.exadel.aem.toolkit.core.relay.models.RelayInfo; +import com.exadel.aem.toolkit.core.relay.models.RelayResource; +import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; +import com.exadel.aem.toolkit.core.relay.utils.RelayResourceHelper; + +/** + * A Sling {@link ResourceProvider} and {@link ResourceChangeListener} that transparently relays resource + * resolution and change notifications from a source JCR path to a configurable target path. Optionally maps + * the resource resolver user identity to a different user or service account + */ +class RelayProvider extends ResourceProvider implements ResourceChangeListener, ExternalResourceChangeListener { + + private static final Logger LOG = LoggerFactory.getLogger(RelayProvider.class); + + private final ResourceResolverFactory resolverFactory; + private volatile RelayInfo relay; + private volatile PathSampler sampler; + + /** + * Creates a new {@code RelayProvider} instance for the given relay model + * @param resolverFactory The {@link ResourceResolverFactory} instance used to create mapped resource resolvers for + * change sampling and user identity mapping + * @param relay The {@link RelayInfo} model containing the configuration for this provider + */ + RelayProvider(ResourceResolverFactory resolverFactory, RelayInfo relay) { + this.resolverFactory = resolverFactory; + update(relay); + } + + /* ----------------- + Fields assignment + ----------------- */ + + /** + * Updates the configuration of this provider based on the given relay model. This method is called when the OSGi + * component is activated or its configuration is updated to apply the new configuration to this provider instance + * @param model The {@link RelayInfo} model containing the new configuration for this provider + */ + synchronized void update(RelayInfo model) { + relay = model; + sampler = PathSampler + .builder() + .resolverFactory(resolverFactory) + .source(model.getSource()) + .target(model.getTarget()) + .samples(model.getChangeSamples()) + .build(); + } + + /* ------------------------ + ResourceProvider members + ------------------------ */ + + /** + * {@inheritDoc} + */ + @Override + public Resource getResource( + @Nonnull ResolveContext context, + @Nonnull String path, + @Nonnull ResourceContext resourceContext, + @Nullable Resource parent) { + RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method + if (!RelayPathHelper.isSamePathOrSubpath(path, localRelay.getSource())) { + return null; + } + String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); + Resource resolved = RelayResourceHelper.getResource( + context.getResourceResolver(), + resolverFactory, + localRelay.getUserMapping(context.getResourceResolver().getUserID()), + targetPath); + if (resolved != null) { + return new RelayResource(resolved, path); + } + return RelayResourceHelper.getResource(context, path, resourceContext, parent); + } + + /** + * {@inheritDoc} + */ + @Override + @Nullable + public Iterator listChildren(@Nonnull ResolveContext context, @Nonnull Resource parent) { + RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method + String path = parent.getPath(); + String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); + Resource resolved = RelayResourceHelper.getResource( + context.getResourceResolver(), + resolverFactory, + localRelay.getUserMapping(context.getResourceResolver().getUserID()), + targetPath); + if (resolved != null) { + resolved = new RelayResource(resolved, path); + } else { + resolved = RelayResourceHelper.getResource(context, path, ResourceContext.EMPTY_CONTEXT, parent); + } + if (resolved == null) { + return null; + } else if (!(resolved instanceof RelayResource)) { + // We have fallen back to an "original" resource, so we should iterate through it without any mapping + return RelayResourceHelper.listChildren(context, resolved); + } + // This method call exerts the path mapping logic for the children of the target resource + return RelayResourceHelper.listChildren(resolved, parent.getPath()); + } + + /** + * {@inheritDoc} + */ + @Override + public void start(@Nonnull ProviderContext providerContext) { + RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method + LOG.info("Relay provider for {} -> {} is starting", localRelay.getSource(), localRelay.getTarget()); + super.start(providerContext); + sampler = PathSampler + .builder() + .resolverFactory(resolverFactory) + .source(localRelay.getSource()) + .target(localRelay.getTarget()) + .samples(localRelay.getChangeSamples()) + .build(); + announce(sampler, providerContext); + } + + /** + * {@inheritDoc} + */ + @Override + public void stop() { + RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method + LOG.info("Relay provider for {} -> {} is stopping", localRelay.getSource(), localRelay.getTarget()); + if (sampler != null && getProviderContext() != null) { + announce(sampler, getProviderContext()); + } + sampler = null; + super.stop(); + } + + /* ------------------------------ + ResourceChangeListener members + ------------------------------ */ + + /** + * {@inheritDoc} + */ + @Override + public void onChange(@Nonnull List changes) { + if (getProviderContext() == null) { + return; + } + RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method + List mappedChanges = changes.stream() + .map(change -> new ResourceChange( + change.getType(), + RelayPathHelper.replace(change.getPath(), localRelay.getTarget(), localRelay.getSource()), + change.isExternal())) + .collect(Collectors.toList()); + getProviderContext().getObservationReporter().reportChanges(mappedChanges, false); + } + + /* ------------- + Announcements + ------------- */ + + /** + * Announces the configured change samples as resource changes, generally to indicate that the relay is changing + * state and trigger any necessary updates in the system + */ + void announce() { + PathSampler localSampler = sampler; + if (localSampler != null && getProviderContext() != null) { + announce(localSampler, getProviderContext()); + } + } + + /** + * Generates resource changes based on the configured change samples and reports them through the observation + * reporter + * @param sampler The {@link PathSampler} instance used to generate the resource changes to report + * @param providerContext The {@link ProviderContext} instance used to access the observation reporter for reporting + * the generated changes + */ + private static void announce(PathSampler sampler, ProviderContext providerContext) { + Collection changes = sampler.createChanges(); + if (!changes.isEmpty()) { + providerContext.getObservationReporter().reportChanges(changes, false); + } + } +} + diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java new file mode 100644 index 000000000..9c3d94877 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java @@ -0,0 +1,265 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.services; + +import java.util.Collections; +import java.util.Comparator; +import java.util.Dictionary; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Hashtable; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.observation.ExternalResourceChangeListener; +import org.apache.sling.api.resource.observation.ResourceChangeListener; +import org.apache.sling.spi.resource.provider.ResourceProvider; +import org.osgi.framework.BundleContext; +import org.osgi.framework.InvalidSyntaxException; +import org.osgi.framework.ServiceReference; +import org.osgi.framework.ServiceRegistration; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.Designate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.exadel.aem.toolkit.core.CoreConstants; +import com.exadel.aem.toolkit.core.relay.models.ChangeSample; +import com.exadel.aem.toolkit.core.relay.models.RelayInfo; +import com.exadel.aem.toolkit.core.relay.models.RelayMapping; +import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; +import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; + +/** + * Handles {@link RelayConfig} factory configurations to create and register {@link RelayProvider} instances at the + * configured source paths. + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +@Component(immediate = true, service = RelayProviderHost.class) +@Designate(ocd = RelayConfig.class, factory = true) +public class RelayProviderHost { + + private static final Logger LOG = LoggerFactory.getLogger(RelayProviderHost.class); + + @Reference + private transient ResourceResolverFactory resolverFactory; + + private final Map, RelayProvider> registrations = new HashMap<>(); + + private final ReentrantLock lock = new ReentrantLock(); + + /** + * Called by the OSGi container when this component is activated or its configuration is updated to register + * {@link RelayProvider} instances for each valid path mapping + * @param context OSGi {@link BundleContext} used to register provider services + * @param config {@link RelayConfig} instance containing the current configuration + */ + @Activate + private void activate(BundleContext context, RelayConfig config) { + List relays = parseConfig(config); + lock.lock(); + try { + Iterator> iterator = registrations.keySet().iterator(); + while (iterator.hasNext()) { + ServiceRegistration registration = iterator.next(); + String existingRoot = (String) registration + .getReference() + .getProperty(ResourceProvider.PROPERTY_ROOT); + RelayInfo matchingRelay = relays.stream() + .filter(relay -> StringUtils.equals(relay.getSource(), existingRoot)) + .findFirst() + .orElse(null); + boolean shouldUnregister = false; + if (matchingRelay != null) { + String[] existingObservedPaths = (String[]) registration.getReference().getProperty(ResourceChangeListener.PATHS); + String existingTarget = ArrayUtils.isNotEmpty(existingObservedPaths) ? existingObservedPaths[0] : null; + if (StringUtils.equals(existingTarget, matchingRelay.getTarget())) { + RelayProvider provider = registrations.get(registration); + provider.announce(); + provider.update(matchingRelay); + provider.announce(); + relays.remove(matchingRelay); + } else { + shouldUnregister = true; + } + } else { + shouldUnregister = true; + } + if (shouldUnregister) { + try { + registration.unregister(); + iterator.remove(); + } catch (IllegalStateException e) { + LOG.warn("Could not unregister relay provider for path {}", existingRoot, e); + } + } + } + if (relays.isEmpty()) { + return; + } + + // Register providers for the remaining new relays that didn't match any existing ones + Map providedPaths = getAlreadyProvidedPaths(context); + for (RelayInfo relay : relays) { + String shadowedEntries = providedPaths.entrySet().stream() + .filter(e -> RelayPathHelper.isSamePathOrSubpath(e.getKey(), relay.getSource())) + .map(e -> e.getKey() + " by " + e.getValue()) + .collect(Collectors.joining(CoreConstants.SEPARATOR_COMMA + StringUtils.SPACE)); + if (!shadowedEntries.isEmpty()) { + LOG.warn( + "Skipping relay registration for path {} to avoid shadowing {}", + relay.getSource(), + shadowedEntries); + continue; + } + RelayProvider provider = new RelayProvider(resolverFactory, relay); + Dictionary properties = new Hashtable<>(); + properties.put(ResourceProvider.PROPERTY_ROOT, relay.getSource()); + properties.put(ResourceChangeListener.PATHS, new String[]{relay.getTarget()}); + ServiceRegistration registration = context.registerService( + new String[]{ + ResourceProvider.class.getName(), + ResourceChangeListener.class.getName(), + ExternalResourceChangeListener.class.getName()}, + provider, + properties); + registrations.put(registration, provider); + // Add newly registered path to providedPaths to prevent overlap with remaining relays + providedPaths.put(StringUtils.stripEnd(relay.getSource(), CoreConstants.SEPARATOR_SLASH), "this config"); + } + } finally { + lock.unlock(); + } + } + + /** + * Discards all previously registered {@link RelayProvider} service instances + */ + @Deactivate + private void deactivate() { + lock.lock(); + try { + Iterator> iterator = registrations.keySet().iterator(); + while (iterator.hasNext()) { + ServiceRegistration registration = iterator.next(); + try { + registration.unregister(); + iterator.remove(); + } catch (IllegalStateException e) { + LOG.warn("Could not unregister relay provider", e); + } + } + } finally { + lock.unlock(); + } + } + + /** + * Parses the given {@link RelayConfig} instance to create a list of {@link RelayInfo} models for valid path mappings + * @param config A {@code RelayConfig} instance; expected to be non-null + * @return A list of {@code Relay} models; might be empty but never null + */ + private static List parseConfig(RelayConfig config) { + if (!config.enabled()) { + return Collections.emptyList(); + } + + Set pathMappings = new LinkedHashSet<>(); + for (String mappingSource : ArrayUtils.nullToEmpty(config.pathMappings())) { + RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); + if (mapping != null + && mapping.isValid() + && StringUtils.startsWith(mapping.getFrom(), CoreConstants.SEPARATOR_SLASH) + && StringUtils.startsWith(mapping.getTo(), CoreConstants.SEPARATOR_SLASH)) { + if (pathMappings.contains(mapping)) { + LOG.warn("Skipping duplicate path mapping with source {} and target {}", mapping.getFrom(), mapping.getTo()); + continue; + } + pathMappings.add(mapping); + } + } + if (pathMappings.isEmpty()) { + return Collections.emptyList(); + } + + Set userMappings = new HashSet<>(); + for (String mappingSource : ArrayUtils.nullToEmpty(config.userMappings())) { + RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); + if (mapping != null && mapping.isValid()) { + if (userMappings.contains(mapping)) { + LOG.warn("Skipping duplicate user mapping with source {} and target {}", mapping.getFrom(), mapping.getTo()); + continue; + } + userMappings.add(mapping); + } + } + + Set samples = new HashSet<>(); + for (String sampleSource : ArrayUtils.nullToEmpty(config.announcedPaths())) { + ChangeSample changeSample = ObjectConversionUtil.toObject(sampleSource, ChangeSample.class); + if (changeSample != null && StringUtils.isNotBlank(changeSample.getPath())) { + samples.add(changeSample); + } + } + + return pathMappings.stream() + .sorted(Comparator.comparingInt(m -> m.getFrom().length())) + .map(m -> new RelayInfo(m, userMappings, samples)) + .collect(Collectors.toList()); + } + + /** + * Collects the JCR paths already provided by already registered {@link ResourceProvider} services to avoid + * "shadowing" external providers + * @param context OSGi {@link BundleContext} used to query registered provider services + * @return A map of JCR path-to-provider name entries; might be empty but never null + */ + private static Map getAlreadyProvidedPaths(BundleContext context) { + Map result = new HashMap<>(); + ServiceReference[] serviceReferences = null; + try { + serviceReferences = context.getServiceReferences(ResourceProvider.class.getName(), null); + } catch (InvalidSyntaxException e) { + LOG.warn("Could not collect info on predefined resource providers", e); + } + if (serviceReferences == null) { + return result; + } + for (ServiceReference ref : serviceReferences) { + Object providedPath = ref.getProperty(ResourceProvider.PROPERTY_ROOT); + Object providerId = ref.getProperty(ResourceProvider.PROPERTY_NAME); + if (providerId == null || StringUtils.isEmpty(providerId.toString())) { + providerId = ref.getBundle().getSymbolicName(); + } + String providedPathString = providedPath instanceof String ? (String) providedPath : null; + if (StringUtils.isNotEmpty(providedPathString)) { + result.put(StringUtils.stripEnd(providedPathString, CoreConstants.SEPARATOR_SLASH), providerId.toString()); + } + } + return result; + } +} + diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java new file mode 100644 index 000000000..1f5444e97 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java @@ -0,0 +1,83 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.utils; + +import org.apache.commons.lang3.StringUtils; + +import com.exadel.aem.toolkit.core.CoreConstants; + +/** + * Provides utility methods for JCR path manipulation within the relay infrastructure + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +public class RelayPathHelper { + + /** + * Default (instantiation-blocking) constructor + */ + private RelayPathHelper() { + } + + /** + * Gets whether the provided path is a descendant of the provided root path + * @param path JCR path to check + * @param root Root JCR path to check against + * @return True or false + */ + public static boolean isSamePathOrSubpath(String path, String root) { + if (StringUtils.isAllEmpty(path, root)) { + return true; + } + String normalizedPath = normalize(path); + String normalizedRoot = normalize(root); + return StringUtils.equals(normalizedPath, normalizedRoot) + || StringUtils.startsWith(normalizedPath, normalizedRoot + CoreConstants.SEPARATOR_SLASH); + } + + /** + * Replaces the {@code source} prefix in the provided path with the {@code target} prefix. Returns the original + * path unchanged if it does not start with {@code source} + * @param path JCR path to transform + * @param source Prefix to replace + * @param target Replacement prefix + * @return A transformed path string + */ + public static String replace(String path, String source, String target) { + String normalizedPath = normalize(path); + String normalizedSource = normalize(source); + String normalizedTarget = normalize(target); + if (StringUtils.equals(normalizedPath, normalizedSource)) { + return normalizedTarget; + } + if (StringUtils.startsWith(normalizedPath, normalizedSource + CoreConstants.SEPARATOR_SLASH)) { + return normalizedTarget + normalizedPath.substring(normalizedSource.length()); + } + return normalizedPath; + } + + /** + * Normalizes the provided path by stripping trailing slashes and replacing an empty result with a single slash + * @param path JCR path to normalize + * @return A normalized path string + */ + private static String normalize(String path) { + if (StringUtils.isBlank(path)) { + return path; + } + return StringUtils.defaultIfEmpty( + StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH), + CoreConstants.SEPARATOR_SLASH); + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java new file mode 100644 index 000000000..ee2aa9916 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java @@ -0,0 +1,187 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.utils; + +import java.util.Iterator; +import java.util.Spliterators; +import java.util.stream.StreamSupport; +import javax.annotation.Nonnull; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.spi.resource.provider.ResolveContext; +import org.apache.sling.spi.resource.provider.ResourceContext; +import org.apache.sling.spi.resource.provider.ResourceProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.exadel.aem.toolkit.core.CoreConstants; +import com.exadel.aem.toolkit.core.relay.models.RelayResource; +import com.exadel.aem.toolkit.core.utils.ResolverUtil; + +/** + * Provides utility methods for resolving and listing Sling resources within the relay infrastructure + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +public class RelayResourceHelper { + + private static final Logger LOG = LoggerFactory.getLogger(RelayResourceHelper.class); + + /** + * Default (instantiation-blocking) constructor + */ + private RelayResourceHelper() {} + + /** + * Resolves a resource at the provided path using a potentially modified {@link ResourceResolver}. Returns the + * resolved resource or {@code null} when the path cannot be resolved + * @param resolver The base {@code ResourceResolver} instance used for resolution + * @param resolverFactory The {@link ResourceResolverFactory} instance used to create subsidiary resolvers if + * needed + * @param userId A nullable user ID to impersonate when creating a subsidiary resolver + * @param path A non-null JCR path of the resource to resolve + * @return A nullable {@link Resource} instance + */ + public static Resource getResource( + @Nonnull ResourceResolver resolver, + @Nonnull ResourceResolverFactory resolverFactory, + String userId, + @Nonnull String path) { + + ResourceResolver effectiveResolver = resolver; + if (userId != null && !userId.equals(resolver.getUserID())) { + String key = ResourceResolver.class.getName() + CoreConstants.SEPARATOR_AT + userId; + // We are going to create another ResourceResolver for a mapped user ID. We need it to live as long + // as the resource(-s) we have resolved with it live. To achieve that, we put it into the property map + // of the basic ResourceResolver so that it will be automatically closed by Sling. + // See https://sling.apache.org/apidocs/sling12/org/apache/sling/api/resource/ResourceResolver.html#getPropertyMap + Object subsidiaryResolver = resolver + .getPropertyMap() + .computeIfAbsent(key, k -> { + try { + LOG.debug("Creating subsidiary resolver for user {} to resolve {}", userId, path); + return ResolverUtil.newResolver(resolverFactory, userId); + } catch (LoginException e) { + LOG.warn("Failed to create subsidiary resolver for user {} to resolve {}", userId, path); + return new Object(); // A sentinel value to avoid repeated attempts to create a resolver for the same user ID + } + }); + if ((subsidiaryResolver instanceof ResourceResolver)) { + effectiveResolver = (ResourceResolver) subsidiaryResolver; + } else { + LOG.debug("Could not resolve {} with user {} due to a user mapping/login failure", path, userId); + return null; + } + } + Resource result = effectiveResolver.getResource(path); + if (result == null) { + LOG.debug("Could not retrieve {} with user {}", path, effectiveResolver.getUserID()); + } else { + LOG.debug("Retrieved {} -> {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); + } + return result; + } + + /** + * Delegates resource resolution for the provided path to a parent {@link ResourceProvider} obtained from the given + * {@link ResolveContext}. This is generally used as a fallback method for + * {@link #getResource(ResourceResolver, ResourceResolverFactory, String, String)} + * @param resolveContext {@link ResolveContext} from which the parent provider and parent context are extracted + * @param path JCR path of the resource to resolve + * @param resourceContext {@link ResourceContext} for the resolution request + * @param parent Nullable parent {@link Resource} + * @return A nullable {@link Resource} resolved by the parent provider, or {@code null} if the context or parent + * provider is missing + */ + @SuppressWarnings("unchecked") + public static Resource getResource( + ResolveContext resolveContext, + String path, + ResourceContext resourceContext, + Resource parent) { + reportFallingBack(path); + if (resolveContext == null) { + reportMissingContext(path); + return null; + } + ResourceProvider parentResourceProvider = resolveContext.getParentResourceProvider(); + ResolveContext parentContext = resolveContext.getParentResolveContext(); + if (parentResourceProvider == null || parentContext == null) { + reportMissingContext(path); + return null; + } + return ((ResourceProvider) parentResourceProvider).getResource((ResolveContext) parentContext, path, resourceContext, parent); + } + + /** + * Lists children of the provided target resource, wrapping each in a {@link RelayResource} with a path + * relative to the given path prefix + * @param target {@link Resource} whose children to list + * @param path JCR path under which the children should be exposed + * @return A non-null {@code Iterator} of {@link Resource} instances + */ + public static Iterator listChildren(Resource target, String path) { + Resource effectiveTarget = target instanceof RelayResource ? ((RelayResource) target).getResource() : target; + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(effectiveTarget.listChildren(), 0), false) + .map(child -> new RelayResource(child, path + CoreConstants.SEPARATOR_SLASH + child.getName())) + .map(Resource.class::cast) + .iterator(); + } + + /** + * Delegates child listing for the provided parent resource to a parent {@link ResourceProvider} obtained from the + * given {@link ResolveContext}. This is generally used as a fallback method for listing children of a resource that + * has not been relayed (is "original") + * @param resolveContext {@link ResolveContext} from which the parent provider and parent context are extracted + * @param parent Parent {@link Resource} whose children to list + * @return A nullable {@code Iterator} of child {@link Resource} instances, or {@code null} if the context or parent + * provider is missing + */ + @SuppressWarnings("unchecked") + public static Iterator listChildren( + ResolveContext resolveContext, + Resource parent) { + reportFallingBack(parent.getPath()); + if (resolveContext == null) { + reportMissingContext(parent.getPath()); + return null; + } + ResourceProvider parentResourceProvider = resolveContext.getParentResourceProvider(); + ResolveContext parentContext = resolveContext.getParentResolveContext(); + if (parentResourceProvider == null || parentContext == null) { + reportMissingContext(parent.getPath()); + return null; + } + return ((ResourceProvider) parentResourceProvider).listChildren((ResolveContext) parentContext, parent); + } + + /** + * Logs a warning when the resource provider or resolve context is missing for the given path + * @param path JCR path that could not be resolved + */ + private static void reportMissingContext(String path) { + LOG.warn("Missing resolution context for {}", path); + } + + /** + * Logs a debug message when falling back to the parent resource provider for the given path + * @param path JCR path that is being resolved + */ + private static void reportFallingBack(String path) { + LOG.debug("Falling back to parent resource provider for {}", path); + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java index 390a7c458..8ec352b6b 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java @@ -76,6 +76,25 @@ public static boolean isJson(String value) { } } + /** + * Parses the provided string value that expectedly represents a JSON into an object of given {@code type} + * @param value String value; a non-null string is expected + * @param type Class of the target object + * @param Type of the target object + * @return An instance of the target type, or null if conversion fails + */ + public static T toObject(String value, Class type) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return OBJECT_MAPPER.readValue(value, type); + } catch (IOException e) { + LOG.error(EXCEPTION_COULD_NOT_READ, e); + return null; + } + } + /** * Analyzes the structure of the given Java entity and converts its available methods and accessors into the * property map. The method reveals the same fields and methods that the {@code Jackson} serializer would find @@ -87,8 +106,8 @@ public static Map toPropertyMap(Object value) { return OBJECT_MAPPER.convertValue(value, PROPERTY_MAP_REFERENCE); } catch (IllegalArgumentException e) { LOG.error(EXCEPTION_COULD_NOT_READ, e); + return Collections.emptyMap(); } - return Collections.emptyMap(); } /** diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java new file mode 100644 index 000000000..d23f77a2a --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java @@ -0,0 +1,168 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.utils; + +import java.io.Closeable; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import javax.annotation.Nonnull; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.scripting.SlingBindings; +import org.apache.sling.api.scripting.SlingScriptHelper; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; + +import com.exadel.aem.toolkit.core.CoreConstants; + +/** + * Provides utility methods for creating {@link ResourceResolver} instances + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own code

+ */ +public class ResolverUtil { + + public static final String SERVICE_USER_ID = "eak-service"; + + /** + * Default (instantiation-restricting) constructor + */ + private ResolverUtil() { + } + + /** + * Creates a new {@link ResourceResolver} instance using the provided resource resolver factory + * @param factory The {@code ResourceResolverFactory} instance + * @return New instance of {@code ResourceResolver} + * @throws LoginException If the resolver cannot be created + */ + @Nonnull + public static ResourceResolver newResolver(@Nonnull ResourceResolverFactory factory) throws LoginException { + return factory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_ID) + ); + } + + /** + * Creates a new {@link ResourceResolver} instance for the given user identifier using the provided resource + * resolver factory + * @param factory The {@code ResourceResolverFactory} instance + * @param user The user identifier string + * @return New instance of {@code ResourceResolver} + * @throws LoginException If the resolver cannot be created + */ + @Nonnull + public static ResourceResolver newResolver( + @Nonnull ResourceResolverFactory factory, + String user) throws LoginException { + + Bundle bundle = FrameworkUtil.getBundle(ResolverUtil.class); + BundleContext context = bundle != null ? bundle.getBundleContext() : null; + return newResolver(factory, user, context); + } + + /** + * Creates a new {@link ResourceResolver} instance for the given user identifier using the provided resource + * resolver factory and bundle context + * @param factory The {@code ResourceResolverFactory} instance + * @param user The user identifier string + * @param context The {@code BundleContext} instance to use for locating the target bundle + * @return New instance of {@code ResourceResolver} + * @throws LoginException If the resolver cannot be created + */ + static ResourceResolver newResolver( + @Nonnull ResourceResolverFactory factory, + String user, + BundleContext context) throws LoginException { + if (StringUtils.isBlank(user)) { + return newResolver(factory); // Use the default eak-service resolver + } + + if (!StringUtils.contains(user, CoreConstants.SEPARATOR_AT)) { + return factory.getServiceResourceResolver(Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, user)); + } + + String localizedUserId = StringUtils.substringBefore(user, CoreConstants.SEPARATOR_AT); + String bundleId = StringUtils.substringAfter(user, CoreConstants.SEPARATOR_AT); + if (context == null) { + throw new LoginException("Not running in an OSGi container"); + } + Bundle targetBundle = Arrays + .stream(context.getBundles()) + .filter(b -> StringUtils.equals(b.getSymbolicName(), bundleId)) + .findFirst() + .orElse(null); + if (targetBundle == null) { + throw new LoginException("Could not locate required bundle: " + bundleId); + } else if (targetBundle.getBundleContext() == null) { + throw new LoginException("Bundle " + bundleId + " is not active"); + } + + AtomicReference nestedException = new AtomicReference<>(); + ResourceResolver resolverByTargetBundle = ServiceUtil.withService( + ResourceResolverFactory.class, + targetBundle.getBundleContext(), + (f, callback) -> { + try { + ResourceResolver resolver = f.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, localizedUserId) + ); + // By adding the callback to the resolver's property map, we ensure that the obtained service + // will be unget no sooner than the resolver is closed + // See https://sling.apache.org/apidocs/sling12/org/apache/sling/api/resource/ResourceResolver.html#getPropertyMap + resolver.getPropertyMap().put("unget", (Closeable) callback::run); + return resolver; + } catch (LoginException e) { + nestedException.set(e); + callback.run(); + return null; + } + }, + null); + if (nestedException.get() != null) { + throw nestedException.get(); + } else if (resolverByTargetBundle == null) { + throw new LoginException("No resource resolver created. See above log for details"); + } + return resolverByTargetBundle; + } + + /** + * Creates a new {@link ResourceResolver} instance for the given username using the resource resolver factory + * obtained from the provided request's Sling bindings + * @param request A {@link SlingHttpServletRequest} instance + * @return New instance of {@code ResourceResolver} + * @throws LoginException If the resolver cannot be created + */ + @Nonnull + public static ResourceResolver newResolver(@Nonnull SlingHttpServletRequest request) throws LoginException { + + SlingBindings bindings = (SlingBindings) request.getAttribute(SlingBindings.class.getName()); + SlingScriptHelper scriptHelper = bindings != null ? (SlingScriptHelper) bindings.get(SlingBindings.SLING) : null; + ResourceResolverFactory factory = scriptHelper != null + ? scriptHelper.getService(ResourceResolverFactory.class) + : null; + if (factory == null) { + throw new LoginException("Could not obtain ResourceResolverFactory"); + } + return factory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_ID) + ); + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java new file mode 100644 index 000000000..7217cfe7a --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java @@ -0,0 +1,290 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.utils; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.annotation.Nonnull; + +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.ServiceReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides utility methods for working with OSGi services outside the usual component injection context + *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own + * code

+ */ +@SuppressWarnings("LoggingSimilarMessage") +public class ServiceUtil { + + private static final Logger LOG = LoggerFactory.getLogger(ServiceUtil.class); + + private static final String ERROR_CONTEXT = "Could not obtain OSGi bundle context for {}"; + private static final String ERROR_HANDLING = "Error handling a service reference for {}"; + private static final String ERROR_RETRIEVAL = "Could not retrieve instance of {}"; + private static final String ERROR_RUNNING = "Error running a method against {}"; + + /** + * Default (instantiation-blocking) constructor + */ + private ServiceUtil() { + } + + /* -------------------------------------- + Consumer-style methods (non-returning) + -------------------------------------- */ + + /** + * Executes the provided routine with an instance of the specified service class, if available. The service is + * automatically obtained and released after the consumer is executed + * @param serviceClass The class of the service to be used + * @param consumer The routine to be executed with the service instance + * @param The type of the service + */ + public static void withService( + @Nonnull Class serviceClass, + @Nonnull Consumer consumer) { + Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class); + BundleContext context = bundle != null ? bundle.getBundleContext() : null; + if (context == null) { + // Absence of bundle is generally unlikely + LOG.debug(ERROR_CONTEXT, serviceClass.getName()); + return; + } + withService(serviceClass, context, consumer); + } + + /** + * Executes the provided routine with an instance of the specified service class, if available. The service is + * automatically obtained and released after the consumer is executed + * @param serviceClass The class of the service to be used + * @param context The OSGi bundle context to be used for service retrieval + * @param consumer The routine to be executed with the service instance + * @param The type of the service + */ + public static void withService( + @Nonnull Class serviceClass, + @Nonnull BundleContext context, + @Nonnull Consumer consumer) { + + ServiceReference reference = null; + try { + reference = context.getServiceReference(serviceClass); + T service = reference != null ? context.getService(reference) : null; + if (service == null) { + // This is an anticipated case because a service might not be registered due to a particular config, + // e.g., a ConfigChangeListener + LOG.debug(ERROR_RETRIEVAL, serviceClass.getName()); + return; + } + try { + consumer.accept(service); + } catch (RuntimeException e) { + LOG.error(ERROR_RUNNING, serviceClass.getName(), e); + } + } catch (IllegalArgumentException | IllegalStateException e) { + LOG.error(ERROR_HANDLING, serviceClass.getName(), e); + } finally { + ungetService(context, reference); + } + } + + /** + * Executes the provided routine with an instance of the specified service class, if available. The service is + * automatically obtained. It is released when the client code triggers the provided callback + * @param serviceClass The class of the service to be used + * @param context The OSGi bundle context to be used for service retrieval + * @param consumer The routine to be executed with the service instance. The second argument is a callback to + * trigger service release + * @param The type of the service + */ + public static void withService( + @Nonnull Class serviceClass, + @Nonnull BundleContext context, + @Nonnull BiConsumer consumer) { + + try { + ServiceReference reference = context.getServiceReference(serviceClass); + T service = reference != null ? context.getService(reference) : null; + if (service == null) { + // This is an anticipated case because a service might not be registered due to a particular config, + // e.g., a ConfigChangeListener + LOG.debug(ERROR_RETRIEVAL, serviceClass.getName()); + return; + } + AtomicBoolean alreadyReleased = new AtomicBoolean(false); + try { + consumer.accept( + service, + () -> { + alreadyReleased.set(true); + ungetService(context, reference); + }); + } catch (RuntimeException e) { + LOG.error(ERROR_RUNNING, serviceClass.getName(), e); + if (!alreadyReleased.get()) { + ungetService(context, reference); + } + } + } catch (IllegalArgumentException | IllegalStateException e) { + LOG.error(ERROR_HANDLING, serviceClass.getName(), e); + } + } + + /* ---------------------- + Function-style methods + ---------------------- */ + + /** + * Executes the provided routine with an instance of the specified service class to get a result, if available. The + * service is automatically obtained and released after the processor is executed + * @param serviceClass The class of the service to be used + * @param processor The routine to compute the result with the service instance + * @param defaultValue The value to be returned if the service is not available or an error occurs + * @param The type of the service + * @param The type of the result + * @return The result of the processor execution, or the default value + */ + public static U withService( + @Nonnull Class serviceClass, + @Nonnull Function processor, + U defaultValue) { + Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class); + BundleContext context = bundle != null ? bundle.getBundleContext() : null; + if (context == null) { + // Absence of bundle is generally unlikely + LOG.debug(ERROR_CONTEXT, serviceClass.getName()); + return defaultValue; + } + return withService(serviceClass, context, processor, defaultValue); + } + + /** + * Executes the provided routine with an instance of the specified service class to get a result, if available. The + * service is automatically obtained and released after the processor is executed + * @param serviceClass The class of the service to be used + * @param context The OSGi bundle context to be used for service retrieval + * @param processor The routine to compute the result with the service instance + * @param defaultValue The value to be returned if the service is not available or an error occurs + * @param The type of the service + * @param The type of the result + * @return The result of the processor execution, or the default value + */ + public static U withService( + @Nonnull Class serviceClass, + @Nonnull BundleContext context, + @Nonnull Function processor, + U defaultValue) { + + U result = defaultValue; + ServiceReference reference = null; + try { + reference = context.getServiceReference(serviceClass); + T service = reference != null ? context.getService(reference) : null; + if (service == null) { + // This is an anticipated case because a service might not be registered due to a particular config, + // e.g., a ConfigChangeListener + LOG.debug(ERROR_RETRIEVAL, serviceClass.getName()); + return defaultValue; + } + try { + result = processor.apply(service); + } catch (RuntimeException e) { + LOG.error(ERROR_RUNNING, serviceClass.getName(), e); + return defaultValue; + } + } catch (IllegalArgumentException | IllegalStateException e) { + LOG.error(ERROR_HANDLING, serviceClass.getName(), e); + } finally { + ungetService(context, reference); + } + return result; + } + + /** + * Executes the provided routine with an instance of the specified service class to get a result, if available. The + * service is automatically obtained. It is released when the client code triggers the provided callback + * @param serviceClass The class of the service to be used + * @param context The OSGi bundle context to be used for service retrieval + * @param processor The routine to compute the result with the service instance. The second argument is a + * callback to trigger service release + * @param defaultValue The value to be returned if the service is not available or an error occurs + * @param The type of the service + * @param The type of the result + * @return The result of the processor execution, or the default value + */ + public static U withService( + @Nonnull Class serviceClass, + @Nonnull BundleContext context, + @Nonnull BiFunction processor, + U defaultValue) { + + U result = defaultValue; + try { + ServiceReference reference = context.getServiceReference(serviceClass); + T service = reference != null ? context.getService(reference) : null; + if (service == null) { + // This is an anticipated case because a service might not be registered due to a particular config, + // e.g., a ConfigChangeListener + LOG.debug(ERROR_RETRIEVAL, serviceClass.getName()); + return defaultValue; + } + AtomicBoolean alreadyReleased = new AtomicBoolean(false); + try { + result = processor.apply( + service, + () -> { + alreadyReleased.set(true); + ungetService(context, reference); + }); + } catch (RuntimeException e) { + LOG.error(ERROR_RUNNING, serviceClass.getName(), e); + if (!alreadyReleased.get()) { + ungetService(context, reference); + } + return defaultValue; + } + } catch (IllegalArgumentException | IllegalStateException e) { + LOG.error(ERROR_HANDLING, serviceClass.getName(), e); + } + return result; + } + + /* --------------- + Utility methods + --------------- */ + + /** + * Safely ungets the provided service reference from the context, suppressing any exceptions that might occur + * @param context The OSGi bundle context to be used for service ungetting + * @param reference The service reference to be ungotten + */ + private static void ungetService(BundleContext context, ServiceReference reference) { + if (reference == null) { + return; + } + try { + context.ungetService(reference); + } catch (IllegalArgumentException | IllegalStateException e) { + LOG.warn(ERROR_HANDLING, reference, e); + } + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/AllTests.java b/core/src/test/java/com/exadel/aem/toolkit/AllTests.java deleted file mode 100644 index 865b75bde..000000000 --- a/core/src/test/java/com/exadel/aem/toolkit/AllTests.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.exadel.aem.toolkit; - -import org.junit.runner.RunWith; -import org.junit.runners.Suite; -import org.junit.runners.Suite.SuiteClasses; - -import com.exadel.aem.toolkit.api.annotations.meta.StringTransformationTest; -import com.exadel.aem.toolkit.core.configurator.models.internal.ConfigDefinitionTest; -import com.exadel.aem.toolkit.core.configurator.models.internal.RenderConditionTest; -import com.exadel.aem.toolkit.core.configurator.services.ConfigChangeListenerTest; -import com.exadel.aem.toolkit.core.configurator.services.ConfigDataUtilTest; -import com.exadel.aem.toolkit.core.configurator.servlets.form.ConfigDataSourceTest; -import com.exadel.aem.toolkit.core.configurator.servlets.form.FieldUtilTest; -import com.exadel.aem.toolkit.core.configurator.servlets.form.ValueUtilTest; -import com.exadel.aem.toolkit.core.configurator.servlets.replication.ReplicationServletTest; -import com.exadel.aem.toolkit.core.configurator.utils.PermissionUtilTest; -import com.exadel.aem.toolkit.core.injectors.ChildInjectorTest; -import com.exadel.aem.toolkit.core.injectors.ChildrenInjectorTest; -import com.exadel.aem.toolkit.core.injectors.EToolboxListInjectorTest; -import com.exadel.aem.toolkit.core.injectors.EnumValueInjectorTest; -import com.exadel.aem.toolkit.core.injectors.I18nInjectorTest; -import com.exadel.aem.toolkit.core.injectors.RequestAttributeInjectorTest; -import com.exadel.aem.toolkit.core.injectors.RequestParamInjectorTest; -import com.exadel.aem.toolkit.core.injectors.RequestSelectorsInjectorTest; -import com.exadel.aem.toolkit.core.injectors.RequestSuffixInjectorTest; -import com.exadel.aem.toolkit.core.injectors.utils.FilteredResourceDecoratorTest; -import com.exadel.aem.toolkit.core.lists.models.ListItemTest; -import com.exadel.aem.toolkit.core.lists.servlets.ItemComponentsServletTest; -import com.exadel.aem.toolkit.core.lists.servlets.ListsServletTest; -import com.exadel.aem.toolkit.core.lists.utils.ListHelperTest; -import com.exadel.aem.toolkit.core.lists.utils.ListPageUtilTest; -import com.exadel.aem.toolkit.core.lists.utils.ListResourceUtilTest; -import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderConstantsTest; -import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderEnumsTest; -import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderHttpTest; -import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderInlineOptionsTest; -import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderTest; -import com.exadel.aem.toolkit.core.policymanagement.filters.TopLevelPolicyFilterTest; - -/** - * Shortcut class for running all available test cases in a batch - */ -@RunWith(Suite.class) -@SuiteClasses({ - StringTransformationTest.class, - - ListHelperTest.class, - ListPageUtilTest.class, - ListResourceUtilTest.class, - - ListsServletTest.class, - ListItemTest.class, - ItemComponentsServletTest.class, - - RequestAttributeInjectorTest.class, - RequestParamInjectorTest.class, - RequestSelectorsInjectorTest.class, - RequestSuffixInjectorTest.class, - I18nInjectorTest.class, - EToolboxListInjectorTest.class, - ChildInjectorTest.class, - ChildrenInjectorTest.class, - EnumValueInjectorTest.class, - FilteredResourceDecoratorTest.class, - - OptionProviderTest.class, - OptionProviderHttpTest.class, - OptionProviderEnumsTest.class, - OptionProviderConstantsTest.class, - OptionProviderInlineOptionsTest.class, - - ConfigChangeListenerTest.class, - ConfigDataSourceTest.class, - ConfigDataUtilTest.class, - ConfigDefinitionTest.class, - ReplicationServletTest.class, - RenderConditionTest.class, - FieldUtilTest.class, - PermissionUtilTest.class, - ValueUtilTest.class, - - TopLevelPolicyFilterTest.class -}) -public class AllTests { -} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java index ea9f8d5f3..9a90237f8 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java +++ b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java @@ -324,7 +324,7 @@ private void createAndInvokeRenderCondition(String feature) { "etoolbox-authoring-kit/configurator/components/rendercondition", valueMap); context.request().setResource(resource); - RenderCondition model = context.request().adaptTo(RenderCondition.class); + context.request().adaptTo(RenderCondition.class); } private void assertRenderCondition(boolean expected) { diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java index f06c7fe72..076e9f7aa 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java +++ b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java @@ -103,19 +103,12 @@ private void setUpBundleContext(String configId) throws IOException { ConfigurationAdmin mockConfigAdmin = Mockito.mock(ConfigurationAdmin.class); Mockito.when(mockConfigAdmin.getConfiguration(Mockito.eq(configId), Mockito.isNull())).thenReturn(null); - MetaTypeService mockMetaTypeService = Mockito.mock(MetaTypeService.class); - BundleContext mockBundleContext = Mockito.mock(BundleContext.class); @SuppressWarnings("unchecked") ServiceReference mockConfigAdminRef = Mockito.mock(ServiceReference.class); Mockito.when(mockBundleContext.getServiceReference(Mockito.eq(ConfigurationAdmin.class))).thenReturn(mockConfigAdminRef); Mockito.when(mockBundleContext.getService(Mockito.eq(mockConfigAdminRef))).thenReturn(mockConfigAdmin); - @SuppressWarnings("unchecked") - ServiceReference mockMetaTypeServiceRef = Mockito.mock(ServiceReference.class); - Mockito.when(mockBundleContext.getServiceReference(Mockito.eq(MetaTypeService.class))).thenReturn(mockMetaTypeServiceRef); - Mockito.when(mockBundleContext.getService(Mockito.eq(mockMetaTypeServiceRef))).thenReturn(mockMetaTypeService); - context.request().setAttribute(BundleContext.class.getName(), mockBundleContext); } } diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/ChangeSampleTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/ChangeSampleTest.java new file mode 100644 index 000000000..70bab3467 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/ChangeSampleTest.java @@ -0,0 +1,63 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; + +public class ChangeSampleTest { + + private static final String PATH_SAMPLE = "/content/sample"; + + @Test + public void shouldReturnProperties() { + ChangeSample withUser = newChangeSample(PATH_SAMPLE, 10, "author"); + + assertEquals(PATH_SAMPLE, withUser.getPath()); + assertEquals(10, withUser.getLimit()); + assertEquals("author", withUser.getUser()); + + // User can be null + ChangeSample withoutUser = newChangeSample(PATH_SAMPLE, 0, null); + assertNull(withoutUser.getUser()); + } + + @Test + public void shouldBeEqualByPathOnly() { + // Same path, different limit and user → equal + ChangeSample a = newChangeSample(PATH_SAMPLE, 5, "user1"); + ChangeSample b = newChangeSample(PATH_SAMPLE, 99, "user2"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + + // Different path → not equal + assertNotEquals(a, newChangeSample("/content/other", 5, "user1")); + + // Null check + assertNotNull(a); + } + + private static ChangeSample newChangeSample(String path, int limit, String user) { + String userJson = user != null ? "\"" + user + "\"" : "null"; + return ObjectConversionUtil.toObject( + "{\"path\":\"" + path + "\",\"limit\":" + limit + ",\"user\":" + userJson + "}", + ChangeSample.class); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayInfoTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayInfoTest.java new file mode 100644 index 000000000..8beda16e8 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayInfoTest.java @@ -0,0 +1,116 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import java.util.Collection; +import java.util.Collections; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; + +public class RelayInfoTest { + + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_TARGET = "/content/target"; + private static final String PATH_SAMPLE = "/content/sample"; + private static final String USER_AUTHOR = "author"; + private static final String USER_ADMIN = "admin"; + + @Test + public void shouldReturnSourceAndTarget() { + RelayInfo relay = newRelayInfo(PATH_SOURCE, PATH_TARGET); + + assertEquals(PATH_SOURCE, relay.getSource()); + assertEquals(PATH_TARGET, relay.getTarget()); + + // Null from/to in path mapping defaults to empty string + RelayInfo withNulls = new RelayInfo( + newMapping(null, null), + Collections.emptyList(), + Collections.emptyList()); + assertEquals(StringUtils.EMPTY, withNulls.getSource()); + assertEquals(StringUtils.EMPTY, withNulls.getTarget()); + } + + @Test + public void shouldLookUpUserMapping() { + RelayInfo relay = new RelayInfo( + newMapping(PATH_SOURCE, PATH_TARGET), + Collections.singletonList(newMapping(USER_AUTHOR, USER_ADMIN)), + Collections.emptyList()); + + assertEquals(USER_ADMIN, relay.getUserMapping(USER_AUTHOR)); + assertNull(relay.getUserMapping("unknown")); + + // Empty or null user mappings → always null + RelayInfo noMappings = newRelayInfo(PATH_SOURCE, PATH_TARGET); + assertNull(noMappings.getUserMapping(USER_AUTHOR)); + + RelayInfo nullMappings = new RelayInfo( + newMapping(PATH_SOURCE, PATH_TARGET), + null, + Collections.emptyList()); + assertNull(nullMappings.getUserMapping(USER_AUTHOR)); + } + + @Test + public void shouldReturnChangeSamples() { + ChangeSample sample = newChangeSample(PATH_SAMPLE); + RelayInfo relay = new RelayInfo( + newMapping(PATH_SOURCE, PATH_TARGET), + Collections.emptyList(), + Collections.singletonList(sample)); + + Collection samples = relay.getChangeSamples(); + assertEquals(1, samples.size()); + assertEquals(PATH_SAMPLE, samples.iterator().next().getPath()); + + // The returned collection is unmodifiable + boolean threw = false; + try { + samples.add(newChangeSample(PATH_SAMPLE)); + } catch (UnsupportedOperationException e) { + threw = true; + } + assertTrue(threw); + } + + @SuppressWarnings("SameParameterValue") + private static RelayInfo newRelayInfo(String source, String target) { + return new RelayInfo( + newMapping(source, target), + Collections.emptyList(), + Collections.emptyList()); + } + + private static RelayMapping newMapping(String from, String to) { + String fromJson = from != null ? "\"" + from + "\"" : "null"; + String toJson = to != null ? "\"" + to + "\"" : "null"; + return ObjectConversionUtil.toObject( + "{\"from\":" + fromJson + ",\"to\":" + toJson + "}", + RelayMapping.class); + } + + @SuppressWarnings("SameParameterValue") + private static ChangeSample newChangeSample(String path) { + return ObjectConversionUtil.toObject( + "{\"path\":\"" + path + "\"}", + ChangeSample.class); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java new file mode 100644 index 000000000..e39c2519f --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java @@ -0,0 +1,65 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; + +public class RelayMappingTest { + + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_TARGET = "/content/target"; + private static final String PATH_OTHER = "/content/other"; + + @Test + public void shouldReturnPropertiesAndValidity() { + RelayMapping mapping = newMapping(PATH_SOURCE, PATH_TARGET); + + assertEquals(PATH_SOURCE, mapping.getFrom()); + assertEquals(PATH_TARGET, mapping.getTo()); + assertTrue(mapping.isValid()); + + assertFalse(newMapping(StringUtils.EMPTY, PATH_TARGET).isValid()); + assertFalse(newMapping(PATH_SOURCE, StringUtils.EMPTY).isValid()); + assertFalse(newMapping(null, PATH_TARGET).isValid()); + assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE).isValid()); + assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE + "/child").isValid()); + assertFalse(newMapping(PATH_SOURCE + "/child", PATH_SOURCE).isValid()); + } + + @Test + public void shouldCheckEquality() { + RelayMapping a = newMapping(PATH_SOURCE, PATH_TARGET); + RelayMapping b = newMapping(PATH_SOURCE, PATH_TARGET); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertEquals(a, newMapping(PATH_SOURCE, PATH_OTHER)); + assertNotEquals(a, newMapping(PATH_OTHER, PATH_TARGET)); + } + + private static RelayMapping newMapping(String from, String to) { + String fromJson = from != null ? "\"" + from + "\"" : "null"; + String toJson = to != null ? "\"" + to + "\"" : "null"; + return ObjectConversionUtil.toObject( + "{\"from\":" + fromJson + ",\"to\":" + toJson + "}", + RelayMapping.class); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java new file mode 100644 index 000000000..b5d577e9d --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java @@ -0,0 +1,112 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.models; + +import java.util.Iterator; + +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceMetadata; +import org.junit.Rule; +import org.junit.Test; +import io.wcm.testing.mock.aem.junit.AemContext; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.AemContextFactory; + +public class RelayResourceTest { + + private static final String PATH_TARGET = "/content/target"; + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_CHILD = "/child"; + private static final String PATH_NO_SLASH = "noSlash"; + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + @Test + public void shouldCopyAndOverrideProperties() { + Resource plain = context.create().resource(PATH_TARGET); + RelayResource relay = new RelayResource(plain, PATH_SOURCE); + + assertEquals(PATH_SOURCE, relay.getPath()); + assertSame(plain, relay.getResource()); + assertNotSame(plain.getResourceMetadata(), relay.getResourceMetadata()); + assertEquals(PATH_SOURCE, relay.getResourceMetadata().get(ResourceMetadata.RESOLUTION_PATH)); + } + + @Test + public void shouldGetName() { + Resource plain = context.create().resource(PATH_TARGET); + + assertEquals("source", new RelayResource(plain, PATH_SOURCE).getName()); + // Path without any slash: returned as-is + assertEquals(PATH_NO_SLASH, new RelayResource(plain, PATH_NO_SLASH).getName()); + } + + @Test + public void shouldGetParent() { + final String pathRelayParent = "/relay"; + Resource original = context.create().resource(PATH_TARGET); + context.create().resource(pathRelayParent); + + RelayResource relay = new RelayResource(original, pathRelayParent + "/item"); + Resource parent = relay.getParent(); + assertNotNull(parent); + assertEquals(pathRelayParent, parent.getPath()); + + assertNotNull(new RelayResource(original, "/top").getParent()); + assertNull(new RelayResource(original, PATH_NO_SLASH).getParent()); + assertNull(new RelayResource(original, "/missing/item").getParent()); + } + + @Test + public void shouldGetChild() { + Resource original = context.create().resource(PATH_TARGET); + context.create().resource(PATH_SOURCE + PATH_CHILD); + RelayResource relay = new RelayResource(original, PATH_SOURCE); + + Resource child = relay.getChild("child"); + assertNotNull(child); + assertEquals(PATH_SOURCE + PATH_CHILD, child.getPath()); + + // Relative path with leading/trailing slashes is stripped + Resource childFromSlashed = relay.getChild("/child/"); + assertNotNull(childFromSlashed); + assertEquals(PATH_SOURCE + PATH_CHILD, childFromSlashed.getPath()); + + assertNull(relay.getChild("nonexistent")); + } + + @Test + public void shouldListChildrenWithOverriddenPath() { + Resource parent = context.create().resource(PATH_TARGET); + context.create().resource(PATH_TARGET + PATH_CHILD); + RelayResource relay = new RelayResource(parent, PATH_SOURCE); + + Iterator children = relay.listChildren(); + + assertNotNull(children); + assertTrue(children.hasNext()); + Resource child = children.next(); + assertEquals(PATH_SOURCE + PATH_CHILD, child.getPath()); + assertTrue(child instanceof RelayResource); + assertFalse(children.hasNext()); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java new file mode 100644 index 000000000..cd7a2da8c --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java @@ -0,0 +1,305 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.services; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; +import javax.jcr.Session; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.PersistenceException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.observation.ResourceChange; +import org.apache.sling.testing.mock.sling.ResourceResolverType; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mockito; +import io.wcm.testing.mock.aem.junit.AemContext; +import io.wcm.testing.mock.aem.junit.AemContextBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.relay.models.ChangeSample; +import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; + +public class PathSamplerTest { + + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_TARGET = "/content/target"; + private static final String PATH_CHILD = "/page1"; + + private static final String XPATH_CHILDREN = "/jcr:root/content/target/*"; + private static final String XPATH_INVALID = "//[@invalid syntax"; + + @Rule + public final AemContext context = new AemContextBuilder() + .resourceResolverType(ResourceResolverType.JCR_OAK) + .build(); + + /* ---------------- + JCR path samples + ---------------- */ + + @Test + public void shouldReturnEmptyForNoSamples() { + PathSampler samplerWithNull = PathSampler + .builder() + .source(PATH_SOURCE) + .target(PATH_TARGET) + .build(); + assertTrue(samplerWithNull.createChanges().isEmpty()); + + PathSampler samplerWithEmpty = PathSampler + .builder() + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Collections.emptyList()) + .build(); + assertTrue(samplerWithEmpty.createChanges().isEmpty()); + } + + @Test + public void shouldCreateChangesFromJcrPaths() { + // Single path: emits one CHANGED event + PathSampler singleSampler = PathSampler + .builder() + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Collections.singletonList(newChangeSample(PATH_TARGET + PATH_CHILD))) + .build(); + + Collection singleResult = singleSampler.createChanges(); + assertEquals(1, singleResult.size()); + assertEquals(ResourceChange.ChangeType.CHANGED, singleResult.iterator().next().getType()); + + // Multiple distinct paths: each emits a separate event + PathSampler multiSampler = PathSampler + .builder() + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Arrays.asList( + newChangeSample(PATH_TARGET + "/page1"), + newChangeSample(PATH_TARGET + "/page2"))) + .build(); + assertEquals(2, multiSampler.createChanges().size()); + + // Duplicate path in samples: deduplicated by the internal path set + PathSampler dupSampler = PathSampler.builder() + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Arrays.asList( + newChangeSample(PATH_TARGET + PATH_CHILD), + newChangeSample(PATH_TARGET + PATH_CHILD))) + .build(); + assertEquals(1, dupSampler.createChanges().size()); + } + + @Test + public void shouldRewriteTargetPaths() { + PathSampler sampler = PathSampler + .builder() + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Arrays.asList( + newChangeSample(PATH_TARGET + PATH_CHILD), + newChangeSample("/content/unrelated/page"))) + .build(); + + Collection changes = sampler.createChanges(); + List paths = changes.stream() + .map(ResourceChange::getPath) + .collect(Collectors.toList()); + + assertEquals(2, paths.size()); + // Path under target is rewritten to source prefix + assertTrue(paths.contains(PATH_SOURCE + PATH_CHILD)); + // Path outside target is returned unchanged + assertTrue(paths.contains("/content/unrelated/page")); + } + + /* ---------------- + XPath resolution + ---------------- */ + + @Test + public void shouldResolveXpathExpression() throws LoginException, PersistenceException { + context.create().resource(PATH_TARGET + PATH_CHILD); + context.resourceResolver().commit(); + + PathSampler sampler = PathSampler + .builder() + .resolverFactory(newMockFactory()) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Collections.singletonList(newChangeSample(XPATH_CHILDREN))) + .build(); + + Collection changes = sampler.createChanges(); + + assertEquals(1, changes.size()); + assertEquals(PATH_SOURCE + PATH_CHILD, changes.iterator().next().getPath()); + } + + @Test + public void shouldApplyQueryLimit() throws LoginException, PersistenceException { + for (int i = 0; i < 10; i++) { + context.create().resource(PATH_TARGET + "/node" + i); + } + context.resourceResolver().commit(); + + PathSampler sampler = PathSampler + .builder() + .resolverFactory(newMockFactory()) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Collections.singletonList(newChangeSampleWithLimit(XPATH_CHILDREN, 5))) + .build(); + + Collection changes = sampler.createChanges(); + + assertEquals(5, changes.size()); + } + + @Test + public void shouldSkipXpathOnError() throws LoginException { + // Invalid XPath syntax causes Oak to throw InvalidQueryException (extends RepositoryException) + PathSampler repositoryErrorSampler = PathSampler + .builder() + .resolverFactory(newMockFactory()) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Collections.singletonList(newChangeSample(XPATH_INVALID))) + .build(); + + assertTrue(repositoryErrorSampler.createChanges().isEmpty()); + + // LoginException when obtaining a resolver: sample is skipped + ResourceResolverFactory failingFactory = Mockito.mock(ResourceResolverFactory.class); + Mockito + .when(failingFactory.getServiceResourceResolver(Mockito.any())) + .thenThrow(new LoginException("Service user not found")); + + PathSampler loginErrorSampler = PathSampler + .builder() + .resolverFactory(failingFactory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Collections.singletonList(newChangeSample(XPATH_CHILDREN))) + .build(); + + assertTrue(loginErrorSampler.createChanges().isEmpty()); + } + + /* ----------------- + Resolver rotation + ----------------- */ + + @Test + public void shouldReuseResolverForSameUser() throws LoginException { + ResourceResolver sessionResolver = newMockResolver(); + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(factory.getServiceResourceResolver(Mockito.any())).thenReturn(sessionResolver); + + PathSampler sampler = PathSampler.builder() + .resolverFactory(factory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Arrays.asList( + newChangeSampleWithUser(XPATH_CHILDREN, "author"), + newChangeSampleWithUser("/jcr:root/content//element(*)", "author"))) + .build(); + + sampler.createChanges(); + + // Factory is called once; the same resolver is reused for the second sample + Mockito.verify(factory, Mockito.times(1)).getServiceResourceResolver(Mockito.any()); + } + + @Test + public void shouldRotateResolverOnUserChange() throws LoginException { + ResourceResolver resolverA = newMockResolver(); + ResourceResolver resolverB = newMockResolver(); + + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(factory.getServiceResourceResolver(Mockito.any())) + .thenReturn(resolverA) + .thenReturn(resolverB); + + PathSampler sampler = PathSampler.builder() + .resolverFactory(factory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Arrays.asList( + newChangeSampleWithUser(XPATH_CHILDREN, "user1"), + newChangeSampleWithUser("/jcr:root/content//element(*)", "user2"))) + .build(); + + sampler.createChanges(); + + // Factory is called once per distinct user + Mockito.verify(factory, Mockito.times(2)).getServiceResourceResolver(Mockito.any()); + // The first resolver is closed when the user changes + Mockito.verify(resolverA).close(); + } + + /* --------------- + Utility methods + --------------- */ + + private static ChangeSample newChangeSample(String path) { + return ObjectConversionUtil.toObject( + "{\"path\":\"" + path + "\"}", + ChangeSample.class); + } + + @SuppressWarnings("SameParameterValue") + private static ChangeSample newChangeSampleWithLimit(String path, int limit) { + return ObjectConversionUtil.toObject( + "{\"path\":\"" + path + "\",\"limit\":" + limit + "}", + ChangeSample.class); + } + + private static ChangeSample newChangeSampleWithUser(String path, String user) { + return ObjectConversionUtil.toObject( + "{\"path\":\"" + path + "\",\"user\":\"" + user + "\"}", + ChangeSample.class); + } + + private ResourceResolverFactory newMockFactory() throws LoginException { + // We are not using the built-in resource resolver factory because of lack of support for + // ResourceResolver#getPropertyMap() in the built-in mock resource resolver (dependency version issue). + // Can be revised to use the real factory once the dependency is updated + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + ResourceResolver sessionResolver = newMockResolver(); + Mockito + .when(factory.getServiceResourceResolver(Mockito.any())) + .thenReturn(sessionResolver); + return factory; + } + + private ResourceResolver newMockResolver() { + Session session = context.resourceResolver().adaptTo(Session.class); + assertNotNull(session); + ResourceResolver resolver = Mockito.mock(ResourceResolver.class); + Mockito.when(resolver.adaptTo(Session.class)).thenReturn(session); + Mockito.when(resolver.getPropertyMap()).thenReturn(new HashMap<>()); + return resolver; + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java new file mode 100644 index 000000000..9a323618d --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java @@ -0,0 +1,309 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.services; + +import java.util.Dictionary; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.Iterator; +import java.util.Map; +import javax.annotation.Nonnull; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.observation.ResourceChangeListener; +import org.apache.sling.spi.resource.provider.ResolveContext; +import org.apache.sling.spi.resource.provider.ResourceContext; +import org.apache.sling.spi.resource.provider.ResourceProvider; +import org.apache.sling.testing.mock.osgi.MockOsgi; +import org.junit.Rule; +import org.junit.Test; +import org.osgi.framework.InvalidSyntaxException; +import org.osgi.framework.ServiceReference; +import io.wcm.testing.mock.aem.junit.AemContext; +import static org.junit.Assert.assertEquals; + +import com.exadel.aem.toolkit.core.AemContextFactory; + +public class RelayProviderHostTest { + + private static final String PATH_SOURCE = "/content/source"; + + private static final String PATH_MAPPING_A = "{\"from\":\"/content/source\",\"to\":\"/content/target\"}"; + private static final String PATH_MAPPING_A_NEW_TARGET = "{\"from\":\"/content/source\",\"to\":\"/content/target-updated\"}"; + private static final String PATH_MAPPING_A_NESTED_SOURCE = "{\"from\":\"/content/source\",\"to\":\"/content\"}"; + private static final String PATH_MAPPING_A_NESTED_TARGET = "{\"from\":\"/content/source\",\"to\":\"/content/source/child\"}"; + private static final String PATH_MAPPING_A_DUP_SOURCE = "{\"from\":\"/content/source\",\"to\":\"/content/other\"}"; + + private static final String PATH_MAPPING_B = "{\"from\":\"/content/source2\",\"to\":\"/content/target2\"}"; + private static final String PATH_MAPPING_MISSING_TO = "{\"from\":\"/content/source\"}"; + + private static final String USER_MAPPING_A = "{\"from\":\"admin\",\"to\":\"service-user\"}"; + private static final String USER_MAPPING_A_DUP = "{\"from\":\"admin\",\"to\":\"other-service\"}"; + private static final String USER_MAPPING_INVALID = "{\"from\":\"admin\"}"; + + private static final String CHANGE_SAMPLE_A = "{\"path\":\"/content/dam\"}"; + private static final String CHANGE_SAMPLE_BLANK_PATH = "{\"path\":\"\"}"; + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + /* ------------------ + Service activation + ------------------ */ + + @Test + public void shouldRegisterForEachValidMapping() throws InvalidSyntaxException { + context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A, PATH_MAPPING_B)); + + assertEquals(2, countRegisteredProviders()); + } + + @Test + public void shouldParseUserMappingsAndSamples() throws InvalidSyntaxException { + context.registerInjectActivateService( + new RelayProviderHost(), + newExtendedProps( + true, + new String[]{PATH_MAPPING_A}, + new String[]{StringUtils.EMPTY, USER_MAPPING_INVALID, USER_MAPPING_A, USER_MAPPING_A_DUP}, + new String[]{StringUtils.EMPTY, CHANGE_SAMPLE_BLANK_PATH, CHANGE_SAMPLE_A})); + assertEquals(1, countRegisteredProviders()); + } + + @Test + public void shouldNotRegisterForInvalidConfig() throws InvalidSyntaxException { + // Disabled with a valid mapping + context.registerInjectActivateService(new RelayProviderHost(), newProps(false, PATH_MAPPING_A)); + assertEquals(0, countRegisteredProviders()); + + // No path mappings + context.registerInjectActivateService(new RelayProviderHost(), newProps(true)); + assertEquals(0, countRegisteredProviders()); + + // Invalid mapping (missing "to" value) + context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_MISSING_TO)); + assertEquals(0, countRegisteredProviders()); + } + + @Test + public void shouldSkipDuplicatePathMappings() throws InvalidSyntaxException { + context.registerInjectActivateService( + new RelayProviderHost(), + newProps( + true, + StringUtils.EMPTY, + PATH_MAPPING_A, + PATH_MAPPING_A_NESTED_SOURCE, + PATH_MAPPING_A_NESTED_TARGET, + PATH_MAPPING_A_DUP_SOURCE)); + assertEquals(1, countRegisteredProviders()); + } + + @Test + public void shouldSkipShadowedPath() throws InvalidSyntaxException { + context.bundleContext().registerService( + ResourceProvider.class.getName(), + new StubResourceProvider(), + newExtProviderProps("external-provider", PATH_SOURCE)); + + context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + } + + @Test + public void shouldSkipShadowingByUnnamedProvider() throws InvalidSyntaxException { + // Null PROPERTY_NAME → fallback to bundle symbolic name → relay is still shadowed + context.bundleContext().registerService( + ResourceProvider.class.getName(), + new StubResourceProvider(), + newExtProviderProps(null, PATH_SOURCE)); + context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_A)); + assertEquals(1, countRegisteredProviders()); + + // Empty PROPERTY_NAME → same fallback behavior + context.bundleContext().registerService( + ResourceProvider.class.getName(), + new StubResourceProvider(), + newExtProviderProps("", "/content/source2")); + context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_B)); + assertEquals(2, countRegisteredProviders()); + } + + @Test + public void shouldRegisterWithRootlessExternalProvider() throws InvalidSyntaxException { + // Provider with no PROPERTY_ROOT → not in the provided-paths map → relay is not shadowed + context.bundleContext().registerService( + ResourceProvider.class.getName(), + new StubResourceProvider(), + newExtProviderProps("rootless-provider", null)); + context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_A)); + assertEquals(2, countRegisteredProviders()); + } + + /* --------------------- + Service re-activation + --------------------- */ + + @Test + public void shouldUpdateProviderOnReactivate() throws InvalidSyntaxException { + RelayProviderHost host = context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + + MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + } + + @Test + public void shouldReregisterWhenTargetChanges() throws InvalidSyntaxException { + RelayProviderHost host = context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + assertEquals("/content/target", getRegisteredTarget(PATH_SOURCE)); + + MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A_NEW_TARGET)); + + assertEquals(1, countRegisteredProviders()); + assertEquals("/content/target-updated", getRegisteredTarget(PATH_SOURCE)); + } + + @Test + public void shouldUnregisterRemovedMappingOnReactivate() throws InvalidSyntaxException { + RelayProviderHost host = context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A, PATH_MAPPING_B)); + + assertEquals(2, countRegisteredProviders()); + + MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + } + + @Test + public void shouldRegisterAddedMappingOnReactivate() throws InvalidSyntaxException { + RelayProviderHost host = context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + + MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A, PATH_MAPPING_B)); + + assertEquals(2, countRegisteredProviders()); + } + + /* ------------ + Deactivation + ------------ */ + + @Test + public void shouldUnregisterOnDeactivate() throws InvalidSyntaxException { + RelayProviderHost host = context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + + MockOsgi.deactivate(host, context.bundleContext()); + + assertEquals(0, countRegisteredProviders()); + } + + /* --------------- + Utility methods + --------------- */ + + private int countRegisteredProviders() throws InvalidSyntaxException { + ServiceReference[] refs = context.bundleContext() + .getAllServiceReferences(ResourceProvider.class.getName(), null); + return refs != null ? refs.length : 0; + } + + @SuppressWarnings("SameParameterValue") + private String getRegisteredTarget(String sourcePath) throws InvalidSyntaxException { + ServiceReference[] refs = context + .bundleContext() + .getAllServiceReferences(ResourceProvider.class.getName(), null); + if (refs == null) { + return null; + } + for (ServiceReference ref : refs) { + Object root = ref.getProperty(ResourceProvider.PROPERTY_ROOT); + if (sourcePath.equals(root)) { + String[] paths = (String[]) ref.getProperty(ResourceChangeListener.PATHS); + return ArrayUtils.isNotEmpty(paths) ? paths[0] : null; + } + } + return null; + } + + private static Dictionary newExtProviderProps(String name, String root) { + Dictionary props = new Hashtable<>(); + if (root != null) { + props.put(ResourceProvider.PROPERTY_ROOT, root); + } + if (name != null) { + props.put(ResourceProvider.PROPERTY_NAME, name); + } + return props; + } + + private static Map newProps(boolean enabled, String... pathMappings) { + Map props = new HashMap<>(); + props.put("enabled", enabled); + props.put("pathMappings", pathMappings); + return props; + } + + private static Map newExtendedProps( + boolean enabled, + String[] pathMappings, + String[] userMappings, + String[] announcedPaths) { + Map props = new HashMap<>(); + props.put("enabled", enabled); + props.put("pathMappings", pathMappings); + props.put("userMappings", userMappings); + props.put("announcedPaths", announcedPaths); + return props; + } + + private static class StubResourceProvider extends ResourceProvider { + + @Override + public Resource getResource( + @Nonnull ResolveContext ctx, + @Nonnull String path, + @Nonnull ResourceContext resourceContext, + Resource parent) { + return null; + } + + @Override + public Iterator listChildren(@Nonnull ResolveContext ctx, @Nonnull Resource parent) { + return null; + } + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.java new file mode 100644 index 000000000..2af8333c9 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.java @@ -0,0 +1,661 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.services; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceMetadata; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.observation.ResourceChange; +import org.apache.sling.spi.resource.provider.ObservationReporter; +import org.apache.sling.spi.resource.provider.ProviderContext; +import org.apache.sling.spi.resource.provider.ResolveContext; +import org.apache.sling.spi.resource.provider.ResourceContext; +import org.apache.sling.spi.resource.provider.ResourceProvider; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import io.wcm.testing.mock.aem.junit.AemContext; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.AemContextFactory; +import com.exadel.aem.toolkit.core.relay.models.ChangeSample; +import com.exadel.aem.toolkit.core.relay.models.RelayInfo; +import com.exadel.aem.toolkit.core.relay.models.RelayMapping; +import com.exadel.aem.toolkit.core.relay.models.RelayResource; +import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; + +public class RelayProviderTest { + + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_TARGET = "/content/target"; + private static final String PATH_CHILD_A = "/page1"; + private static final String PATH_CHILD_B = "/page2"; + private static final String USER_AUTHOR = "author"; + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + /* ------------------------------ + getResource(): path resolution + ------------------------------ */ + + @Test + public void shouldReturnRelayResource() { + // Path under source prefix + context.create().resource(PATH_TARGET); + context.create().resource(PATH_TARGET + PATH_CHILD_A); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResolveContext resolveContext = newResolveContext(); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + + assertNotNull(result); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + + // Exact source path + provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + resolveContext = newResolveContext(); + resourceContext = Mockito.mock(ResourceContext.class); + + result = provider.getResource(resolveContext, PATH_SOURCE, resourceContext, null); + + assertNotNull(result); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE, result.getPath()); + } + + @Test + public void shouldReturnNullWhenPathNotUnderSource() { + String otherPath = "/content/other/page"; + context.create().resource(otherPath); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResolveContext resolveContext = newResolveContext(); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, otherPath, resourceContext, null); + + assertNull(result); + } + + @Test + public void shouldFallBackToParentProvider() { + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + Resource fallbackResource = context.create().resource(PATH_SOURCE + PATH_CHILD_A); + Mockito + .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE + PATH_CHILD_A), Mockito.any(), Mockito.any())) + .thenReturn(fallbackResource); + + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + + assertNotNull(result); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + assertFalse(result instanceof RelayResource); + } + + @Test + public void shouldReturnNullWhenTargetAndParentMiss() { + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResolveContext resolveContext = newResolveContext(); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + + assertNull(result); + } + + /* -------------- + listChildren() + -------------- */ + + @Test + public void shouldListRelayChildren() { + context.create().resource(PATH_TARGET); + context.create().resource(PATH_TARGET + PATH_CHILD_A); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResolveContext resolveContext = newResolveContext(); + Resource parent = context.create().resource(PATH_SOURCE); + + // Single child + Iterator result = provider.listChildren(resolveContext, parent); + + assertNotNull(result); + assertTrue(result.hasNext()); + Resource child = result.next(); + assertEquals(PATH_SOURCE + PATH_CHILD_A, child.getPath()); + assertTrue(child instanceof RelayResource); + assertFalse(result.hasNext()); + + // Multiple children + context.create().resource(PATH_TARGET + PATH_CHILD_B); + result = provider.listChildren(resolveContext, parent); + + assertNotNull(result); + Map children = new HashMap<>(); + while (result.hasNext()) { + Resource c = result.next(); + children.put(c.getPath(), c); + } + assertEquals(2, children.size()); + assertTrue(children.containsKey(PATH_SOURCE + PATH_CHILD_A)); + assertTrue(children.containsKey(PATH_SOURCE + PATH_CHILD_B)); + assertTrue(children.get(PATH_SOURCE + PATH_CHILD_A) instanceof RelayResource); + assertTrue(children.get(PATH_SOURCE + PATH_CHILD_B) instanceof RelayResource); + } + + @Test + public void shouldReturnNullWhenTargetNotFound() { + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResolveContext resolveContext = newResolveContext(); + + Resource parent = context.create().resource(PATH_SOURCE); + + Iterator result = provider.listChildren(resolveContext, parent); + + assertNull(result); + } + + @Test + public void shouldDelegateToParentProvider() { + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + Resource fallbackResource = context.create().resource("/content/fallback"); + Mockito + .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE), Mockito.any(), Mockito.any())) + .thenReturn(fallbackResource); + + Resource parent = context.create().resource(PATH_SOURCE); + Resource expectedChild = context.create().resource(parent, "page1"); + Mockito + .when(mockProvider.listChildren(Mockito.any(), Mockito.any())) + .thenReturn(Collections.singletonList(expectedChild).iterator()); + + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + + Iterator result = provider.listChildren(resolveContext, parent); + + assertNotNull(result); + assertTrue(result.hasNext()); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.next().getPath()); + } + + /* ---------------------- + Start / stop lifecycle + ---------------------- */ + + @Test + public void shouldStartWithEmptyChangeSamples() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + provider.start(providerContext); + + Mockito.verify(reporter, Mockito.never()).reportChanges(Mockito.any(), Mockito.anyBoolean()); + } + + @Test + public void shouldStartAndReportChangeSamples() { + ChangeSample sample = newChangeSample(PATH_TARGET + PATH_CHILD_A); + RelayInfo relay = newRelayInfo( + PATH_SOURCE, PATH_TARGET, + Collections.emptyList(), + Collections.singletonList(sample)); + + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + + RelayProvider provider = newProvider(relay); + provider.start(providerContext); + + ArgumentCaptor> captor = changeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + Collection changes = captor.getValue(); + assertEquals(1, changes.size()); + ResourceChange change = changes.iterator().next(); + assertEquals(PATH_SOURCE + PATH_CHILD_A, change.getPath()); + assertEquals(ResourceChange.ChangeType.CHANGED, change.getType()); + } + + @Test + public void shouldSetProviderContext() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + + // Before start: onChange is a no-op because providerContext is null + provider.onChange(Collections.singletonList( + new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false))); + Mockito.verify(reporter, Mockito.never()).reportChanges(Mockito.any(), Mockito.anyBoolean()); + + // After start: onChange calls reportChanges because providerContext is set + provider.start(providerContext); + provider.onChange(Collections.singletonList( + new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false))); + Mockito.verify(reporter, Mockito.times(1)).reportChanges(Mockito.any(), Mockito.eq(false)); + } + + @Test + public void shouldStopGracefullyBeforeStart() { + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + + // Calling stop() without a prior start() must not throw (providerContext and sampler are null) + provider.stop(); + } + + @Test + public void shouldStopWithEmptyChangeSamples() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + provider.start(providerContext); + Mockito.reset(reporter); + + provider.stop(); + + Mockito.verify(reporter, Mockito.never()).reportChanges(Mockito.any(), Mockito.anyBoolean()); + } + + @Test + public void shouldStopAndReportChanges() { + ChangeSample sample = newChangeSample(PATH_TARGET + PATH_CHILD_A); + RelayInfo relay = newRelayInfo( + PATH_SOURCE, PATH_TARGET, + Collections.emptyList(), + Collections.singletonList(sample)); + + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + + RelayProvider provider = newProvider(relay); + provider.start(providerContext); + + Mockito.reset(reporter); + + provider.stop(); + + ArgumentCaptor> captor = changeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + Collection changes = captor.getValue(); + assertEquals(1, changes.size()); + assertEquals(PATH_SOURCE + PATH_CHILD_A, changes.iterator().next().getPath()); + } + + /* -------------------- + Change event mapping + -------------------- */ + + @Test + public void shouldNoOpWhenProviderContextIsNull() { + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + + // Not started: providerContext is null, so onChange returns immediately without throwing + provider.onChange(Collections.singletonList( + new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false))); + } + + @Test + public void shouldMapSingleChange() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + provider.start(providerContext); + + provider.onChange(Collections.singletonList( + new ResourceChange(ResourceChange.ChangeType.REMOVED, PATH_TARGET + PATH_CHILD_A, false))); + + ArgumentCaptor> captor = listChangeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + List mapped = captor.getValue(); + assertEquals(1, mapped.size()); + assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath()); + assertEquals(ResourceChange.ChangeType.REMOVED, mapped.get(0).getType()); + } + + @Test + public void shouldMapChangePathsFromTargetToSource() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + provider.start(providerContext); + + // Single change + provider.onChange(Collections.singletonList( + new ResourceChange(ResourceChange.ChangeType.REMOVED, PATH_TARGET + PATH_CHILD_A, false))); + + ArgumentCaptor> captor = listChangeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + List mapped = captor.getValue(); + assertEquals(1, mapped.size()); + assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath()); + assertEquals(ResourceChange.ChangeType.REMOVED, mapped.get(0).getType()); + + Mockito.reset(reporter); + + // Multiple changes + List incoming = Arrays.asList( + new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false), + new ResourceChange(ResourceChange.ChangeType.ADDED, PATH_TARGET + PATH_CHILD_B, false)); + provider.onChange(incoming); + + captor = listChangeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + mapped = captor.getValue(); + assertEquals(2, mapped.size()); + assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath()); + assertEquals(ResourceChange.ChangeType.CHANGED, mapped.get(0).getType()); + assertEquals(PATH_SOURCE + PATH_CHILD_B, mapped.get(1).getPath()); + assertEquals(ResourceChange.ChangeType.ADDED, mapped.get(1).getType()); + } + + @Test + public void shouldHandleEmptyChangeList() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + provider.start(providerContext); + + provider.onChange(Collections.emptyList()); + + ArgumentCaptor> captor = changeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + assertTrue(captor.getValue().isEmpty()); + } + + @Test + public void shouldPreserveChangeExternalFlag() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + provider.start(providerContext); + + provider.onChange(Collections.singletonList( + new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, true))); + + ArgumentCaptor> captor = listChangeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + List mapped = captor.getValue(); + assertEquals(1, mapped.size()); + assertTrue(mapped.get(0).isExternal()); + assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath()); + } + + @Test + public void shouldNotMapChangesOutsideTarget() { + ObservationReporter reporter = Mockito.mock(ObservationReporter.class); + ProviderContext providerContext = newMockProviderContext(reporter); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + provider.start(providerContext); + + String unrelatedPath = "/content/unrelated/page"; + provider.onChange(Collections.singletonList( + new ResourceChange(ResourceChange.ChangeType.CHANGED, unrelatedPath, false))); + + ArgumentCaptor> captor = listChangeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + List mapped = captor.getValue(); + assertEquals(1, mapped.size()); + // Path not under target prefix → returned unchanged + assertEquals(unrelatedPath, mapped.get(0).getPath()); + } + + /* ------------- + Config update + ------------- */ + + @Test + public void shouldUpdateRelayConfig() { + String altTarget = PATH_TARGET + "2"; + String altSource = PATH_SOURCE + "2"; + context.create().resource(altTarget + PATH_CHILD_A); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + RelayInfo newRelay = newRelayInfo(altSource, altTarget); + provider.update(newRelay); + + ResolveContext resolveContext = newResolveContext(); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + // Old source path no longer matches after the relay was updated + Resource oldResult = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + assertNull(oldResult); + + // New source path resolves to a RelayResource + Resource newResult = provider.getResource(resolveContext, altSource + PATH_CHILD_A, resourceContext, null); + assertNotNull(newResult); + assertTrue(newResult instanceof RelayResource); + assertEquals(altSource + PATH_CHILD_A, newResult.getPath()); + } + + /* --------------------- + User resolver mapping + --------------------- */ + + @Test + public void shouldUseOriginalResolverWhenNoUserMapping() { + context.create().resource(PATH_TARGET + PATH_CHILD_A); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResolveContext resolveContext = newResolveContext(); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + + assertNotNull(result); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + } + + @Test + public void shouldUseMappedUserResolver() throws LoginException { + String userService = "eak-service"; + RelayInfo relay = newRelayInfo( + PATH_SOURCE, PATH_TARGET, + Collections.singletonList(newMapping(USER_AUTHOR, userService)), + Collections.emptyList()); + + Resource mockTargetResource = Mockito.mock(Resource.class); + Mockito.when(mockTargetResource.getPath()).thenReturn(PATH_TARGET + PATH_CHILD_A); + Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); + + ResourceResolver mappedResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(mappedResolver.getResource(PATH_TARGET + PATH_CHILD_A)).thenReturn(mockTargetResource); + Mockito.when(mappedResolver.getUserID()).thenReturn(userService); + + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(factory.getServiceResourceResolver(Mockito.any())).thenReturn(mappedResolver); + + Map propertyMap = new HashMap<>(); + ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(basicResolver.getUserID()).thenReturn(USER_AUTHOR); + Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); + + ResolveContext resolveContext = newResolveContext(basicResolver); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + RelayProvider provider = newProvider(factory, relay); + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + + assertNotNull(result); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + Mockito.verify(mappedResolver).getResource(PATH_TARGET + PATH_CHILD_A); + } + + @Test + public void shouldUseOriginalResolver() { + RelayInfo relay = newRelayInfo( + PATH_SOURCE, PATH_TARGET, + Collections.singletonList(newMapping(USER_AUTHOR, USER_AUTHOR)), + Collections.emptyList()); + + Resource mockTargetResource = Mockito.mock(Resource.class); + Mockito.when(mockTargetResource.getPath()).thenReturn(PATH_TARGET + PATH_CHILD_A); + Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); + + ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(basicResolver.getUserID()).thenReturn(USER_AUTHOR); + Mockito.when(basicResolver.getResource(PATH_TARGET + PATH_CHILD_A)).thenReturn(mockTargetResource); + + ResolveContext resolveContext = newResolveContext(basicResolver); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + RelayProvider provider = newProvider(context.getService(ResourceResolverFactory.class), relay); + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + + assertNotNull(result); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + } + + @Test + public void shouldReturnNullForInvalidMapping() throws LoginException { + RelayInfo relay = newRelayInfo( + PATH_SOURCE, PATH_TARGET, + Collections.singletonList(newMapping(USER_AUTHOR, "broken-service")), + Collections.emptyList()); + + Resource mockTargetResource = Mockito.mock(Resource.class); + Mockito.when(mockTargetResource.getPath()).thenReturn(PATH_TARGET + PATH_CHILD_A); + Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); + + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + Mockito + .when(factory.getServiceResourceResolver(Mockito.any())) + .thenThrow(new LoginException("No service user")); + + ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(basicResolver.getUserID()).thenReturn(USER_AUTHOR); + Mockito.when(basicResolver.getResource(PATH_TARGET + PATH_CHILD_A)).thenReturn(mockTargetResource); + + ResolveContext resolveContext = newResolveContext(basicResolver); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + RelayProvider provider = newProvider(factory, relay); + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); + + assertNull(result); + } + + /* --------------- + Utility methods + --------------- */ + + private RelayProvider newProvider(RelayInfo relay) { + return new RelayProvider(context.getService(ResourceResolverFactory.class), relay); + } + + private static RelayProvider newProvider(ResourceResolverFactory factory, RelayInfo relay) { + return new RelayProvider(factory, relay); + } + + @SuppressWarnings("unchecked") + private static ResourceProvider newMockProvider() { + return Mockito.mock(ResourceProvider.class); + } + + private static RelayInfo newRelayInfo(String source, String target) { + return newRelayInfo(source, target, Collections.emptyList(), Collections.emptyList()); + } + + private static RelayInfo newRelayInfo( + String source, + String target, + Collection userMappings, + Collection changeSamples) { + return new RelayInfo(newMapping(source, target), userMappings, changeSamples); + } + + private static RelayMapping newMapping(String from, String to) { + return ObjectConversionUtil.toObject( + "{\"from\":\"" + from + "\",\"to\":\"" + to + "\"}", + RelayMapping.class); + } + + @SuppressWarnings("SameParameterValue") + private static ChangeSample newChangeSample(String path) { + return ObjectConversionUtil.toObject( + "{\"path\":\"" + path + "\"}", + ChangeSample.class); + } + + private ResolveContext newResolveContext() { + return newResolveContext(context.resourceResolver()); + } + + @SuppressWarnings("unchecked") + private static ResolveContext newResolveContext(ResourceResolver resolver) { + ResolveContext resolveContext = Mockito.mock(ResolveContext.class); + Mockito.when(resolveContext.getResourceResolver()).thenReturn(resolver); + return resolveContext; + } + + @SuppressWarnings("unchecked") + private static ResolveContext newMockResolveContext() { + return Mockito.mock(ResolveContext.class); + } + + private static ProviderContext newMockProviderContext(ObservationReporter reporter) { + ProviderContext providerContext = Mockito.mock(ProviderContext.class); + Mockito.when(providerContext.getObservationReporter()).thenReturn(reporter); + return providerContext; + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> changeCaptor() { + return (ArgumentCaptor>) (ArgumentCaptor) ArgumentCaptor.forClass(Collection.class); + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> listChangeCaptor() { + return (ArgumentCaptor>) (ArgumentCaptor) ArgumentCaptor.forClass(List.class); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java new file mode 100644 index 000000000..6f7b9ef8c --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.utils; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class RelayPathHelperTest { + + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_TARGET = "/content/target"; + + @Test + public void shouldCheckForSamePathOrSubpath() { + assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE, PATH_SOURCE)); + assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "/child", PATH_SOURCE)); + assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "/a/b/c", PATH_SOURCE)); + assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "/", PATH_SOURCE)); + assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE, PATH_SOURCE + "/")); + + assertFalse(RelayPathHelper.isSamePathOrSubpath("/content/other", PATH_SOURCE)); + assertFalse(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "-extra", PATH_SOURCE)); + assertFalse(RelayPathHelper.isSamePathOrSubpath(null, PATH_SOURCE)); + assertTrue(RelayPathHelper.isSamePathOrSubpath(StringUtils.EMPTY, null)); + } + + @Test + public void shouldReplacePrefix() { + assertEquals(PATH_TARGET, RelayPathHelper.replace(PATH_SOURCE, PATH_SOURCE, PATH_TARGET)); + assertEquals(PATH_TARGET + "/child", RelayPathHelper.replace(PATH_SOURCE + "/child", PATH_SOURCE, PATH_TARGET)); + assertEquals(PATH_TARGET + "/a/b/c", RelayPathHelper.replace(PATH_SOURCE + "/a/b/c", PATH_SOURCE, PATH_TARGET)); + } + + @Test + public void shouldReturnPathUnchanged() { + assertEquals("/content/other", RelayPathHelper.replace("/content/other", PATH_SOURCE, PATH_TARGET)); + assertEquals(PATH_SOURCE + "-extra", RelayPathHelper.replace(PATH_SOURCE + "-extra", PATH_SOURCE, PATH_TARGET)); + assertNull(RelayPathHelper.replace(null, PATH_SOURCE, PATH_TARGET)); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java new file mode 100644 index 000000000..73687d22b --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java @@ -0,0 +1,258 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.relay.utils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.spi.resource.provider.ResolveContext; +import org.apache.sling.spi.resource.provider.ResourceContext; +import org.apache.sling.spi.resource.provider.ResourceProvider; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mockito; +import io.wcm.testing.mock.aem.junit.AemContext; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.AemContextFactory; +import com.exadel.aem.toolkit.core.relay.models.RelayResource; + +public class RelayResourceHelperTest { + + private static final String PATH_TARGET = "/content/target"; + private static final String PATH_CHILD = "/child"; + private static final String PATH_EXPOSED = "/content/exposed"; + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + @Test + public void shouldResolveResource() { + context.create().resource(PATH_TARGET); + ResourceResolver resolver = context.resourceResolver(); + ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class); + + assertNotNull(resolverFactory); + + // userId matches resolver's own ID → uses original resolver + Resource sameUserResult = RelayResourceHelper.getResource(resolver, resolverFactory, resolver.getUserID(), PATH_TARGET); + assertNotNull(sameUserResult); + assertEquals(PATH_TARGET, sameUserResult.getPath()); + + // userId is null → no subsidiary resolver lookup; uses original resolver + Resource nullUserResult = RelayResourceHelper.getResource(resolver, resolverFactory, null, PATH_TARGET); + assertNotNull(nullUserResult); + assertEquals(PATH_TARGET, nullUserResult.getPath()); + + // resource not found → null + Resource missingTarget = RelayResourceHelper.getResource(resolver, resolverFactory, resolver.getUserID(), PATH_TARGET + "/missing"); + assertNull(missingTarget); + } + + @Test + public void shouldTrackSubsidiaryResolver() { + Map propertyMap = new HashMap<>(); + ResourceResolver resolver = Mockito.mock(ResourceResolver.class); + Mockito.when(resolver.getPropertyMap()).thenReturn(propertyMap); + + ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class); + assertNotNull(resolverFactory); + + RelayResourceHelper.getResource(resolver, resolverFactory, "modified-user", PATH_TARGET); + + Object stored = propertyMap.get(ResourceResolver.class.getName() + "@modified-user"); + assertTrue(stored instanceof ResourceResolver); + } + + @Test + public void shouldReuseExistingSubsidiaryResolver() { + Map propertyMap = new HashMap<>(); + ResourceResolver resolver = Mockito.mock(ResourceResolver.class); + Mockito.when(resolver.getPropertyMap()).thenReturn(propertyMap); + + ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class); + assertNotNull(resolverFactory); + + RelayResourceHelper.getResource(resolver, resolverFactory, "modified-user", PATH_TARGET); + Object firstResolver = propertyMap.get(ResourceResolver.class.getName() + "@modified-user"); + + RelayResourceHelper.getResource(resolver, resolverFactory, "modified-user", PATH_TARGET); + Object secondResolver = propertyMap.get(ResourceResolver.class.getName() + "@modified-user"); + + assertSame(firstResolver, secondResolver); + } + + @Test + public void shouldStoreSentinelWhenLoginFails() throws LoginException { + Map propertyMap = new HashMap<>(); + ResourceResolver resolver = Mockito.mock(ResourceResolver.class); + Mockito.when(resolver.getPropertyMap()).thenReturn(propertyMap); + + ResourceResolverFactory resolverFactory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(resolverFactory.getServiceResourceResolver(Mockito.any())) + .thenThrow(new LoginException("Simulated login failure")); + + // First call: LoginException → sentinel stored; original resolver used as fallback + RelayResourceHelper.getResource(resolver, resolverFactory, "blocked-user", PATH_TARGET); + + Object stored = propertyMap.get(ResourceResolver.class.getName() + "@blocked-user"); + assertNotNull(stored); + assertFalse(stored instanceof ResourceResolver); // sentinel, not a resolver + Mockito.verify(resolverFactory, Mockito.times(1)).getServiceResourceResolver(Mockito.any()); + + // Second call with same userId: sentinel already in map, factory not called again + RelayResourceHelper.getResource(resolver, resolverFactory, "blocked-user", PATH_TARGET); + Mockito.verify(resolverFactory, Mockito.times(1)).getServiceResourceResolver(Mockito.any()); + } + + @Test + public void shouldDelegateToParentProvider() { + Resource fallback = context.create().resource(PATH_TARGET + PATH_CHILD); + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + Mockito.when(mockProvider.getResource(Mockito.same(parentCtx), Mockito.eq(PATH_TARGET + PATH_CHILD), Mockito.any(), Mockito.isNull())) + .thenReturn(fallback); + + ResolveContext resolveContext = newMockResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = RelayResourceHelper.getResource(resolveContext, PATH_TARGET + PATH_CHILD, resourceContext, null); + + assertNotNull(result); + assertEquals(PATH_TARGET + PATH_CHILD, result.getPath()); + Mockito.verify(mockProvider).getResource(Mockito.same(parentCtx), Mockito.eq(PATH_TARGET + PATH_CHILD), Mockito.same(resourceContext), Mockito.isNull()); + } + + @Test + public void shouldReturnNullWithoutContext() { + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + // null resolveContext + assertNull(RelayResourceHelper.getResource(null, PATH_TARGET, resourceContext, null)); + + // missing parent resource provider + ResolveContext noProvider = newMockResolveContext(); + Mockito.doReturn(null).when(noProvider).getParentResourceProvider(); + Mockito.doReturn(newMockResolveContext()).when(noProvider).getParentResolveContext(); + assertNull(RelayResourceHelper.getResource(noProvider, PATH_TARGET, resourceContext, null)); + + // missing parent resolve context + ResolveContext noParentCtx = newMockResolveContext(); + Mockito.doReturn(newMockProvider()).when(noParentCtx).getParentResourceProvider(); + Mockito.doReturn(null).when(noParentCtx).getParentResolveContext(); + assertNull(RelayResourceHelper.getResource(noParentCtx, PATH_TARGET, resourceContext, null)); + } + + @Test + public void shouldListChildrenWithOverriddenPath() { + Resource parent = context.create().resource(PATH_TARGET); + context.create().resource(PATH_TARGET + PATH_CHILD); + + Iterator children = RelayResourceHelper.listChildren(parent, PATH_EXPOSED); + + assertNotNull(children); + assertTrue(children.hasNext()); + Resource child = children.next(); + assertEquals(PATH_EXPOSED + PATH_CHILD, child.getPath()); + assertTrue(child instanceof RelayResource); + assertFalse(children.hasNext()); + } + + @Test + public void shouldListChildrenOfUnwrappedRelay() { + Resource original = context.create().resource(PATH_TARGET); + context.create().resource(PATH_TARGET + PATH_CHILD); + RelayResource relay = new RelayResource(original, PATH_EXPOSED); + + // target is a RelayResource → helper must unwrap and use the underlying resource's children + Iterator children = RelayResourceHelper.listChildren(relay, PATH_EXPOSED); + + assertNotNull(children); + assertTrue(children.hasNext()); + Resource child = children.next(); + assertEquals(PATH_EXPOSED + PATH_CHILD, child.getPath()); + assertTrue(child instanceof RelayResource); + assertFalse(children.hasNext()); + } + + @Test + public void shouldDelegateListChildrenToParent() { + Resource parent = context.create().resource(PATH_TARGET); + Resource expectedChild = context.create().resource(PATH_TARGET + PATH_CHILD); + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + Mockito.when(mockProvider.listChildren(Mockito.same(parentCtx), Mockito.same(parent))) + .thenReturn(Collections.singletonList(expectedChild).iterator()); + + ResolveContext resolveContext = newMockResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + Iterator children = RelayResourceHelper.listChildren(resolveContext, parent); + + assertNotNull(children); + assertTrue(children.hasNext()); + assertEquals(PATH_TARGET + PATH_CHILD, children.next().getPath()); + Mockito.verify(mockProvider).listChildren(Mockito.same(parentCtx), Mockito.same(parent)); + } + + @Test + public void shouldReturnNullChildrenWithoutContext() { + Resource parent = context.create().resource(PATH_TARGET); + + // null resolveContext + assertNull(RelayResourceHelper.listChildren(null, parent)); + + // missing parent resource provider + ResolveContext noProvider = newMockResolveContext(); + Mockito.doReturn(null).when(noProvider).getParentResourceProvider(); + Mockito.doReturn(newMockResolveContext()).when(noProvider).getParentResolveContext(); + assertNull(RelayResourceHelper.listChildren(noProvider, parent)); + + // missing parent resolve context + ResolveContext noParentCtx = newMockResolveContext(); + Mockito.doReturn(newMockProvider()).when(noParentCtx).getParentResourceProvider(); + Mockito.doReturn(null).when(noParentCtx).getParentResolveContext(); + assertNull(RelayResourceHelper.listChildren(noParentCtx, parent)); + } + + /* --------------- + Utility methods + --------------- */ + + @SuppressWarnings("unchecked") + private static ResourceProvider newMockProvider() { + return Mockito.mock(ResourceProvider.class); + } + + @SuppressWarnings("unchecked") + private static ResolveContext newMockResolveContext() { + return Mockito.mock(ResolveContext.class); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtilTest.java new file mode 100644 index 000000000..bcdc5be53 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtilTest.java @@ -0,0 +1,280 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.utils; + +import java.beans.Transient; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.CoreConstants; + +@SuppressWarnings("unused") +public class ObjectConversionUtilTest { + + private static final String PN_VISIBLE = "visible"; + private static final String VALUE_VISIBLE = "visible-value"; + private static final String VALUE_HIDDEN = "hidden-value"; + + @Test + public void shouldDetectValidJson() { + assertTrue(ObjectConversionUtil.isJson("{\"key\": \"value\"}")); + assertTrue(ObjectConversionUtil.isJson("[1, 2, 3]")); + assertTrue(ObjectConversionUtil.isJson("42")); + assertTrue(ObjectConversionUtil.isJson("null")); + assertTrue(ObjectConversionUtil.isJson("true")); + assertTrue(ObjectConversionUtil.isJson("false")); + assertTrue(ObjectConversionUtil.isJson("\"hello\"")); + } + + @Test + public void shouldRejectBlankOrInvalidJson() { + assertFalse(ObjectConversionUtil.isJson(null)); + assertFalse(ObjectConversionUtil.isJson("")); + assertFalse(ObjectConversionUtil.isJson(" ")); + assertFalse(ObjectConversionUtil.isJson("{not valid json}")); + assertFalse(ObjectConversionUtil.isJson("{\"key\": \"value\"")); + } + + @Test + public void shouldParseJsonPrimitivesToTypedObjects() { + assertEquals("hello", ObjectConversionUtil.toObject("\"hello\"", String.class)); + assertEquals(Integer.valueOf(42), ObjectConversionUtil.toObject("42", Integer.class)); + assertEquals(Boolean.TRUE, ObjectConversionUtil.toObject("true", Boolean.class)); + } + + @Test + public void shouldParseJsonObjectToBean() { + SimpleBean bean = ObjectConversionUtil.toObject("{\"name\":\"test\",\"value\":7}", SimpleBean.class); + assertNotNull(bean); + assertEquals("test", bean.getName()); + assertEquals(7, bean.getValue()); + } + + @Test + public void shouldReturnNullForBlankNullOrUnparsableInput() { + assertNull(ObjectConversionUtil.toObject(null, String.class)); + assertNull(ObjectConversionUtil.toObject("", String.class)); + assertNull(ObjectConversionUtil.toObject(" ", String.class)); + // Bare word is not a valid JSON + assertNull(ObjectConversionUtil.toObject("not-a-number", Integer.class)); + } + + @Test + public void shouldConvertPojoToPropertyMap() { + Map simple = ObjectConversionUtil.toPropertyMap(new SimpleBean("hello", 42)); + assertNotNull(simple); + assertEquals(2, simple.size()); + assertEquals("hello", simple.get(CoreConstants.PN_NAME)); + assertEquals(42, simple.get(CoreConstants.PN_VALUE)); + + // Bean with nested object serialized as a sub-map + Map withNested = ObjectConversionUtil.toPropertyMap( + new BeanWithNestedObject("outer", new SimpleBean("inner", 5))); + assertNotNull(withNested); + assertEquals(2, withNested.size()); + assertEquals("outer", withNested.get("label")); + Object childValue = withNested.get("child"); + assertTrue(childValue instanceof Map); + Map childMap = (Map) childValue; + assertEquals("inner", childMap.get(CoreConstants.PN_NAME)); + assertEquals(5, childMap.get(CoreConstants.PN_VALUE)); + + // Map input passes through as an equivalent map + Map sourceMap = new HashMap<>(); + sourceMap.put("a", 1); + sourceMap.put("b", "two"); + Map fromMap = ObjectConversionUtil.toPropertyMap(sourceMap); + assertNotNull(fromMap); + assertEquals(2, fromMap.size()); + assertEquals(1, fromMap.get("a")); + assertEquals("two", fromMap.get("b")); + } + + @Test + public void shouldReturnNullForNullPropertyMapInput() { + assertNull(ObjectConversionUtil.toPropertyMap(null)); + } + + @Test + public void shouldReturnEmptyMapForNoPropertiesOrConversionError() { + // Empty bean has no serializable properties → empty map, not null + Map fromEmpty = ObjectConversionUtil.toPropertyMap(new EmptyBean()); + assertNotNull(fromEmpty); + assertTrue(fromEmpty.isEmpty()); + + // A plain String cannot be deserialized as a Map → IllegalArgumentException caught → emptyMap + Map fromError = ObjectConversionUtil.toPropertyMap("a plain string"); + assertNotNull(fromError); + assertTrue(fromError.isEmpty()); + } + + @Test + public void shouldExcludeIgnoredProperties() { + // @java.beans.Transient on getter + assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithTransientAnnotation())); + + // transient field modifier + assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithTransientField())); + + // @JsonIgnore triggers super._isIgnorable in LocalAnnotationIntrospector + assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithJsonIgnore())); + + // Both @Transient annotation and transient modifier present at once + assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithBothTransientMechanisms())); + } + + @Test + public void shouldParseJsonToNodeTree() throws IOException { + JsonNode objectNode = ObjectConversionUtil.toNodeTree("{\"key\": \"value\"}"); + assertNotNull(objectNode); + JsonNode keyNode = objectNode.get("key"); + assertNotNull(keyNode); + assertEquals("value", keyNode.asText()); + + // JSON array with two elements — validates caller usage pattern in InlineOptionSourceResolver + JsonNode arrayNode = ObjectConversionUtil.toNodeTree("[{\"name\":\"a\"},{\"name\":\"b\"}]"); + assertNotNull(arrayNode); + assertTrue(arrayNode.isArray()); + assertEquals(2, arrayNode.size()); + JsonNode firstName = arrayNode.get(0).get(CoreConstants.PN_NAME); + assertNotNull(firstName); + assertEquals("a", firstName.asText()); + JsonNode secondName = arrayNode.get(1).get(CoreConstants.PN_NAME); + assertNotNull(secondName); + assertEquals("b", secondName.asText()); + + // Nested JSON — validates traversal pattern used by HttpOptionSourceResolver + JsonNode nestedNode = ObjectConversionUtil.toNodeTree("{\"outer\": {\"inner\": \"deep\"}}"); + assertNotNull(nestedNode); + JsonNode outerNode = nestedNode.get("outer"); + assertNotNull(outerNode); + JsonNode innerNode = outerNode.get("inner"); + assertNotNull(innerNode); + assertEquals("deep", innerNode.asText()); + } + + @Test(expected = IOException.class) + public void shouldThrowOnInvalidJsonInNodeTree() throws IOException { + ObjectConversionUtil.toNodeTree("{not valid}"); + } + + /* ------- + Fixtures + ------- */ + + private static void assertSingleVisibleProperty(Map map) { + assertNotNull(map); + assertEquals(1, map.size()); + assertEquals(VALUE_VISIBLE, map.get(PN_VISIBLE)); + } + + private static class SimpleBean { + private String name; + private int value; + + // Required by Jackson for JSON deserialization + SimpleBean() {} + + SimpleBean(String name, int value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + } + + private static class BeanWithNestedObject { + private final String label; + private final SimpleBean child; + + BeanWithNestedObject(String label, SimpleBean child) { + this.label = label; + this.child = child; + } + + public String getLabel() { + return label; + } + + public SimpleBean getChild() { + return child; + } + } + + private static class EmptyBean {} + + private static class BeanWithTransientAnnotation { + public String getVisible() { + return VALUE_VISIBLE; + } + + @Transient + public String getHidden() { + return VALUE_HIDDEN; + } + } + + private static class BeanWithTransientField { + public String visible = VALUE_VISIBLE; + public transient String hidden = VALUE_HIDDEN; + } + + private static class BeanWithJsonIgnore { + public String getVisible() { + return VALUE_VISIBLE; + } + + @JsonIgnore + public String getHidden() { + return VALUE_HIDDEN; + } + } + + private static class BeanWithBothTransientMechanisms { + public transient String hiddenField = VALUE_HIDDEN; + + public String getVisible() { + return VALUE_VISIBLE; + } + + @Transient + public String getHiddenAnnotated() { + return VALUE_HIDDEN; + } + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java new file mode 100644 index 000000000..753341c5d --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java @@ -0,0 +1,212 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.utils; + +import java.util.Collections; +import java.util.HashMap; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.scripting.SlingBindings; +import org.apache.sling.api.scripting.SlingScriptHelper; +import org.apache.sling.testing.mock.sling.servlet.MockSlingHttpServletRequest; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mockito; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceReference; +import io.wcm.testing.mock.aem.junit.AemContext; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; + +import com.exadel.aem.toolkit.core.AemContextFactory; + +public class ResolverUtilTest { + + private static final String FACTORY_ERROR = "Could not obtain ResourceResolverFactory"; + private static final String LOGIN_ERROR = "Test error"; + private static final String OSGI_ERROR = "Not running in an OSGi container"; + private static final String SERVICE_USER = "eak-service"; + private static final String SERVICE_USER_CUSTOM = "custom-service-user"; + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + @Test + public void shouldCreateResolverWithServiceUser() throws LoginException { + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + ResourceResolver expected = Mockito.mock(ResourceResolver.class); + Mockito.when(factory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER))).thenReturn(expected); + + try (ResourceResolver resultFromFactory = ResolverUtil.newResolver(factory)) { + assertEquals(expected, resultFromFactory); + } + try (ResourceResolver resultFromRequest = ResolverUtil.newResolver(newRequestWithFactory(factory))) { + assertEquals(expected, resultFromRequest); + } + } + + @Test + public void shouldHandleBlankUser() throws LoginException { + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + ResourceResolver expected = Mockito.mock(ResourceResolver.class); + Mockito.when(factory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER))).thenReturn(expected); + + for (String blankUser : new String[]{null, StringUtils.EMPTY, StringUtils.SPACE}) { + try (ResourceResolver result = ResolverUtil.newResolver(factory, blankUser)) { + assertEquals(expected, result); + } + } + } + + @Test + public void shouldCreateResolverWithCustomUser() throws LoginException { + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + ResourceResolver expected = Mockito.mock(ResourceResolver.class); + Mockito.when(factory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_CUSTOM))).thenReturn(expected); + + try (ResourceResolver result = ResolverUtil.newResolver(factory, SERVICE_USER_CUSTOM)) { + assertEquals(expected, result); + } + } + + @Test + public void shouldThrowWhenNotInOsgiContainer() { + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + assertLoginException(factory, "user@some.bundle", OSGI_ERROR); + } + + @Test + public void shouldThrowWhenDependenciesUnavailable() { + MockSlingHttpServletRequest requestNullBindings = + new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext()); + requestNullBindings.setAttribute(SlingBindings.class.getName(), null); + assertLoginException(requestNullBindings, FACTORY_ERROR); + + SlingBindings noScriptHelper = new SlingBindings(); + MockSlingHttpServletRequest requestNoHelper = + new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext()); + requestNoHelper.setAttribute(SlingBindings.class.getName(), noScriptHelper); + assertLoginException(requestNoHelper, FACTORY_ERROR); + + SlingScriptHelper scriptHelper = Mockito.mock(SlingScriptHelper.class); + Mockito.when(scriptHelper.getService(ResourceResolverFactory.class)).thenReturn(null); + SlingBindings noFactory = new SlingBindings(); + noFactory.put(SlingBindings.SLING, scriptHelper); + MockSlingHttpServletRequest requestNoFactory = + new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext()); + requestNoFactory.setAttribute(SlingBindings.class.getName(), noFactory); + assertLoginException(requestNoFactory, FACTORY_ERROR); + } + + @Test + public void shouldThrowOnLoginException() throws LoginException { + ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(factory.getServiceResourceResolver(Mockito.anyMap())).thenThrow(new LoginException(LOGIN_ERROR)); + assertLoginException(factory, LOGIN_ERROR); + assertLoginException(factory, SERVICE_USER_CUSTOM, LOGIN_ERROR); + assertLoginException(newRequestWithFactory(factory), LOGIN_ERROR); + } + + @Test + @SuppressWarnings({"resource", "unchecked"}) + public void shouldGetUserFromForeignBundle() throws LoginException { + ResourceResolver mockResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(mockResolver.getPropertyMap()).thenReturn(new HashMap<>()); + ResourceResolverFactory mockResolverFactory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(mockResolverFactory.getServiceResourceResolver(Mockito.any())).thenReturn(mockResolver); + + ServiceReference mockServiceReference = + (ServiceReference) Mockito.mock(ServiceReference.class); + + BundleContext mockBundleContext = Mockito.mock(BundleContext.class); + Mockito + .when(mockBundleContext.getServiceReference(Mockito.eq(ResourceResolverFactory.class))) + .thenReturn(mockServiceReference); + Mockito + .when(mockBundleContext.getService(Mockito.eq(mockServiceReference))) + .thenReturn(mockResolverFactory); + + Bundle foreignBundle = Mockito.mock(Bundle.class); + Mockito.when(foreignBundle.getSymbolicName()).thenReturn("foreign.bundle"); + Mockito.when(foreignBundle.getBundleContext()).thenReturn(mockBundleContext); + + Mockito.when(mockBundleContext.getBundles()).thenReturn(new Bundle[] {context.bundleContext().getBundle(), foreignBundle}); + + // Verify that resolver can be obtained for a user associated with the foreign bundle + try (ResourceResolver resolver = ResolverUtil.newResolver( + mockResolverFactory, + "user@foreign.bundle", + mockBundleContext)) { + assertNotNull(resolver); + } + + // Verify that LoginException is thrown when the user is associated with a non-existent bundle + assertThrows( + LoginException.class, + () -> ResolverUtil.newResolver(mockResolverFactory, "user@other.bundle", mockBundleContext)); + + // Verify that LoginException is thrown when the user is not associated with any bundle + Mockito.when(mockResolverFactory.getServiceResourceResolver(Mockito.any())).thenThrow(new LoginException(LOGIN_ERROR)); + assertThrows( + LoginException.class, + () -> ResolverUtil.newResolver(mockResolverFactory, "user@foreign.bundle", mockBundleContext)); + } + + private MockSlingHttpServletRequest newRequestWithFactory(ResourceResolverFactory factory) { + SlingScriptHelper scriptHelper = Mockito.mock(SlingScriptHelper.class); + Mockito.when(scriptHelper.getService(ResourceResolverFactory.class)).thenReturn(factory); + SlingBindings bindings = new SlingBindings(); + bindings.put(SlingBindings.SLING, scriptHelper); + MockSlingHttpServletRequest request = + new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext()); + request.setAttribute(SlingBindings.class.getName(), bindings); + return request; + } + + private static void assertLoginException(SlingHttpServletRequest request, String errorMessage) { + try (ResourceResolver ignored = ResolverUtil.newResolver(request)) { + Assert.fail("Expected LoginException"); + } catch (LoginException e) { + assertEquals(errorMessage, e.getMessage()); + } + } + + @SuppressWarnings("SameParameterValue") + private static void assertLoginException(ResourceResolverFactory factory, String errorMessage) { + try (ResourceResolver ignored = ResolverUtil.newResolver(factory)) { + Assert.fail("Expected LoginException"); + } catch (LoginException e) { + assertEquals(errorMessage, e.getMessage()); + } + } + + private static void assertLoginException(ResourceResolverFactory factory, String user, String errorMessage) { + try (ResourceResolver ignored = ResolverUtil.newResolver(factory, user)) { + Assert.fail("Expected LoginException"); + } catch (LoginException e) { + assertEquals(errorMessage, e.getMessage()); + } + } +} + diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java new file mode 100644 index 000000000..14640e22a --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java @@ -0,0 +1,322 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.exadel.aem.toolkit.core.utils; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mockito; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceReference; +import io.wcm.testing.mock.aem.junit.AemContext; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.AemContextFactory; + +public class ServiceUtilTest { + + private static final String DEFAULT_VALUE = "fallback"; + private static final String STUB_VALUE = "stub-value"; + + private static final String TEST_EXCEPTION_MESSAGE = "Something went wrong"; + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + /* --------------------- + Consumer and Function + --------------------- */ + + @Test + public void shouldInvokeConsumerAndReturnFunctionResult() { + AtomicReference capturedService = new AtomicReference<>(); + StubService service = new StubServiceImpl(); + context.registerService(StubService.class, service); + + ServiceUtil.withService(StubService.class, context.bundleContext(), capturedService::set); + assertEquals(service, capturedService.get()); + + String functionResult = ServiceUtil.withService( + StubService.class, + context.bundleContext(), + StubService::getValue, + DEFAULT_VALUE); + assertEquals(STUB_VALUE, functionResult); + } + + @Test + public void shouldSkipExecutionWhenServiceUnavailable() { + AtomicBoolean consumerInvoked = new AtomicBoolean(false); + + // No service registered, so getServiceReference returns null + ServiceUtil.withService(StubService.class, context.bundleContext(), s -> consumerInvoked.set(true)); + assertFalse(consumerInvoked.get()); + + String result1 = ServiceUtil.withService( + StubService.class, + context.bundleContext(), + StubService::getValue, + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result1); + + // Non-null reference but getService returns null + BundleContext mockContext = newMockContextWithNullService(); + ServiceUtil.withService(StubService.class, mockContext, s -> consumerInvoked.set(true)); + assertFalse(consumerInvoked.get()); + + String result2 = ServiceUtil.withService( + StubService.class, + mockContext, + StubService::getValue, + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result2); + } + + @Test + public void shouldSwallowExceptionsAndReleaseService() { + // Consumer throws IllegalArgumentException: exception swallowed, service still released in the finally block + MockContext consumerCtx = newMockContextReturningService(); + ServiceUtil.withService( + StubService.class, + consumerCtx.bundleContext, + s -> { throw new IllegalArgumentException(TEST_EXCEPTION_MESSAGE); }); + Mockito.verify(consumerCtx.bundleContext).ungetService(consumerCtx.serviceRef); + + // Consumer throws generic RuntimeException: exception also swallowed + MockContext consumerRteCtx = newMockContextReturningService(); + ServiceUtil.withService( + StubService.class, + consumerRteCtx.bundleContext, + s -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); }); + Mockito.verify(consumerRteCtx.bundleContext).ungetService(consumerRteCtx.serviceRef); + + // Function throws IllegalStateException: returns default, service still released in the finally block + MockContext functionCtx = newMockContextReturningService(); + String result = ServiceUtil.withService( + StubService.class, + functionCtx.bundleContext, + s -> { throw new IllegalStateException(TEST_EXCEPTION_MESSAGE); }, + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result); + Mockito.verify(functionCtx.bundleContext).ungetService(functionCtx.serviceRef); + + // Function throws generic RuntimeException: also returns default + MockContext functionRteCtx = newMockContextReturningService(); + String result2 = ServiceUtil.withService( + StubService.class, + functionRteCtx.bundleContext, + s -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); }, + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result2); + Mockito.verify(functionRteCtx.bundleContext).ungetService(functionRteCtx.serviceRef); + + // UngetService itself throws: exception is swallowed, prior consumer side effects are preserved + MockContext ungetCtx = newMockContextThrowingOnUnget(); + AtomicBoolean consumerInvoked = new AtomicBoolean(false); + ServiceUtil.withService(StubService.class, ungetCtx.bundleContext, s -> consumerInvoked.set(true)); + assertTrue(consumerInvoked.get()); + } + + @Test + public void shouldHandleMissingBundleContext() { + AtomicBoolean consumerInvoked = new AtomicBoolean(false); + + // FrameworkUtil.getBundle() returns null outside an OSGi container: logs error, consumer not invoked + ServiceUtil.withService(StubService.class, s -> consumerInvoked.set(true)); + assertFalse(consumerInvoked.get()); + + String result = ServiceUtil.withService(StubService.class, StubService::getValue, DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result); + } + + /* ------------------------- + BiConsumer and BiFunction + ------------------------- */ + + @Test + public void shouldInvokeBiConsumerAndBiFunction() { + MockContext biConsumerCtx = newMockContextReturningService(); + AtomicBoolean biConsumerInvoked = new AtomicBoolean(false); + ServiceUtil.withService( + StubService.class, + biConsumerCtx.bundleContext, + (s, release) -> { + biConsumerInvoked.set(true); + release.run(); + }); + assertTrue(biConsumerInvoked.get()); + Mockito.verify(biConsumerCtx.bundleContext).ungetService(biConsumerCtx.serviceRef); + + MockContext biFunctionCtx = newMockContextReturningService(); + String result = ServiceUtil.withService( + StubService.class, + biFunctionCtx.bundleContext, + (s, release) -> { + release.run(); + return s.getValue(); + }, + DEFAULT_VALUE); + assertEquals(STUB_VALUE, result); + Mockito.verify(biFunctionCtx.bundleContext).ungetService(biFunctionCtx.serviceRef); + } + + @Test + public void shouldSkipBiVariantsWhenUnavailable() { + AtomicBoolean consumerInvoked = new AtomicBoolean(false); + + // No service registered: null reference + ServiceUtil.withService( + StubService.class, + context.bundleContext(), + (s, release) -> consumerInvoked.set(true)); + assertFalse(consumerInvoked.get()); + + String result1 = ServiceUtil.withService( + StubService.class, + context.bundleContext(), + (s, release) -> s.getValue(), + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result1); + + // Non-null reference but getService returns null + BundleContext mockCtx = newMockContextWithNullService(); + ServiceUtil.withService(StubService.class, mockCtx, (s, release) -> consumerInvoked.set(true)); + assertFalse(consumerInvoked.get()); + + String result2 = ServiceUtil.withService( + StubService.class, + mockCtx, + (s, release) -> s.getValue(), + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result2); + } + + @Test + public void shouldSwallowBiCallbackExceptions() { + // BiConsumer throws RuntimeException: exception swallowed, service released directly + MockContext biConsumerCtx = newMockContextReturningService(); + ServiceUtil.withService( + StubService.class, + biConsumerCtx.bundleContext, + (s, release) -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); }); + Mockito.verify(biConsumerCtx.bundleContext).ungetService(biConsumerCtx.serviceRef); + + // BiFunction throws RuntimeException: default returned, service released directly + MockContext biFunctionCtx = newMockContextReturningService(); + String result = ServiceUtil.withService( + StubService.class, + biFunctionCtx.bundleContext, + (s, release) -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); }, + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result); + Mockito.verify(biFunctionCtx.bundleContext).ungetService(biFunctionCtx.serviceRef); + } + + /* -------------- + Context errors + -------------- */ + + @Test + public void shouldHandleContextException() { + AtomicBoolean consumerInvoked = new AtomicBoolean(false); + BundleContext throwingCtx = newMockContextThrowingOnGetReference(); + + // Consumer: getServiceReference throws -> consumer not invoked + ServiceUtil.withService(StubService.class, throwingCtx, s -> consumerInvoked.set(true)); + assertFalse(consumerInvoked.get()); + + // Function: getServiceReference throws -> default returned + String result1 = ServiceUtil.withService( + StubService.class, + throwingCtx, + StubService::getValue, + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result1); + + // BiConsumer: getServiceReference throws -> consumer not invoked + ServiceUtil.withService( + StubService.class, + throwingCtx, + (s, release) -> consumerInvoked.set(true)); + assertFalse(consumerInvoked.get()); + + // BiFunction: getServiceReference throws -> default returned + String result2 = ServiceUtil.withService( + StubService.class, + throwingCtx, + (s, release) -> s.getValue(), + DEFAULT_VALUE); + assertEquals(DEFAULT_VALUE, result2); + } + + private static BundleContext newMockContextWithNullService() { + BundleContext mockContext = Mockito.mock(BundleContext.class); + @SuppressWarnings("unchecked") + ServiceReference mockRef = Mockito.mock(ServiceReference.class); + Mockito.when(mockContext.getServiceReference(StubService.class)).thenReturn(mockRef); + Mockito.when(mockContext.getService(mockRef)).thenReturn(null); + return mockContext; + } + + private static MockContext newMockContextReturningService() { + BundleContext mockContext = Mockito.mock(BundleContext.class); + @SuppressWarnings("unchecked") + ServiceReference mockRef = Mockito.mock(ServiceReference.class); + Mockito.when(mockContext.getServiceReference(StubService.class)).thenReturn(mockRef); + Mockito.when(mockContext.getService(mockRef)).thenReturn(new StubServiceImpl()); + return new MockContext(mockContext, mockRef); + } + + private static MockContext newMockContextThrowingOnUnget() { + BundleContext mockContext = Mockito.mock(BundleContext.class); + @SuppressWarnings("unchecked") + ServiceReference mockRef = Mockito.mock(ServiceReference.class); + Mockito.when(mockContext.getServiceReference(StubService.class)).thenReturn(mockRef); + Mockito.when(mockContext.getService(mockRef)).thenReturn(new StubServiceImpl()); + Mockito.when(mockContext.ungetService(mockRef)).thenThrow(new IllegalStateException("Unget failed")); + return new MockContext(mockContext, mockRef); + } + + private static BundleContext newMockContextThrowingOnGetReference() { + BundleContext mockContext = Mockito.mock(BundleContext.class); + Mockito.when(mockContext.getServiceReference(StubService.class)) + .thenThrow(new IllegalStateException("Bundle context invalid")); + return mockContext; + } + + private interface StubService { + String getValue(); + } + + private static class StubServiceImpl implements StubService { + @Override + public String getValue() { + return STUB_VALUE; + } + } + + private static class MockContext { + final BundleContext bundleContext; + final ServiceReference serviceRef; + + MockContext(BundleContext bundleContext, ServiceReference serviceRef) { + this.bundleContext = bundleContext; + this.serviceRef = serviceRef; + } + } +} diff --git a/docs/content/dev-tools/configurator.md b/docs/content/dev-tools/configurator.md index a6f0dde9b..984eb88ce 100644 --- a/docs/content/dev-tools/configurator.md +++ b/docs/content/dev-tools/configurator.md @@ -8,7 +8,7 @@ order: 6 Configurator is a tool that allows editing OSGi configurations in a user-friendly manner. -Note: As of Exadel Authoring Kit 2.7.2, Configurator is an experimental feature. You need to specially enable it through your own AEM project by adding a configuration file like the following: +Note: As of Exadel Authoring Kit 2.8.0, Configurator is an experimental feature. You need to specially enable it through your own AEM project by adding a configuration file like the following: Path: `ui.config/src/main/content/jcr_root/apps/your_app/osgiconfig/config/com.exadel.aem.toolkit.core.configurator.services.ConfigChangeListener.xml` ```xml diff --git a/docs/website/package-lock.json b/docs/website/package-lock.json index 930c2c126..c92af29ce 100644 --- a/docs/website/package-lock.json +++ b/docs/website/package-lock.json @@ -1,12 +1,12 @@ { "name": "eak-website", - "version": "2.7.1-SNAPSHOT", + "version": "2.8.0-SNAPSHOT", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eak-website", - "version": "2.7.1-SNAPSHOT", + "version": "2.8.0-SNAPSHOT", "license": "ISC", "dependencies": { "@exadel/esl": "^5.3.0", diff --git a/it.tests/pom.xml b/it.tests/pom.xml index f893d8791..e901acd56 100644 --- a/it.tests/pom.xml +++ b/it.tests/pom.xml @@ -20,7 +20,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT etoolbox-authoring-kit-it.tests diff --git a/plugin/pom.xml b/plugin/pom.xml index 1421836b4..c6d8008f4 100644 --- a/plugin/pom.xml +++ b/plugin/pom.xml @@ -7,7 +7,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT etoolbox-authoring-kit-plugin @@ -40,11 +40,6 @@ org.apache.maven.plugins maven-surefire-plugin - - - **/AllTests.class - - diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/AllTests.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/AllTests.java deleted file mode 100644 index 71e3dba2e..000000000 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/AllTests.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.exadel.aem.toolkit.plugin; - -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.runner.RunWith; -import org.junit.runners.Suite; -import org.junit.runners.Suite.SuiteClasses; - -import com.exadel.aem.toolkit.plugin.exceptions.TerminateOnTest; -import com.exadel.aem.toolkit.plugin.handlers.common.AllowedChildrenTest; -import com.exadel.aem.toolkit.plugin.handlers.common.ComponentsTest; -import com.exadel.aem.toolkit.plugin.handlers.common.EditConfigTest; -import com.exadel.aem.toolkit.plugin.handlers.common.IgnoreFreshnessTest; -import com.exadel.aem.toolkit.plugin.handlers.common.MaxChildrenTest; -import com.exadel.aem.toolkit.plugin.handlers.common.WriteModeTest; -import com.exadel.aem.toolkit.plugin.handlers.dependson.DependsOnTest; -import com.exadel.aem.toolkit.plugin.handlers.placement.CoincidenceTest; -import com.exadel.aem.toolkit.plugin.handlers.placement.IgnoreTest; -import com.exadel.aem.toolkit.plugin.handlers.placement.InheritanceTest; -import com.exadel.aem.toolkit.plugin.handlers.placement.OrderingTest; -import com.exadel.aem.toolkit.plugin.handlers.placement.ReplacementTest; -import com.exadel.aem.toolkit.plugin.handlers.placement.layouts.LayoutTest; -import com.exadel.aem.toolkit.plugin.handlers.widgets.WidgetsTest; -import com.exadel.aem.toolkit.plugin.handlers.widgets.common.WidgetsMetaTest; -import com.exadel.aem.toolkit.plugin.maven.PluginContextRule; -import com.exadel.aem.toolkit.plugin.metadata.MetadataTest; -import com.exadel.aem.toolkit.plugin.metadata.RenderingFilterTest; -import com.exadel.aem.toolkit.plugin.metadata.scripting.ScriptingHelperTest; -import com.exadel.aem.toolkit.plugin.sources.SourcesTest; -import com.exadel.aem.toolkit.plugin.targets.TargetsTest; -import com.exadel.aem.toolkit.plugin.utils.XmlMergeHelperTest; -import com.exadel.aem.toolkit.plugin.utils.ordering.TopologicalSorterTest; -import com.exadel.aem.toolkit.plugin.validators.ValidatorsTest; -import com.exadel.aem.toolkit.plugin.writers.PackageInfoTest; - -/** - * Shortcut class for running all available test cases in a batch - */ -@RunWith(Suite.class) -@SuiteClasses({ - SourcesTest.class, - TargetsTest.class, - RenderingFilterTest.class, - ScriptingHelperTest.class, - XmlMergeHelperTest.class, - TerminateOnTest.class, - - ComponentsTest.class, - EditConfigTest.class, - WriteModeTest.class, - WidgetsTest.class, - WidgetsMetaTest.class, - AllowedChildrenTest.class, - DependsOnTest.class, - IgnoreFreshnessTest.class, - MaxChildrenTest.class, - - LayoutTest.class, - ReplacementTest.class, - IgnoreTest.class, - InheritanceTest.class, - CoincidenceTest.class, - OrderingTest.class, - TopologicalSorterTest.class, - - ValidatorsTest.class, - MetadataTest.class, - PackageInfoTest.class, -}) -public class AllTests { - @BeforeClass - public static void setUp() { - PluginContextRule.initializeContext(); - } - @AfterClass - public static void tearDown() { - PluginContextRule.closeContext(); - } -} diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/EvaluationRule.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/EvaluationRule.java deleted file mode 100644 index c98463e9c..000000000 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/EvaluationRule.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.exadel.aem.toolkit.plugin.maven; - -import org.junit.runner.Description; -import org.junit.runners.model.Statement; - -public class EvaluationRule extends PluginContextRule { - - public EvaluationRule() { - super(); - } - - @Override - public Statement apply(Statement base, Description description) { - return new Statement() { - @Override - public void evaluate() throws Throwable { - boolean adHocInitialization = !isContextInitialized(); - try { - if (adHocInitialization) { - initializeContext(); - } - base.evaluate(); - } finally { - if (adHocInitialization) { - closeContext(); - } - } - } - }; - } -} diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java index aebbb1502..3fb534aa3 100644 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java +++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java @@ -21,6 +21,7 @@ import java.util.function.Consumer; import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.RegExUtils; import org.apache.commons.lang3.StringUtils; import org.junit.Assert; import org.slf4j.Logger; @@ -51,10 +52,6 @@ public PluginContextRenderingRule(FileSystem fileSystem) { } public void test(Class component) { - boolean adHocInitialization = !isContextInitialized(); - if (adHocInitialization) { - initializeContext(); - } String subfolderName = TestConstants.RESOURCE_FOLDER_COMPONENTS + PATH_POSTFIX_GENERIC; if (component.getSimpleName().endsWith(KEYWORD_WIDGET)) { subfolderName = TestConstants.RESOURCE_FOLDER_WIDGETS; @@ -63,10 +60,7 @@ public void test(Class component) { } test(component, subfolderName, - StringUtils.uncapitalize(StringUtils.removePattern(component.getSimpleName(), SUFFIX_PATTERN))); - if (adHocInitialization) { - closeContext(); - } + StringUtils.uncapitalize(RegExUtils.removePattern(component.getSimpleName(), SUFFIX_PATTERN))); } public void test(Class component, String... pathElements) { diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java index 06a6b418b..65b739bdc 100644 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java +++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java @@ -16,12 +16,13 @@ import java.nio.file.Paths; import java.util.Arrays; import java.util.List; +import java.util.function.UnaryOperator; import org.junit.rules.TestRule; import org.junit.runner.Description; import org.junit.runners.model.Statement; -public abstract class PluginContextRule implements TestRule { +public class PluginContextRule implements TestRule { private static final String PLUGIN_MODULE_TARGET = Paths.get("target", "classes").toAbsolutePath().toString(); private static final String PLUGIN_MODULE_TEST_TARGET = Paths.get( "target", "test-classes").toAbsolutePath().toString(); @@ -33,37 +34,52 @@ public abstract class PluginContextRule implements TestRule { API_MODULE_TARGET ); + private static final UnaryOperator PLAIN = (stmt) -> new Statement() { + @Override + public void evaluate() throws Throwable { + try { + setUp(); + stmt.evaluate(); + } finally { + tearDown(); + } + } + }; + + private static final UnaryOperator EXCEPTION_AWARE = (stmt) -> new Statement() { + @Override + public void evaluate() throws Throwable { + try { + setUp(); + exceptionHandler.unmute(); + stmt.evaluate(); + } finally { + if (exceptionHandler != null) { + exceptionHandler.mute(); + } + tearDown(); + } + } + }; + + private static MuteableExceptionHandler exceptionHandler; @Override public Statement apply(Statement statement, Description description) { if (description.getAnnotation(ThrowsPluginException.class) == null && description.getTestClass().getAnnotation(ThrowsPluginException.class) == null) { - return statement; - } - if (exceptionHandler != null) { - exceptionHandler.unmute(); + return PLAIN.apply(statement); } - return new Statement() { - @Override - public void evaluate() throws Throwable { - try { - statement.evaluate(); - } finally { - if (exceptionHandler != null) { - exceptionHandler.mute(); - } - } - } - }; + return EXCEPTION_AWARE.apply(statement); } - public static void initializeContext() { + static void setUp() { PluginSettings settings = PluginSettings .builder() .defaultPathBase(TestConstants.PACKAGE_ROOT_PATH) .build(); - exceptionHandler = new MuteableExceptionHandler(); + exceptionHandler = exceptionHandler == null ? new MuteableExceptionHandler() : exceptionHandler; PluginRuntime.contextBuilder() .classPathElements(CLASSPATH_ELEMENTS) .settings(settings) @@ -71,11 +87,7 @@ public static void initializeContext() { .build(); } - public static void closeContext() { + static void tearDown() { PluginRuntime.close(); } - - protected static boolean isContextInitialized() { - return exceptionHandler != null; - } } diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java index fd07d45e4..14d833b2e 100644 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java +++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java @@ -32,7 +32,6 @@ import org.apache.commons.lang3.StringUtils; import org.junit.Assert; -import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; @@ -45,8 +44,7 @@ import com.exadel.aem.toolkit.plugin.annotations.cases.NestedAnnotations; import com.exadel.aem.toolkit.plugin.exceptions.PluginException; import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ScriptedComponent; -import com.exadel.aem.toolkit.plugin.maven.FileSystemRule; -import com.exadel.aem.toolkit.plugin.maven.PluginContextRenderingRule; +import com.exadel.aem.toolkit.plugin.maven.PluginContextRule; import com.exadel.aem.toolkit.plugin.maven.ThrowsPluginException; public class MetadataTest { @@ -54,11 +52,8 @@ public class MetadataTest { private static final String PN_OPTIONS = "moreOptions"; private static final String PN_NUMBERS = "numbers"; - @ClassRule - public static FileSystemRule fileSystemHost = new FileSystemRule(); - @Rule - public PluginContextRenderingRule pluginContext = new PluginContextRenderingRule(fileSystemHost.getFileSystem()); + public PluginContextRule context = new PluginContextRule(); @Test public void testAnnotationCreation() { diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java index 6e0c5f036..2c61d4c84 100644 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java +++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java @@ -18,6 +18,7 @@ import java.util.List; import org.junit.Assert; +import org.junit.Rule; import org.junit.Test; import com.exadel.aem.toolkit.api.annotations.main.Setting; @@ -28,6 +29,7 @@ import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ScriptedFieldset1; import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ScriptedFieldset2; +import com.exadel.aem.toolkit.plugin.maven.PluginContextRule; import com.exadel.aem.toolkit.plugin.sources.Sources; import com.exadel.aem.toolkit.plugin.utils.DialogConstants; @@ -36,6 +38,9 @@ public class ScriptingHelperTest { private static final String SCRIPT_CONTAINER = "Lorem {ipsum dolor} ${sit amet}, consectetur \"${adipiscing} elit\"," + "sed do @{eiusmod tempor} incididunt ut @labore et @dolore 'magna @aliqua'"; + @Rule + public PluginContextRule context = new PluginContextRule(); + @Test public void testInlineScriptExtraction() { SubstringMatcher substringMatcher = new SubstringMatcher( diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java index 070c772d5..7bfe56a16 100644 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java +++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java @@ -26,12 +26,12 @@ import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ComplexComponent1; import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.viewpattern.component1.views.DesignDialogView; import com.exadel.aem.toolkit.plugin.handlers.common.cases.policies.AllowedChildrenTestCases; -import com.exadel.aem.toolkit.plugin.maven.EvaluationRule; +import com.exadel.aem.toolkit.plugin.maven.PluginContextRule; public class SourcesTest { @Rule - public EvaluationRule evaluation = new EvaluationRule(); + public PluginContextRule context = new PluginContextRule(); @Test public void testCacheMetadata1() { diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java index b53f67093..cc4596f93 100644 --- a/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java +++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java @@ -20,6 +20,7 @@ import org.apache.commons.lang3.StringUtils; import org.junit.Assert; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -28,6 +29,7 @@ import com.exadel.aem.toolkit.api.handlers.Target; import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.plugin.adapters.DomAdapter; +import com.exadel.aem.toolkit.plugin.maven.PluginContextRule; import com.exadel.aem.toolkit.plugin.maven.PluginRuntime; public class TargetsTest { @@ -43,6 +45,9 @@ public class TargetsTest { private static final int TIER_1_CHILD_COUNT = 10; + @Rule + public PluginContextRule context = new PluginContextRule(); + private Target testable; @Before diff --git a/pom.xml b/pom.xml index 10f76f788..98039885c 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT Exadel Toolbox Authoring Kit Automates generating and managing rich and responsive authoring interfaces for Adobe Experience Manager @@ -615,7 +615,7 @@ org.apache.sling org.apache.sling.api - 2.22.0 + 2.24.0 provided diff --git a/ui.apps/package-lock.json b/ui.apps/package-lock.json index ae018f7c3..8ed00e28f 100644 --- a/ui.apps/package-lock.json +++ b/ui.apps/package-lock.json @@ -1,12 +1,12 @@ { "name": "depends-on", - "version": "2.7.1-SNAPSHOT", + "version": "2.8.0-SNAPSHOT", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "depends-on", - "version": "2.7.1-SNAPSHOT", + "version": "2.8.0-SNAPSHOT", "license": "Apache-2.0", "devDependencies": { "eslint": "^9.28.0", diff --git a/ui.apps/package.json b/ui.apps/package.json index a06072973..3e4e6d15b 100644 --- a/ui.apps/package.json +++ b/ui.apps/package.json @@ -1,7 +1,7 @@ { "name": "depends-on", "description": "A clientlib that executes defined action on dependent fields", - "version": "2.7.1-SNAPSHOT", + "version": "2.8.0-SNAPSHOT", "private": true, "devDependencies": { "eslint": "^9.28.0", diff --git a/ui.apps/pom.xml b/ui.apps/pom.xml index d801d9dcd..c5d16e93b 100644 --- a/ui.apps/pom.xml +++ b/ui.apps/pom.xml @@ -7,7 +7,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT etoolbox-authoring-kit-ui.apps diff --git a/ui.config/pom.xml b/ui.config/pom.xml index cb10148ad..1147356f2 100644 --- a/ui.config/pom.xml +++ b/ui.config/pom.xml @@ -8,7 +8,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT etoolbox-authoring-kit-ui.config @@ -57,13 +57,6 @@ true - - org.sonatype.plugins - nexus-staging-maven-plugin - - true - - diff --git a/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml b/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml index 735ea2e1c..164f4b581 100644 --- a/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml +++ b/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml @@ -3,5 +3,5 @@ xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" jcr:primaryType="sling:OsgiConfig" - user.mapping="[com.exadel.etoolbox.authoring-kit-core:eak-service=[eak-service]]" + user.mapping="[com.exadel.etoolbox.authoring-kit-core:eak-service=eak-service,com.exadel.etoolbox.authoring-kit-core:eak-scripting=sling-scripting]" /> diff --git a/ui.content/pom.xml b/ui.content/pom.xml index 595afd49c..401adf0d9 100644 --- a/ui.content/pom.xml +++ b/ui.content/pom.xml @@ -8,7 +8,7 @@ com.exadel.etoolbox etoolbox-authoring-kit - 2.7.1-SNAPSHOT + 2.8.0-SNAPSHOT etoolbox-authoring-kit-ui.content