From 933f8367e55e0484f17e10690cfecf2ccaa5a969 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 23 Apr 2026 08:14:10 +0300 Subject: [PATCH 01/92] [Tech] Change project version to 2.8.0-SNAPSHOT --- all/pom.xml | 2 +- core/pom.xml | 2 +- docs/website/package-lock.json | 4 ++-- it.tests/pom.xml | 2 +- plugin/pom.xml | 2 +- pom.xml | 2 +- ui.apps/package-lock.json | 4 ++-- ui.apps/package.json | 2 +- ui.apps/pom.xml | 2 +- ui.config/pom.xml | 2 +- ui.content/pom.xml | 2 +- 11 files changed, 13 insertions(+), 13 deletions(-) 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..7692862c8 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 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..330c0f1f5 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 diff --git a/pom.xml b/pom.xml index 10f76f788..e5ef15ba2 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 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..a43ce1c3f 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 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 From 49d42fd9754d1bcf286d8953bed24243c910fcec Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 23 Apr 2026 15:49:54 +0300 Subject: [PATCH 02/92] [EAK-651] Implement basic Relay Resource Provider logic --- .../core/relay/models/RelayResource.java | 59 +++++ .../core/relay/services/RelayConfig.java | 49 ++++ .../core/relay/services/RelayProvider.java | 244 ++++++++++++++++++ .../relay/services/RelayProviderHost.java | 106 ++++++++ .../core/relay/services/ScriptSampler.java | 83 ++++++ .../toolkit/core/relay/utils/PathHelper.java | 34 +++ .../core/relay/utils/ResourceHelper.java | 112 ++++++++ pom.xml | 2 +- 8 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.java create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java 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..f0755d4cf --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java @@ -0,0 +1,59 @@ +/* + * 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.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceMetadata; +import org.apache.sling.api.resource.ResourceWrapper; + +import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; + +public class RelayResource extends ResourceWrapper { + + private final String path; + private final ResourceMetadata resourceMetadata; + + public RelayResource(@Nonnull Resource original, @Nonnull String path) { + super(original); + this.path = path; + // 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()); + if (this.resourceMetadata.containsKey(ResourceMetadata.RESOLUTION_PATH)) { + this.resourceMetadata.put(ResourceMetadata.RESOLUTION_PATH, path); + } + } + + @Override + @Nonnull + public String getPath() { + return path; + } + + @Override + @Nonnull + public ResourceMetadata getResourceMetadata() { + return resourceMetadata; + } + + @Override + public @Nonnull Iterator listChildren() { + return ResourceHelper.listChildren(getResource(), path); + } +} + 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..5f02bb71f --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java @@ -0,0 +1,49 @@ +/* + * 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.ObjectClassDefinition; + +@ObjectClassDefinition(name = "EToolbox Authoring Kit - Relay Configuration") +public @interface RelayConfig { + + @AttributeDefinition( + name = "Enable", + description = "Is the relay enabled?" + ) + boolean enabled(); + + @AttributeDefinition( + name = "Path mappings", + description = "List of path mapping rules." + ) + String[] pathMappings(); + + @AttributeDefinition( + name = "User mappings", + description = "Optional list of user mapping rules. " + + "The mapping target is either \"user:password\" or \"registered_subservice\"." + ) + String[] userMappings() default {}; + + @AttributeDefinition( + name = "Report paths", + description = "Optional list of XPath expressions. " + + "Target paths matching the list will be reported as changed as the relay is enabled or disabled." + + ) + String[] reportedPaths() default {}; +} + 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..0c574f190 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java @@ -0,0 +1,244 @@ +/* + * 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.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.jcr.Session; + +import org.apache.commons.lang3.StringUtils; +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.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.CoreConstants; +import com.exadel.aem.toolkit.core.relay.models.RelayResource; +import com.exadel.aem.toolkit.core.relay.utils.PathHelper; +import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; +import com.exadel.aem.toolkit.core.utils.ResolverUtil; + +class RelayProvider extends ResourceProvider implements ResourceChangeListener, ExternalResourceChangeListener { + + private static final Logger LOG = LoggerFactory.getLogger(RelayProvider.class); + + private ResourceResolverFactory resolverFactory; + private ScriptSampler scriptSampler; + private String source; + private String target; + private Map userMappings; + + private RelayProvider() { + } + + /* ------------------------ + ResourceProvider members + ------------------------ */ + + @Override + public Resource getResource( + @Nonnull ResolveContext context, + @Nonnull String path, + @Nonnull ResourceContext resourceContext, + @Nullable Resource parent) { + + if (!StringUtils.equals(path, source) && !StringUtils.startsWith(path, source + CoreConstants.SEPARATOR_SLASH)) { + return null; + } + String targetPath = target + StringUtils.substring(path, source.length()); + return ResourceHelper.getResource( + context.getResourceResolver(), + this::getMappedResourceResolver, + targetPath, + resource -> resource != null ? new RelayResource(resource, path) : null, + () -> ResourceHelper.getResource( + context.getParentResourceProvider(), + context.getParentResolveContext(), + path, + resourceContext, + parent)); + } + + @Override + @Nullable + public Iterator listChildren(@Nonnull ResolveContext context, @Nonnull Resource parent) { + String path = parent.getPath(); + String targetPath = PathHelper.replace(path, source, target); + Resource targetResource = ResourceHelper.getResource( + context.getResourceResolver(), + this::getMappedResourceResolver, + targetPath, + resource -> resource != null ? new RelayResource(resource, path) : null, + () -> ResourceHelper.getResource( + context.getParentResourceProvider(), + context.getParentResolveContext(), + path, + null, + parent)); + if (targetResource == null) { + return null; + } else if (targetResource.getPath().equals(parent.getPath())) { + // We have fallen back to an "original" resource, so we should iterate through it without any mapping + return ResourceHelper.listChildren( + context.getParentResourceProvider(), + context.getParentResolveContext(), + parent); + } + return ResourceHelper.listChildren(targetResource, parent.getPath()); + } + + @Override + public void start(@Nonnull ProviderContext providerContext) { + LOG.info("Relay provider for {} -> {} is starting", source, target); + super.start(providerContext); + if (!StringUtils.startsWithAny(source, "/apps", "/libs") + || !StringUtils.startsWith(target, "/content")) { + return; + } + try (ResourceResolver resolver = ResolverUtil.newResolver(resolverFactory)) { + Session session = resolver.adaptTo(Session.class); + scriptSampler = ScriptSampler.from(Objects.requireNonNull(session), source, target); + Collection declaredChanges = scriptSampler.generateChanges(); + if (!declaredChanges.isEmpty()) { + providerContext.getObservationReporter().reportChanges(declaredChanges, false); + } + } catch (LoginException e) { + LOG.error("Failed to create a JCR session for script sampling at {}", target, e); + } + } + + @Override + public void stop() { + LOG.info("Relay provider for {} -> {} is stopping", source, target); + if (getProviderContext() != null && scriptSampler != null) { + Collection declaredChanges = scriptSampler.generateChanges(); + if (!declaredChanges.isEmpty()) { + getProviderContext().getObservationReporter().reportChanges(declaredChanges, false); + } + } + super.stop(); + } + + /* ------------------------------ + ResourceChangeListener members + ------------------------------ */ + + @Override + public void onChange(@Nonnull List changes) { + if (getProviderContext() == null) { + return; + } + List mappedChanges = changes.stream() + .map(change -> new ResourceChange( + change.getType(), + PathHelper.replace(change.getPath(), target, source), + change.isExternal())) + .collect(Collectors.toList()); + getProviderContext().getObservationReporter().reportChanges(mappedChanges, false); + } + + /* --------------- + Service methods + --------------- */ + + private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { + String mappedId = userMappings.get(resolver.getUserID()); + if (mappedId != null && !mappedId.equals(resolver.getUserID())) { + Map authInfo = new HashMap<>(); + try { + if (StringUtils.contains(mappedId, CoreConstants.SEPARATOR_COLON)) { + authInfo.put(ResourceResolverFactory.USER, StringUtils.substringBefore(mappedId, CoreConstants.SEPARATOR_COLON)); + authInfo.put(ResourceResolverFactory.PASSWORD, StringUtils.substringAfter(mappedId, CoreConstants.SEPARATOR_COLON).toCharArray()); + return resolverFactory.getResourceResolver(authInfo); + } + authInfo.put(ResourceResolverFactory.SUBSERVICE, mappedId); + return resolverFactory.getServiceResourceResolver(authInfo); + } catch (LoginException e) { + LOG.error( + "Failed to create a resource resolver for user {}", + authInfo.containsKey(ResourceResolverFactory.USER) + ? authInfo.get(ResourceResolverFactory.USER) + : authInfo.get(ResourceResolverFactory.SUBSERVICE), + e); + } + } + return resolver; + } + + /* ------------- + Factory logic + ------------- */ + + static Builder builder() { + return new Builder(); + } + + @SuppressWarnings("unused") + static class Builder { + + private ResourceResolverFactory resolverFactory; + private String source; + private String target; + private Map userMappings; + + private Builder() { + } + + Builder resolverFactory(ResourceResolverFactory value) { + resolverFactory = value; + return this; + } + + Builder source(String value) { + this.source = value; + return this; + } + + Builder target(String value) { + this.target = value; + return this; + } + + Builder userMappings(Map value) { + this.userMappings = value; + return this; + } + + RelayProvider build() { + RelayProvider result = new RelayProvider(); + result.resolverFactory = resolverFactory; + result.source = source; + result.target = target; + result.userMappings = userMappings; + return result; + } + } +} + 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..ae5eb4d6d --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java @@ -0,0 +1,106 @@ +/* + * 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.Dictionary; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.resource.ResourceResolverFactory; +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.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; + +@Component(immediate = true, service = RelayProviderHost.class) +@Designate(ocd = RelayConfig.class) +public class RelayProviderHost { + + private static final String MAPPING_DELIMITER = "->"; + + @Reference + private transient ResourceResolverFactory resolverFactory; + + private final List> registrations = new ArrayList<>(); + + private final ReentrantLock lock = new ReentrantLock(); + + @Activate + private void activate(BundleContext bundleContext, RelayConfig config) { + lock.lock(); + try { + deactivate(); + Map userMappings = new HashMap<>(); + for (String userMapping : config.userMappings()) { + String source = StringUtils.substringBefore(userMapping, MAPPING_DELIMITER).trim(); + String target = StringUtils.substringAfter(userMapping, MAPPING_DELIMITER).trim(); + if (StringUtils.isAnyEmpty(source, target)) { + continue; + } + userMappings.put(source, target); + } + for (String pathMapping : config.pathMappings()) { + String source = StringUtils.substringBefore(pathMapping, MAPPING_DELIMITER).trim(); + String target = StringUtils.substringAfter(pathMapping, MAPPING_DELIMITER).trim(); + if (StringUtils.isAnyEmpty(source, target)) { + continue; + } + createRelay(bundleContext, source, target, userMappings); + } + } finally { + lock.unlock(); + } + } + + @Deactivate + private void deactivate() { + lock.lock(); + try { + for (ServiceRegistration registration : registrations) { + registration.unregister(); + } + registrations.clear(); + } finally { + lock.unlock(); + } + } + + private void createRelay(BundleContext bundleContext, String source, String target, Map userMappings) { + Dictionary properties = new Hashtable<>(); + properties.put(ResourceProvider.PROPERTY_ROOT, source); + properties.put(ResourceChangeListener.PATHS, new String[]{target}); + ServiceRegistration registration = bundleContext.registerService( + new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, + RelayProvider + .builder() + .resolverFactory(resolverFactory) + .source(source) + .target(target) + .userMappings(userMappings) + .build(), + properties); + registrations.add(registration); + } +} + diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.java new file mode 100644 index 000000000..5d2cad4cf --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.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.services; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.jcr.NodeIterator; +import javax.jcr.RepositoryException; +import javax.jcr.Session; +import javax.jcr.query.Query; +import javax.jcr.query.QueryResult; + +import org.apache.commons.lang3.StringUtils; +import org.apache.jackrabbit.util.Text; +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.utils.PathHelper; + +class ScriptSampler { + + private static final Logger LOG = LoggerFactory.getLogger(ScriptSampler.class); + + private static final List SCRIPT_EXTENSIONS = Arrays.asList(".java", ".jsp", ".js", ".html"); + + private static final String QUERY_TEMPLATE = "SELECT * FROM [nt:file] AS file WHERE ISDESCENDANTNODE(file, '%s')"; + + private final Map samples = new HashMap<>(); + + public Collection generateChanges() { + return samples.values() + .stream() + .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) + .collect(Collectors.toList()); + } + + public boolean isEmpty() { + return samples.isEmpty(); + } + + public static ScriptSampler from(Session session, String source, String target) { + ScriptSampler result = new ScriptSampler(); + try { + Query query = session + .getWorkspace() + .getQueryManager() + .createQuery(String.format(QUERY_TEMPLATE, Text.escape(target, '%', true)), Query.JCR_SQL2); + QueryResult queryResult = query.execute(); + NodeIterator nodes = queryResult.getNodes(); + while (nodes.hasNext()) { + String path = nodes.nextNode().getPath(); + if (SCRIPT_EXTENSIONS.stream().anyMatch(path::endsWith)) { + result.samples.put( + StringUtils.substringAfterLast(path, CoreConstants.SEPARATOR_DOT), + PathHelper.replace(path, target, source)); + } + if (result.samples.size() >= SCRIPT_EXTENSIONS.size()) { + break; + } + } + } catch (RepositoryException e) { + LOG.error("Failed to collect script samples at {}", target, e); + } + return result; + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java new file mode 100644 index 000000000..73bd9568e --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java @@ -0,0 +1,34 @@ +/* + * 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; + +public class PathHelper { + + private PathHelper() { + } + + public static String replace(String path, String source, String target) { + if (StringUtils.equals(path, source)) { + return target; + } + if (StringUtils.startsWith(path, source + CoreConstants.SEPARATOR_SLASH)) { + return target + path.substring(source.length()); + } + return path; + } +} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java new file mode 100644 index 000000000..b2ea6ac23 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.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.utils; + +import java.util.Iterator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.stream.StreamSupport; + +import org.apache.commons.collections4.IteratorUtils; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +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; + +public class ResourceHelper { + + private static final Logger LOG = LoggerFactory.getLogger(ResourceHelper.class); + + private static final String KEY_SUBSIDIARY = "subsidiary"; + + private ResourceHelper() {} + + public static Resource getResource( + ResourceResolver basicResolver, + UnaryOperator resolverModifier, + String path, + Function onSuccess, + Supplier onFailure) { + + ResourceResolver effectiveResolver = resolverModifier.apply(basicResolver); + Resource result = effectiveResolver.getResource(path); + if (result == null) { + LOG.warn("Could not resolve {} with user {}", path, effectiveResolver.getUserID()); + return onFailure.get(); + } + if (!effectiveResolver.equals(basicResolver)) { + // We have created another {@link ResourceResolver} via the {@code resolverModifier}. We cannot close it + // in place -- instead, 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 {@code basicResolver} so that it will be + // automatically closed when the {@code basicResolver} is closed by Sling + // See {@link ResourceResolver#getPropertyMap()}. + ResourceResolver existingSubsidiary = (ResourceResolver) basicResolver.getPropertyMap().get(KEY_SUBSIDIARY); + if (existingSubsidiary != null) { + LOG.warn("A subsidiary resolver for {} will close", existingSubsidiary.getUserID()); + existingSubsidiary.close(); + } + basicResolver.getPropertyMap().put(KEY_SUBSIDIARY, effectiveResolver); + } + LOG.debug("Resolved {} to {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); + return onSuccess.apply(result); + } + + @SuppressWarnings("unchecked") + public static Resource getResource( + ResourceProvider resourceProvider, + ResolveContext resolveContext, + String path, + ResourceContext resourceContext, + Resource parent) { + if (resourceProvider == null || resolveContext == null) { + reportMissingContext(path); + return null; + } + LOG.debug("Falling back to parent resource provider for {}", path); + return ((ResourceProvider)resourceProvider).getResource((ResolveContext) resolveContext, path, resourceContext, parent); + } + + @SuppressWarnings("unchecked") + public static Iterator listChildren( + ResourceProvider resourceProvider, + ResolveContext resolveContext, + Resource parent) { + if (resourceProvider == null || resolveContext == null) { + reportMissingContext(parent.getPath()); + return null; + } + return ((ResourceProvider)resourceProvider).listChildren((ResolveContext) resolveContext, parent); + } + + public static Iterator listChildren(Resource target, String path) { + if (target instanceof RelayResource) { + return target.listChildren(); + } + return StreamSupport.stream(IteratorUtils.asIterable(target.listChildren()).spliterator(), false) + .map(child -> new RelayResource(child, path + CoreConstants.SEPARATOR_SLASH + child.getName())) + .map(Resource.class::cast) + .iterator(); + } + + private static void reportMissingContext(String path) { + LOG.warn("Missing resolution context for {}", path); + } +} diff --git a/pom.xml b/pom.xml index e5ef15ba2..98039885c 100644 --- a/pom.xml +++ b/pom.xml @@ -615,7 +615,7 @@ org.apache.sling org.apache.sling.api - 2.22.0 + 2.24.0 provided From 98e81025605eedddac963e2fbdda04b1e9de19bc Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 9 Apr 2026 01:09:21 +0300 Subject: [PATCH 03/92] [EAK-647] Introduce ResolverUtil to unify resource resolver handling --- .../services/ConfigChangeListener.java | 22 ++--- .../core/configurator/utils/ResolverUtil.java | 82 +++++++++++++++++++ ...impl.ServiceUserMapperImpl.amended-eak.xml | 2 +- 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java 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..05548c689 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.configurator.utils.ResolverUtil; import com.exadel.aem.toolkit.core.utils.ValueMapUtil; /** @@ -71,6 +72,8 @@ public class ConfigChangeListener implements ResourceChangeListener, ExternalRes private static final String UPDATABLE_CONFIG_TOKEN = "?"; + private static final String SERVICE_USER_ID = "eak-service"; + @Reference private transient ConfigurationAdmin configurationAdmin; @@ -96,7 +99,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, SERVICE_USER_ID)) { if (ArrayUtils.isNotEmpty(config.cleanUp())) { activateWithCleanUp(resolver, config.cleanUp()); } @@ -225,7 +228,7 @@ public void onChange(List list) { return; } asyncExecutor.submit(() -> { - try (ResourceResolver resolver = newResolver()) { + try (ResourceResolver resolver = ResolverUtil.newResolver(resourceResolverFactory, SERVICE_USER_ID)) { for (String path : configsToUpdate) { Resource resource = resolver.getResource(path); if (resource == null) { @@ -247,21 +250,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/utils/ResolverUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java new file mode 100644 index 000000000..3a4d8fba3 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java @@ -0,0 +1,82 @@ +/* + * 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.configurator.utils; + +import java.util.Collections; + +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.jetbrains.annotations.NotNull; + +/** + * Provides utility methods for creating {@link ResourceResolver} instances in the context of + * {@code EToolbox Configurator} + *

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 { + + /** + * Default (instantiation-restricting) constructor + */ + private ResolverUtil() { + } + + /** + * Creates a new {@link ResourceResolver} instance for the given username using the provided resource resolver + * factory + * @param factory The {@code ResourceResolverFactory} instance + * @param userName The username for which the resolver should be created + * @return New instance of {@code ResourceResolver} + * @throws LoginException If the resolver cannot be created + */ + @NotNull + public static ResourceResolver newResolver( + @NotNull ResourceResolverFactory factory, + @NotNull String userName) throws LoginException { + + return factory.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, userName) + ); + } + + /** + * 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 + * @param name The username or subservice name for which the resolver should be created + * @return New instance of {@code ResourceResolver} + * @throws LoginException If the resolver cannot be created + */ + @NotNull + public static ResourceResolver newResolver( + @NotNull SlingHttpServletRequest request, + @NotNull String name) throws LoginException { + + SlingBindings bindings = (SlingBindings) request.getAttribute(SlingBindings.class.getName()); + SlingScriptHelper scriptHelper = bindings != null ? (SlingScriptHelper) bindings.get("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, name) + ); + } +} 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]" /> From d7514f3ea046126dd14e7682c9346cfc98ea267a Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Sat, 11 Apr 2026 19:06:48 +0300 Subject: [PATCH 04/92] [EAK-647] Add ResolverUtil tests --- .../core/configurator/utils/ResolverUtil.java | 2 +- .../java/com/exadel/aem/toolkit/AllTests.java | 19 ++- .../configurator/utils/ResolverUtilTest.java | 120 ++++++++++++++++++ 3 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtilTest.java diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java index 3a4d8fba3..310915bf7 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java @@ -68,7 +68,7 @@ public static ResourceResolver newResolver( @NotNull String name) throws LoginException { SlingBindings bindings = (SlingBindings) request.getAttribute(SlingBindings.class.getName()); - SlingScriptHelper scriptHelper = bindings != null ? (SlingScriptHelper) bindings.get("sling") : null; + SlingScriptHelper scriptHelper = bindings != null ? (SlingScriptHelper) bindings.get(SlingBindings.SLING) : null; ResourceResolverFactory factory = scriptHelper != null ? scriptHelper.getService(ResourceResolverFactory.class) : null; diff --git a/core/src/test/java/com/exadel/aem/toolkit/AllTests.java b/core/src/test/java/com/exadel/aem/toolkit/AllTests.java index 865b75bde..7bae669bc 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/AllTests.java +++ b/core/src/test/java/com/exadel/aem/toolkit/AllTests.java @@ -21,12 +21,13 @@ 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.services.ConfigDataHelperTest; 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.form.FieldProcessorTest; +import com.exadel.aem.toolkit.core.configurator.servlets.form.ValueProcessorTest; 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.configurator.utils.ResolverUtilTest; import com.exadel.aem.toolkit.core.injectors.ChildInjectorTest; import com.exadel.aem.toolkit.core.injectors.ChildrenInjectorTest; import com.exadel.aem.toolkit.core.injectors.EToolboxListInjectorTest; @@ -49,6 +50,7 @@ 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; +import com.exadel.aem.toolkit.core.utils.ResourceFactoryTest; /** * Shortcut class for running all available test cases in a batch @@ -84,15 +86,18 @@ ConfigChangeListenerTest.class, ConfigDataSourceTest.class, - ConfigDataUtilTest.class, + ConfigDataHelperTest.class, ConfigDefinitionTest.class, ReplicationServletTest.class, RenderConditionTest.class, - FieldUtilTest.class, + FieldProcessorTest.class, PermissionUtilTest.class, - ValueUtilTest.class, + ResolverUtilTest.class, + ValueProcessorTest.class, - TopLevelPolicyFilterTest.class + TopLevelPolicyFilterTest.class, + + ResourceFactoryTest.class }) public class AllTests { } diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtilTest.java new file mode 100644 index 000000000..5f06158ec --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtilTest.java @@ -0,0 +1,120 @@ +/* + * 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.configurator.utils; + +import java.util.Collections; + +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.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import io.wcm.testing.mock.aem.junit.AemContext; +import static org.junit.Assert.assertEquals; + +import com.exadel.aem.toolkit.core.AemContextFactory; + +@RunWith(MockitoJUnitRunner.class) +public class ResolverUtilTest { + + private static final String USER_NAME = "testServiceUser"; + + private static final String FACTORY_ERROR = "Could not obtain ResourceResolverFactory"; + private static final String LOGIN_ERROR = "Test error"; + + @Rule + public 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, USER_NAME))).thenReturn(expected); + + ResourceResolver resultFromFactory = ResolverUtil.newResolver(factory, USER_NAME); + assertEquals(expected, resultFromFactory); + + ResourceResolver resultFromRequest = ResolverUtil.newResolver(newRequestWithFactory(factory), USER_NAME); + assertEquals(expected, resultFromRequest); + } + + @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(newRequestWithFactory(factory), LOGIN_ERROR); + } + + 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, USER_NAME)) { + 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, USER_NAME)) { + Assert.fail("Expected LoginException"); + } catch (LoginException e) { + assertEquals(errorMessage, e.getMessage()); + } + } +} + From bb062ee7ede85844683495313861fd51e29f48a2 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 27 Apr 2026 09:49:23 +0300 Subject: [PATCH 05/92] [Tech] Remove obsolete plugin reference --- ui.config/pom.xml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ui.config/pom.xml b/ui.config/pom.xml index a43ce1c3f..1147356f2 100644 --- a/ui.config/pom.xml +++ b/ui.config/pom.xml @@ -57,13 +57,6 @@ true - - org.sonatype.plugins - nexus-staging-maven-plugin - - true - - From cc0a2ba1ae23315f45a19a1779b0cd7c3fa8fb85 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 27 Apr 2026 09:52:06 +0300 Subject: [PATCH 06/92] [Tech] Simplify launching tests --- core/pom.xml | 5 - .../java/com/exadel/aem/toolkit/AllTests.java | 103 ------------------ .../exadel/aem/toolkit/plugin/AllTests.java | 92 ---------------- .../toolkit/plugin/maven/EvaluationRule.java | 44 -------- .../maven/PluginContextRenderingRule.java | 13 +-- .../plugin/maven/PluginContextRule.java | 60 ++++++---- .../toolkit/plugin/metadata/MetadataTest.java | 9 +- .../scripting/ScriptingHelperTest.java | 5 + .../toolkit/plugin/sources/SourcesTest.java | 4 +- .../toolkit/plugin/targets/TargetsTest.java | 5 + 10 files changed, 55 insertions(+), 285 deletions(-) delete mode 100644 core/src/test/java/com/exadel/aem/toolkit/AllTests.java delete mode 100644 plugin/src/test/com/exadel/aem/toolkit/plugin/AllTests.java delete mode 100644 plugin/src/test/com/exadel/aem/toolkit/plugin/maven/EvaluationRule.java diff --git a/core/pom.xml b/core/pom.xml index 7692862c8..8f515e73b 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -74,11 +74,6 @@ org.apache.maven.plugins maven-surefire-plugin - - - **/AllTests.class - - 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 7bae669bc..000000000 --- a/core/src/test/java/com/exadel/aem/toolkit/AllTests.java +++ /dev/null @@ -1,103 +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.ConfigDataHelperTest; -import com.exadel.aem.toolkit.core.configurator.servlets.form.ConfigDataSourceTest; -import com.exadel.aem.toolkit.core.configurator.servlets.form.FieldProcessorTest; -import com.exadel.aem.toolkit.core.configurator.servlets.form.ValueProcessorTest; -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.configurator.utils.ResolverUtilTest; -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; -import com.exadel.aem.toolkit.core.utils.ResourceFactoryTest; - -/** - * 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, - ConfigDataHelperTest.class, - ConfigDefinitionTest.class, - ReplicationServletTest.class, - RenderConditionTest.class, - FieldProcessorTest.class, - PermissionUtilTest.class, - ResolverUtilTest.class, - ValueProcessorTest.class, - - TopLevelPolicyFilterTest.class, - - ResourceFactoryTest.class -}) -public class AllTests { -} 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..499dc02e4 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) { @@ -84,6 +78,7 @@ public void test(Class component, Path sampleFilesPath, Consumer } private void test(Class component, String createdFilesPath, Path sampleFilesPath, Consumer preparation) { + setUp(); if (preparation != null) { preparation.accept(fileSystem); } @@ -101,6 +96,8 @@ private void test(Class component, String createdFilesPath, Path sampleFilesP LOG.error(INSTANTIATION_EXCEPTION_MESSAGE, component.getName(), cnfEx); } catch (IOException ioEx) { LOG.error(CLEANUP_EXCEPTION_MESSAGE, component.getName(), ioEx); + } finally { + tearDown(); } } 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 From 2a86c63d692a04fa7eccb8f40b4fd9e47d324ac3 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 27 Apr 2026 09:54:55 +0300 Subject: [PATCH 07/92] [EAK-651] Move ResolverUtil --- .../services/ConfigChangeListener.java | 8 +++----- .../{configurator => }/utils/ResolverUtil.java | 18 +++++++----------- .../utils/ResolverUtilTest.java | 14 ++++++-------- 3 files changed, 16 insertions(+), 24 deletions(-) rename core/src/main/java/com/exadel/aem/toolkit/core/{configurator => }/utils/ResolverUtil.java (83%) rename core/src/test/java/com/exadel/aem/toolkit/core/{configurator => }/utils/ResolverUtilTest.java (94%) 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 05548c689..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,7 +51,7 @@ import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.core.configurator.ConfiguratorConstants; -import com.exadel.aem.toolkit.core.configurator.utils.ResolverUtil; +import com.exadel.aem.toolkit.core.utils.ResolverUtil; import com.exadel.aem.toolkit.core.utils.ValueMapUtil; /** @@ -72,8 +72,6 @@ public class ConfigChangeListener implements ResourceChangeListener, ExternalRes private static final String UPDATABLE_CONFIG_TOKEN = "?"; - private static final String SERVICE_USER_ID = "eak-service"; - @Reference private transient ConfigurationAdmin configurationAdmin; @@ -99,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 = ResolverUtil.newResolver(resourceResolverFactory, SERVICE_USER_ID)) { + try (ResourceResolver resolver = ResolverUtil.newResolver(resourceResolverFactory)) { if (ArrayUtils.isNotEmpty(config.cleanUp())) { activateWithCleanUp(resolver, config.cleanUp()); } @@ -228,7 +226,7 @@ public void onChange(List list) { return; } asyncExecutor.submit(() -> { - try (ResourceResolver resolver = ResolverUtil.newResolver(resourceResolverFactory, SERVICE_USER_ID)) { + try (ResourceResolver resolver = ResolverUtil.newResolver(resourceResolverFactory)) { for (String path : configsToUpdate) { Resource resource = resolver.getResource(path); if (resource == null) { diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java similarity index 83% rename from core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java rename to core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java index 310915bf7..919acdc4c 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtil.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java @@ -11,7 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.exadel.aem.toolkit.core.configurator.utils; +package com.exadel.aem.toolkit.core.utils; import java.util.Collections; @@ -30,6 +30,8 @@ */ public class ResolverUtil { + private static final String SERVICE_USER_ID = "eak-service"; + /** * Default (instantiation-restricting) constructor */ @@ -40,17 +42,14 @@ private ResolverUtil() { * Creates a new {@link ResourceResolver} instance for the given username using the provided resource resolver * factory * @param factory The {@code ResourceResolverFactory} instance - * @param userName The username for which the resolver should be created * @return New instance of {@code ResourceResolver} * @throws LoginException If the resolver cannot be created */ @NotNull - public static ResourceResolver newResolver( - @NotNull ResourceResolverFactory factory, - @NotNull String userName) throws LoginException { + public static ResourceResolver newResolver(@NotNull ResourceResolverFactory factory) throws LoginException { return factory.getServiceResourceResolver( - Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, userName) + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_ID) ); } @@ -58,14 +57,11 @@ public static ResourceResolver newResolver( * 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 - * @param name The username or subservice name for which the resolver should be created * @return New instance of {@code ResourceResolver} * @throws LoginException If the resolver cannot be created */ @NotNull - public static ResourceResolver newResolver( - @NotNull SlingHttpServletRequest request, - @NotNull String name) throws LoginException { + public static ResourceResolver newResolver(@NotNull SlingHttpServletRequest request) throws LoginException { SlingBindings bindings = (SlingBindings) request.getAttribute(SlingBindings.class.getName()); SlingScriptHelper scriptHelper = bindings != null ? (SlingScriptHelper) bindings.get(SlingBindings.SLING) : null; @@ -76,7 +72,7 @@ public static ResourceResolver newResolver( throw new LoginException("Could not obtain ResourceResolverFactory"); } return factory.getServiceResourceResolver( - Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, name) + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_ID) ); } } diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java similarity index 94% rename from core/src/test/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtilTest.java rename to core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java index 5f06158ec..2f01cb0e9 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/utils/ResolverUtilTest.java +++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java @@ -11,7 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.exadel.aem.toolkit.core.configurator.utils; +package com.exadel.aem.toolkit.core.utils; import java.util.Collections; @@ -36,8 +36,6 @@ @RunWith(MockitoJUnitRunner.class) public class ResolverUtilTest { - private static final String USER_NAME = "testServiceUser"; - private static final String FACTORY_ERROR = "Could not obtain ResourceResolverFactory"; private static final String LOGIN_ERROR = "Test error"; @@ -49,12 +47,12 @@ 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, USER_NAME))).thenReturn(expected); + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "eak-service"))).thenReturn(expected); - ResourceResolver resultFromFactory = ResolverUtil.newResolver(factory, USER_NAME); + ResourceResolver resultFromFactory = ResolverUtil.newResolver(factory); assertEquals(expected, resultFromFactory); - ResourceResolver resultFromRequest = ResolverUtil.newResolver(newRequestWithFactory(factory), USER_NAME); + ResourceResolver resultFromRequest = ResolverUtil.newResolver(newRequestWithFactory(factory)); assertEquals(expected, resultFromRequest); } @@ -101,7 +99,7 @@ private MockSlingHttpServletRequest newRequestWithFactory(ResourceResolverFactor } private static void assertLoginException(SlingHttpServletRequest request, String errorMessage) { - try (ResourceResolver ignored = ResolverUtil.newResolver(request, USER_NAME)) { + try (ResourceResolver ignored = ResolverUtil.newResolver(request)) { Assert.fail("Expected LoginException"); } catch (LoginException e) { assertEquals(errorMessage, e.getMessage()); @@ -110,7 +108,7 @@ private static void assertLoginException(SlingHttpServletRequest request, String @SuppressWarnings("SameParameterValue") private static void assertLoginException(ResourceResolverFactory factory, String errorMessage) { - try (ResourceResolver ignored = ResolverUtil.newResolver(factory, USER_NAME)) { + try (ResourceResolver ignored = ResolverUtil.newResolver(factory)) { Assert.fail("Expected LoginException"); } catch (LoginException e) { assertEquals(errorMessage, e.getMessage()); From 8f547fb11ada5204acbac0cb722323b8c21c67a5 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 28 Apr 2026 12:59:14 +0300 Subject: [PATCH 08/92] [EAK-651] Add a JSON-to-entity conversion method --- .../core/utils/ObjectConversionUtil.java | 21 +- .../core/utils/ObjectConversionUtilTest.java | 280 ++++++++++++++++++ 2 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtilTest.java 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/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; + } + } +} From f56bbda27ed1fe1c1a8b8c4d59aeb6fc5bd39a1a Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 28 Apr 2026 15:10:30 +0300 Subject: [PATCH 09/92] [EAK-651] Optimize relay code; add Javadoc --- .../core/relay/models/RelayResource.java | 13 ++ .../core/relay/services/PathSampler.java | 165 ++++++++++++++++ .../core/relay/services/RelayConfig.java | 24 ++- .../core/relay/services/RelayProvider.java | 110 +++++++++-- .../relay/services/RelayProviderHost.java | 179 ++++++++++++++---- .../core/relay/services/ScriptSampler.java | 83 -------- .../toolkit/core/relay/utils/PathHelper.java | 12 ++ .../core/relay/utils/ResourceHelper.java | 44 +++++ 8 files changed, 498 insertions(+), 132 deletions(-) create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java delete mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.java 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 index f0755d4cf..418f14a17 100644 --- 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 @@ -22,11 +22,21 @@ import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; +/** + * 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 = path; @@ -39,18 +49,21 @@ public RelayResource(@Nonnull Resource original, @Nonnull String path) { } } + /** {@inheritDoc} */ @Override @Nonnull public String getPath() { return path; } + /** {@inheritDoc} */ @Override @Nonnull public ResourceMetadata getResourceMetadata() { return resourceMetadata; } + /** {@inheritDoc} */ @Override public @Nonnull Iterator listChildren() { return ResourceHelper.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..e99b64460 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java @@ -0,0 +1,165 @@ +/* + * 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.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +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.observation.ResourceChange; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.exadel.aem.toolkit.core.CoreConstants; + +/** + * 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 Pattern LIMIT_PREDICATE = Pattern.compile("\\s+limit\\s+(\\d+)$", Pattern.CASE_INSENSITIVE); + + private final List samples; + private Collection paths; + + /** + * Creates a new {@code PathSampler} with the provided list of path samples + * @param samples Nullable list of JCR paths or XPath expressions + */ + PathSampler(List samples) { + this.samples = samples; + } + + /** + * Resolves the configured path samples against the provided JCR session and produces a collection of + * {@link ResourceChange} notifications + * @param session JCR {@link Session} used to execute XPath queries + * @return A non-null, possibly empty collection of {@link ResourceChange} instances + */ + Collection createChanges(Session session) { + if (paths == null) { + paths = CollectionUtils.emptyIfNull(samples) + .stream() + .flatMap(s -> resolve(s, session).stream()) + .collect(Collectors.toSet()); + } + return createChanges(); + } + + /** + * Creates a collection of {@link ResourceChange} notifications from the previously resolved paths + * @return A non-null, possibly empty collection of {@link ResourceChange} instances + */ + Collection createChanges() { + return CollectionUtils.emptyIfNull(paths) + .stream() + .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) + .collect(Collectors.toList()); + } + + /** + * Gets whether this sampler has no configured path samples + * @return True or false + */ + public boolean isEmpty() { + return CollectionUtils.isEmpty(samples); + } + + /** + * 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, "(*"); + } + + /** + * Resolves the provided path or XPath expression to a list of concrete JCR paths + * @param pathOrExpression JCR path or XPath expression to resolve + * @param session JCR {@link Session} used for XPath query execution + * @return A non-null, possibly empty list of resolved JCR path strings + */ + private static List resolve(String pathOrExpression, Session session) { + if (isXPath(pathOrExpression)) { + return resolveXPath(pathOrExpression, session); + } else if (isJcrPath(pathOrExpression)) { + return Collections.singletonList(pathOrExpression); + } + return Collections.emptyList(); + } + + /** + * Executes the provided XPath expression against the given session and collects the resulting JCR paths. + * Supports an optional {@code limit N} suffix to cap the number of results + * @param expression XPath expression to execute + * @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(String expression, Session session) { + String effectiveExpression = expression.trim(); + Matcher matcher = LIMIT_PREDICATE.matcher(effectiveExpression); + int limit = -1; + if (matcher.matches()) { + limit = Integer.parseInt(matcher.group(1)); + effectiveExpression = effectiveExpression.replace(matcher.group(), StringUtils.EMPTY).trim(); + } + try { + @SuppressWarnings("deprecation") + Query query = session.getWorkspace().getQueryManager().createQuery(effectiveExpression, Query.XPATH); + if (limit > 0) { + query.setLimit(limit); + } + 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 parse the path sample expression {}", expression, e); + return Collections.emptyList(); + } + } +} 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 index 5f02bb71f..5643ed37c 100644 --- 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 @@ -16,21 +16,38 @@ import org.osgi.service.metatype.annotations.AttributeDefinition; 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 Configuration") public @interface RelayConfig { + /** + * Gets whether this relay configuration is active + * @return True or false + */ @AttributeDefinition( name = "Enable", description = "Is the relay enabled?" ) 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. Each entry is either a {@code "user:password"} credential + * string or a registered subservice name + * @return A non-null array of user mapping rule strings; might be empty + */ @AttributeDefinition( name = "User mappings", description = "Optional list of user mapping rules. " + @@ -38,9 +55,14 @@ ) String[] userMappings() default {}; + /** + * Gets the optional list of JCR paths or XPath expressions whose matching resources are reported as changed + * when the relay is enabled or disabled + * @return A non-null array of path or XPath expression strings; might be empty + */ @AttributeDefinition( name = "Report paths", - description = "Optional list of XPath expressions. " + + description = "Optional list of JCR paths or XPath expressions. " + "Target paths matching the list will be reported as changed as the relay is enabled or disabled." ) 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 index 0c574f190..682c10108 100644 --- 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 @@ -13,12 +13,13 @@ */ package com.exadel.aem.toolkit.core.relay.services; +import java.util.ArrayList; 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 java.util.Objects; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -45,16 +46,24 @@ import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; import com.exadel.aem.toolkit.core.utils.ResolverUtil; +/** + * 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 ResourceResolverFactory resolverFactory; - private ScriptSampler scriptSampler; + private PathSampler pathSampler; private String source; private String target; private Map userMappings; + /** + * Default (instantiation-blocking) constructor + */ private RelayProvider() { } @@ -62,6 +71,9 @@ private RelayProvider() { ResourceProvider members ------------------------ */ + /** + * {@inheritDoc} + */ @Override public Resource getResource( @Nonnull ResolveContext context, @@ -86,6 +98,9 @@ public Resource getResource( parent)); } + /** + * {@inheritDoc} + */ @Override @Nullable public Iterator listChildren(@Nonnull ResolveContext context, @Nonnull Resource parent) { @@ -114,31 +129,32 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N return ResourceHelper.listChildren(targetResource, parent.getPath()); } + /** + * {@inheritDoc} + */ @Override public void start(@Nonnull ProviderContext providerContext) { LOG.info("Relay provider for {} -> {} is starting", source, target); super.start(providerContext); - if (!StringUtils.startsWithAny(source, "/apps", "/libs") - || !StringUtils.startsWith(target, "/content")) { - return; - } try (ResourceResolver resolver = ResolverUtil.newResolver(resolverFactory)) { Session session = resolver.adaptTo(Session.class); - scriptSampler = ScriptSampler.from(Objects.requireNonNull(session), source, target); - Collection declaredChanges = scriptSampler.generateChanges(); - if (!declaredChanges.isEmpty()) { - providerContext.getObservationReporter().reportChanges(declaredChanges, false); + Collection changes = pathSampler.createChanges(session); + if (!changes.isEmpty()) { + providerContext.getObservationReporter().reportChanges(changes, false); } } catch (LoginException e) { LOG.error("Failed to create a JCR session for script sampling at {}", target, e); } } + /** + * {@inheritDoc} + */ @Override public void stop() { LOG.info("Relay provider for {} -> {} is stopping", source, target); - if (getProviderContext() != null && scriptSampler != null) { - Collection declaredChanges = scriptSampler.generateChanges(); + if (getProviderContext() != null && !pathSampler.isEmpty()) { + Collection declaredChanges = pathSampler.createChanges(); if (!declaredChanges.isEmpty()) { getProviderContext().getObservationReporter().reportChanges(declaredChanges, false); } @@ -150,6 +166,9 @@ public void stop() { ResourceChangeListener members ------------------------------ */ + /** + * {@inheritDoc} + */ @Override public void onChange(@Nonnull List changes) { if (getProviderContext() == null) { @@ -168,6 +187,12 @@ public void onChange(@Nonnull List changes) { Service methods --------------- */ + /** + * Returns a {@link ResourceResolver} for the user identity mapped to the provided resolver's user ID. + * If no mapping is configured for the current user, returns the original resolver unchanged + * @param resolver {@code ResourceResolver} whose user ID is used as the mapping key + * @return A non-null {@code ResourceResolver} instance; may be the original if no mapping applies + */ private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { String mappedId = userMappings.get(resolver.getUserID()); if (mappedId != null && !mappedId.equals(resolver.getUserID())) { @@ -196,47 +221,100 @@ private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { Factory logic ------------- */ + /** + * Creates a new {@link Builder} for configuring and instantiating a {@link RelayProvider} + * @return A new {@code Builder} instance + */ static Builder builder() { return new Builder(); } - @SuppressWarnings("unused") + /** + * Constructs {@link RelayProvider} instances with the required configuration + */ + @SuppressWarnings({"UnusedReturnValue"}) static class Builder { + private List reportedPaths; private ResourceResolverFactory resolverFactory; private String source; private String target; private Map userMappings; + /** + * Default (instantiation-restricting) constructor + */ private Builder() { } + /** + * Adds a JCR path to report as changed when the provider starts or stops + * @param value JCR path to include in change reports + * @return This builder + */ + Builder reportedPath(String value) { + if (reportedPaths == null) { + reportedPaths = new ArrayList<>(); + } + reportedPaths.add(value); + return this; + } + + /** + * Sets the {@link ResourceResolverFactory} used to create user-mapped resolvers + * @param value {@link ResourceResolverFactory} instance + * @return This builder + */ Builder resolverFactory(ResourceResolverFactory value) { resolverFactory = value; return this; } + /** + * Sets the JCR source path handled by this provider + * @param value Source JCR path + * @return This builder + */ Builder source(String value) { this.source = value; return this; } + /** + * Sets the JCR target path to which resource resolution is delegated + * @param value Target JCR path + * @return This builder + */ Builder target(String value) { this.target = value; return this; } - Builder userMappings(Map value) { - this.userMappings = value; + /** + * Adds a user identity mapping from a source user ID to a target user or subservice + * @param source Source user ID + * @param value Target user ID, {@code "user:password"} credential string, or service subservice name + * @return This builder + */ + Builder userMapping(String source, String value) { + if (userMappings == null) { + userMappings = new HashMap<>(); + } + userMappings.put(source, value); return this; } + /** + * Creates a configured {@link RelayProvider} from the current builder state + * @return A new {@link RelayProvider} instance + */ RelayProvider build() { RelayProvider result = new RelayProvider(); + result.pathSampler = new PathSampler(reportedPaths); result.resolverFactory = resolverFactory; result.source = source; result.target = target; - result.userMappings = userMappings; + result.userMappings = userMappings != null ? userMappings : Collections.emptyMap(); return result; } } 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 index ae5eb4d6d..a8abdc8f0 100644 --- 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 @@ -14,13 +14,17 @@ package com.exadel.aem.toolkit.core.relay.services; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Dictionary; -import java.util.HashMap; +import java.util.HashSet; import java.util.Hashtable; import java.util.List; -import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.locks.ReentrantLock; +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.ResourceChangeListener; @@ -32,13 +36,21 @@ import org.osgi.service.component.annotations.Deactivate; import org.osgi.service.component.annotations.Reference; import org.osgi.service.metatype.annotations.Designate; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +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) +@Designate(ocd = RelayConfig.class, factory = true) public class RelayProviderHost { - private static final String MAPPING_DELIMITER = "->"; - @Reference private transient ResourceResolverFactory resolverFactory; @@ -46,33 +58,50 @@ public class RelayProviderHost { 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 bundleContext, RelayConfig config) { + private void activate(BundleContext context, RelayConfig config) { lock.lock(); try { deactivate(); - Map userMappings = new HashMap<>(); - for (String userMapping : config.userMappings()) { - String source = StringUtils.substringBefore(userMapping, MAPPING_DELIMITER).trim(); - String target = StringUtils.substringAfter(userMapping, MAPPING_DELIMITER).trim(); - if (StringUtils.isAnyEmpty(source, target)) { - continue; + if (!config.enabled()) { + return; + } + Set pathMappings = new HashSet<>(); + Set userMappings = new HashSet<>(); + for (String mappingSource : config.pathMappings()) { + Mapping mapping = ObjectConversionUtil.toObject(mappingSource, Mapping.class); + if (Mapping.isValid(mapping)) { + pathMappings.add(mapping); } - userMappings.put(source, target); } - for (String pathMapping : config.pathMappings()) { - String source = StringUtils.substringBefore(pathMapping, MAPPING_DELIMITER).trim(); - String target = StringUtils.substringAfter(pathMapping, MAPPING_DELIMITER).trim(); - if (StringUtils.isAnyEmpty(source, target)) { - continue; + if (pathMappings.isEmpty()) { + return; + } + for (String mappingSource : config.userMappings()) { + Mapping mapping = ObjectConversionUtil.toObject(mappingSource, Mapping.class); + if (Mapping.isValid(mapping)) { + userMappings.add(mapping); } - createRelay(bundleContext, source, target, userMappings); } + createRelays( + context, + pathMappings, + userMappings, + ArrayUtils.nullToEmpty(config.reportedPaths())); } finally { lock.unlock(); } } + /** + * Discards all previously registered {@link RelayProvider} service instances + */ @Deactivate private void deactivate() { lock.lock(); @@ -86,21 +115,107 @@ private void deactivate() { } } - private void createRelay(BundleContext bundleContext, String source, String target, Map userMappings) { - Dictionary properties = new Hashtable<>(); - properties.put(ResourceProvider.PROPERTY_ROOT, source); - properties.put(ResourceChangeListener.PATHS, new String[]{target}); - ServiceRegistration registration = bundleContext.registerService( - new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, - RelayProvider + /** + * Creates and registers a {@link RelayProvider} service instance for each entry in the provided path mappings + * collection + * @param context OSGi {@link BundleContext} used to register provider services + * @param pathMappings Collection of path mapping rules defining source-to-target path redirections + * @param userMappings Collection of user mapping rules defining source-to-target user identity redirections + * @param reportedPaths Array of JCR paths to report as changed when a provider starts or stops + */ + private void createRelays( + BundleContext context, + Collection pathMappings, + Collection userMappings, + String[] reportedPaths) { + + for (Mapping pathMapping : pathMappings) { + + RelayProvider.Builder builder = RelayProvider .builder() .resolverFactory(resolverFactory) - .source(source) - .target(target) - .userMappings(userMappings) - .build(), - properties); - registrations.add(registration); + .source(pathMapping.getFrom()) + .target(pathMapping.getFrom()); + userMappings.forEach(mapping -> builder.userMapping(mapping.getFrom(), mapping.getTo())); + Arrays.stream(reportedPaths).forEach(builder::reportedPath); + RelayProvider provider = builder.build(); + + Dictionary properties = new Hashtable<>(); + properties.put(ResourceProvider.PROPERTY_ROOT, pathMapping.getFrom()); + properties.put(ResourceChangeListener.PATHS, new String[]{pathMapping.getFrom()}); + + ServiceRegistration registration = context.registerService( + new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, + provider, + properties); + registrations.add(registration); + } + } + + /** + * Represents a source-to-target mapping entry used for path or user identity translation + */ + private static class Mapping { + 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 + Mapping(@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 + */ + String getFrom() { + return from; + } + + /** + * Gets the target value of this mapping + * @return A nullable target string + */ + String getTo() { + return to; + } + + /** + * {@inheritDoc} + */ + @Override + public final boolean equals(Object other) { + if (!(other instanceof Mapping)) { + return false; + } + Mapping mapping = (Mapping) other; + return Objects.equals(from, mapping.from) && Objects.equals(to, mapping.to); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + int result = Objects.hashCode(from); + result = 31 * result + Objects.hashCode(to); + return result; + } + + /** + * Gets whether the provided mapping is non-null and has non-blank source and target values + * @param value Nullable {@code Mapping} instance to validate + * @return True or false + */ + static boolean isValid(Mapping value) { + return value != null && StringUtils.isNoneBlank(value.getFrom(), value.getTo()); + } } } diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.java deleted file mode 100644 index 5d2cad4cf..000000000 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/ScriptSampler.java +++ /dev/null @@ -1,83 +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.core.relay.services; - -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import javax.jcr.NodeIterator; -import javax.jcr.RepositoryException; -import javax.jcr.Session; -import javax.jcr.query.Query; -import javax.jcr.query.QueryResult; - -import org.apache.commons.lang3.StringUtils; -import org.apache.jackrabbit.util.Text; -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.utils.PathHelper; - -class ScriptSampler { - - private static final Logger LOG = LoggerFactory.getLogger(ScriptSampler.class); - - private static final List SCRIPT_EXTENSIONS = Arrays.asList(".java", ".jsp", ".js", ".html"); - - private static final String QUERY_TEMPLATE = "SELECT * FROM [nt:file] AS file WHERE ISDESCENDANTNODE(file, '%s')"; - - private final Map samples = new HashMap<>(); - - public Collection generateChanges() { - return samples.values() - .stream() - .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) - .collect(Collectors.toList()); - } - - public boolean isEmpty() { - return samples.isEmpty(); - } - - public static ScriptSampler from(Session session, String source, String target) { - ScriptSampler result = new ScriptSampler(); - try { - Query query = session - .getWorkspace() - .getQueryManager() - .createQuery(String.format(QUERY_TEMPLATE, Text.escape(target, '%', true)), Query.JCR_SQL2); - QueryResult queryResult = query.execute(); - NodeIterator nodes = queryResult.getNodes(); - while (nodes.hasNext()) { - String path = nodes.nextNode().getPath(); - if (SCRIPT_EXTENSIONS.stream().anyMatch(path::endsWith)) { - result.samples.put( - StringUtils.substringAfterLast(path, CoreConstants.SEPARATOR_DOT), - PathHelper.replace(path, target, source)); - } - if (result.samples.size() >= SCRIPT_EXTENSIONS.size()) { - break; - } - } - } catch (RepositoryException e) { - LOG.error("Failed to collect script samples at {}", target, e); - } - return result; - } -} diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java index 73bd9568e..4ef30a8a1 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java @@ -17,11 +17,23 @@ import com.exadel.aem.toolkit.core.CoreConstants; +/** + * Provides utility methods for JCR path manipulation within the relay infrastructure + */ public class PathHelper { + /** Default (instantiation-blocking) constructor */ private PathHelper() { } + /** + * 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 non-null transformed path string + */ public static String replace(String path, String source, String target) { if (StringUtils.equals(path, source)) { return target; diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index b2ea6ac23..6be23759f 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -31,14 +31,29 @@ import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.core.relay.models.RelayResource; +/** + * Provides utility methods for resolving and listing Sling resources within the relay infrastructure + */ public class ResourceHelper { private static final Logger LOG = LoggerFactory.getLogger(ResourceHelper.class); private static final String KEY_SUBSIDIARY = "subsidiary"; + /** Default (instantiation-blocking) constructor */ private ResourceHelper() {} + /** + * Resolves a resource at the provided path using a potentially modified {@link ResourceResolver}. Falls back to + * the {@code onFailure} supplier when the path cannot be resolved + * @param basicResolver The base {@link ResourceResolver} instance used for resolution + * @param resolverModifier A {@code UnaryOperator} that optionally produces an alternative {@code ResourceResolver} + * from the provided one + * @param path JCR path of the resource to resolve + * @param onSuccess A {@code Function} applied to the resolved resource to produce the final result + * @param onFailure A {@code Supplier} invoked when the resource cannot be resolved + * @return A nullable {@link Resource} instance + */ public static Resource getResource( ResourceResolver basicResolver, UnaryOperator resolverModifier, @@ -69,6 +84,16 @@ public static Resource getResource( return onSuccess.apply(result); } + /** + * Delegates resource resolution for the provided path to a parent {@link ResourceProvider} + * @param resourceProvider Parent {@code ResourceProvider} instance + * @param resolveContext {@link ResolveContext} associated with the current resolution + * @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 provider or + * context is missing + */ @SuppressWarnings("unchecked") public static Resource getResource( ResourceProvider resourceProvider, @@ -84,6 +109,14 @@ public static Resource getResource( return ((ResourceProvider)resourceProvider).getResource((ResolveContext) resolveContext, path, resourceContext, parent); } + /** + * Delegates child listing for the provided parent resource to a parent {@link ResourceProvider} + * @param resourceProvider Parent {@code ResourceProvider} instance + * @param resolveContext {@link ResolveContext} associated with the current child listing + * @param parent Parent {@link Resource} whose children to list + * @return A nullable {@code Iterator} of child {@link Resource} instances, or {@code null} if the provider or + * context is missing + */ @SuppressWarnings("unchecked") public static Iterator listChildren( ResourceProvider resourceProvider, @@ -96,6 +129,13 @@ public static Iterator listChildren( return ((ResourceProvider)resourceProvider).listChildren((ResolveContext) resolveContext, 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) { if (target instanceof RelayResource) { return target.listChildren(); @@ -106,6 +146,10 @@ public static Iterator listChildren(Resource target, String path) { .iterator(); } + /** + * 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); } From 74cfcfdefb3952fea6d14124a21b953469d39feb Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 28 Apr 2026 15:56:02 +0300 Subject: [PATCH 10/92] [EAK-651] Create unit tests --- .../core/relay/services/PathSamplerTest.java | 153 +++++++++ .../relay/services/RelayProviderHostTest.java | 103 ++++++ .../relay/services/RelayProviderTest.java | 309 ++++++++++++++++++ .../core/relay/utils/PathHelperTest.java | 38 +++ .../core/relay/utils/ResourceHelperTest.java | 195 +++++++++++ 5 files changed, 798 insertions(+) create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/PathHelperTest.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java 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..94ae46e53 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java @@ -0,0 +1,153 @@ +/* + * 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.List; +import javax.jcr.Node; +import javax.jcr.RepositoryException; +import javax.jcr.Session; +import javax.jcr.query.Query; + +import org.apache.sling.api.resource.observation.ResourceChange; +import org.apache.sling.testing.mock.jcr.MockJcr; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PathSamplerTest { + + private static final String PATH_PLAIN = "/content/plain"; + private static final String PATH_ANOTHER = "/content/another"; + + private static final String XPATH_MATCH_SAMPLED = "/jcr:root/content//*[@type='sampled']"; + + @Test + public void shouldReportEmptyWhenSamplesAreNullOrEmpty() { + assertTrue(new PathSampler(null).isEmpty()); + assertTrue(new PathSampler(Collections.emptyList()).isEmpty()); + assertFalse(new PathSampler(Collections.singletonList(PATH_PLAIN)).isEmpty()); + } + + @Test + public void shouldReturnEmptyChangesWithoutResolution() { + PathSampler sampler = new PathSampler(Collections.singletonList(PATH_PLAIN)); + + Collection changes = sampler.createChanges(); + + assertTrue(changes.isEmpty()); + } + + @Test + public void shouldProduceChangesFromJcrPaths() { + // Session is not accessed during plain JCR path resolution; null is safe here + PathSampler sampler = new PathSampler(Arrays.asList(PATH_PLAIN, PATH_ANOTHER)); + + Collection changes = sampler.createChanges(null); + + assertEquals(2, changes.size()); + assertTrue(changes.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); + assertTrue(changes.stream().anyMatch(c -> PATH_ANOTHER.equals(c.getPath()))); + changes.forEach(c -> assertEquals(ResourceChange.ChangeType.CHANGED, c.getType())); + } + + @Test + public void shouldProduceChangesFromXPathExpression() throws RepositoryException { + Session session = MockJcr.newSession(); + Node contentNode = session.getRootNode().addNode("content"); + Node plainNode = contentNode.addNode("plain"); + Node anotherNode = contentNode.addNode("another"); + setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Arrays.asList(plainNode, anotherNode)); + + PathSampler sampler = new PathSampler(Collections.singletonList(XPATH_MATCH_SAMPLED)); + Collection changes = sampler.createChanges(session); + + assertEquals(2, changes.size()); + assertTrue(changes.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); + assertTrue(changes.stream().anyMatch(c -> PATH_ANOTHER.equals(c.getPath()))); + changes.forEach(c -> assertEquals(ResourceChange.ChangeType.CHANGED, c.getType())); + } + + @Test + public void shouldProduceChangesFromMixedSamples() throws RepositoryException { + Session session = MockJcr.newSession(); + Node anotherNode = session.getRootNode().addNode("content").addNode("another"); + setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Collections.singletonList(anotherNode)); + + // PATH_PLAIN is a plain JCR path; PATH_ANOTHER is resolved via the XPath expression + PathSampler sampler = new PathSampler(Arrays.asList(PATH_PLAIN, XPATH_MATCH_SAMPLED)); + Collection changes = sampler.createChanges(session); + + assertEquals(2, changes.size()); + assertTrue(changes.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); + assertTrue(changes.stream().anyMatch(c -> PATH_ANOTHER.equals(c.getPath()))); + } + + @Test + public void shouldSkipUnrecognizedSamples() { + PathSampler sampler = new PathSampler(Arrays.asList("not-a-path", "relative/path", "")); + + // Session is not accessed because none of the samples require resolution; null is safe + Collection changes = sampler.createChanges(null); + + assertTrue(changes.isEmpty()); + } + + @Test + public void shouldReturnEmptyChangesWhenXPathYieldsNoResults() { + Session session = MockJcr.newSession(); + setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Collections.emptyList()); + + PathSampler sampler = new PathSampler(Collections.singletonList(XPATH_MATCH_SAMPLED)); + Collection changes = sampler.createChanges(session); + + assertTrue(changes.isEmpty()); + } + + @Test + public void shouldResolveSamplesOnce() throws RepositoryException { + Session session = MockJcr.newSession(); + Node plainNode = session.getRootNode().addNode("content").addNode("plain"); + setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Collections.singletonList(plainNode)); + + PathSampler sampler = new PathSampler(Collections.singletonList(XPATH_MATCH_SAMPLED)); + Collection firstResult = sampler.createChanges(session); + // Second call with null session — if resolution were not cached, a NullPointerException would occur + Collection secondResult = sampler.createChanges(null); + + assertEquals(firstResult.size(), secondResult.size()); + assertTrue(secondResult.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); + } + + @Test + public void shouldReturnEmptyChangesForNullSamples() { + PathSampler sampler = new PathSampler(null); + + Collection changes = sampler.createChanges(null); + + assertTrue(changes.isEmpty()); + } + + /* --------------- + Utility methods + --------------- */ + + @SuppressWarnings({"deprecation", "SameParameterValue"}) + private static void setXpathQueryResult(Session session, String expression, List nodes) { + MockJcr.setQueryResult(session, expression, Query.XPATH, nodes); + } +} 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..a3156d0ee --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java @@ -0,0 +1,103 @@ +/* + * 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.HashMap; +import java.util.Map; + +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.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_MAPPING_A = "{\"from\":\"/content/source\",\"to\":\"/content/target\"}"; + private static final String PATH_MAPPING_B = "{\"from\":\"/content/source2\",\"to\":\"/content/target2\"}"; + private static final String PATH_MAPPING_MISSING_TO = "{\"from\":\"/content/source\"}"; + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + @Test + public void shouldNotRegisterServicesWhenDisabled() throws Exception { + context.registerInjectActivateService( + new RelayProviderHost(), + newProps(false, PATH_MAPPING_A)); + + assertEquals(0, countRegisteredProviders()); + } + + @Test + public void shouldNotRegisterServicesWithoutValidPathMappings() throws Exception { + context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true)); + + assertEquals(0, countRegisteredProviders()); + } + + @Test + public void shouldSkipInvalidPathMappings() throws Exception { + context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_MISSING_TO)); + + assertEquals(0, countRegisteredProviders()); + } + + @Test + public void shouldRegisterServicesForEachValidPathMapping() throws Exception { + context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A, PATH_MAPPING_B)); + + assertEquals(2, countRegisteredProviders()); + } + + @Test + public void shouldUnregisterServicesOnDeactivate() throws Exception { + 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 Exception { + ServiceReference[] refs = context.bundleContext() + .getAllServiceReferences(ResourceProvider.class.getName(), null); + return refs != null ? refs.length : 0; + } + + private static Map newProps(boolean enabled, String... pathMappings) { + Map props = new HashMap<>(); + props.put("enabled", enabled); + props.put("pathMappings", pathMappings); + return props; + } +} 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..d85591665 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.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.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.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.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +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.assertNull; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.AemContextFactory; + +@RunWith(MockitoJUnitRunner.class) +public class RelayProviderTest { + + private static final String PATH_ASSET = "/content/asset"; + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_TARGET = "/content/target"; + + private static final String SUBPATH_CHILD = "/child"; + private static final String SUBPATH_PAGE = "/page"; + + + @Rule + public final AemContext context = AemContextFactory.newInstance(); + + @Test + public void shouldReturnRelayResource() { + context.create().resource(PATH_TARGET); + context.create().resource(PATH_TARGET + SUBPATH_CHILD); + + RelayProvider provider = newProvider(); + ResolveContext resolveContext = newResolveContext(); + ResourceContext mockResourceContext = Mockito.mock(ResourceContext.class); + + // Path outside the configured source → null regardless of what the resolver holds + Resource unrelated = provider.getResource(resolveContext, "/content/other", mockResourceContext, null); + assertNull(unrelated); + + // No target resource for source → null (parent provider also absent) + Resource missingTarget = provider.getResource(resolveContext, PATH_SOURCE + "/missing", mockResourceContext, null); + assertNull(missingTarget); + + // When the target resource exists, a relay resource is created based on the target resource, + // but it reports the source path + Resource existingTarget = provider.getResource(resolveContext, PATH_SOURCE, mockResourceContext, null); + assertNotNull(existingTarget); + assertEquals(PATH_SOURCE, existingTarget.getPath()); + + // When the target resource exists with a child, a relay resource is created based on the target resource's child, + // but it reports the source path + Resource existingTargetChild = provider.getResource( + resolveContext, + PATH_SOURCE + SUBPATH_CHILD, + mockResourceContext, + null); + assertNotNull(existingTargetChild); + assertEquals(PATH_SOURCE + SUBPATH_CHILD, existingTargetChild.getPath()); + } + + @Test + public void shouldDelegateChildListingToParentProvider() { + context.create().resource(PATH_TARGET); + Resource parent = context.create().resource(PATH_SOURCE); + Resource expectedChild = context.create().resource(PATH_SOURCE + SUBPATH_CHILD); + + @SuppressWarnings("unchecked") + ResourceProvider mockParentProvider = Mockito.mock(ResourceProvider.class); + @SuppressWarnings("unchecked") + ResolveContext mockParentContext = Mockito.mock(ResolveContext.class); + Mockito.when(mockParentProvider.listChildren(Mockito.any(), Mockito.eq(parent))) + .thenReturn(Collections.singletonList(expectedChild).iterator()); + + ResolveContext resolveContext = newResolveContextWithParent(mockParentProvider, mockParentContext); + + RelayProvider provider = newProvider(); + Iterator result = provider.listChildren(resolveContext, parent); + + assertNotNull(result); + assertTrue(result.hasNext()); + assertEquals(PATH_SOURCE + SUBPATH_CHILD, result.next().getPath()); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void shouldNotifyChangedPaths() throws LoginException { + ResourceResolverFactory mockFactory = newMockFactory(); + RelayProvider provider = newProvider(mockFactory); + + // onChange before start() → no providerContext, must not throw + ResourceChange earlyChange = new ResourceChange( + ResourceChange.ChangeType.CHANGED, + PATH_TARGET + SUBPATH_PAGE, + false); + provider.onChange(Collections.singletonList(earlyChange)); + + // Start the provider so providerContext is established + ProviderContext mockProviderContext = Mockito.mock(ProviderContext.class); + ObservationReporter mockReporter = Mockito.mock(ObservationReporter.class); + Mockito.when(mockProviderContext.getObservationReporter()).thenReturn(mockReporter); + provider.start(mockProviderContext); + + // onChange after start → changes translated from target to source path + ResourceChange change = new ResourceChange( + ResourceChange.ChangeType.CHANGED, + PATH_TARGET + SUBPATH_PAGE, + false); + provider.onChange(Collections.singletonList(change)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + Mockito.verify(mockReporter).reportChanges(captor.capture(), Mockito.eq(false)); + + List reported = captor.getValue(); + assertEquals(1, reported.size()); + assertEquals(PATH_SOURCE + SUBPATH_PAGE, reported.get(0).getPath()); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void shouldReportChangesOnStartAndStop() throws LoginException { + ResourceResolverFactory mockFactory = newMockFactory(); + RelayProvider provider = RelayProvider + .builder() + .resolverFactory(mockFactory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .reportedPath(PATH_ASSET) + .build(); + + ProviderContext mockProviderContext = Mockito.mock(ProviderContext.class); + ObservationReporter mockReporter = Mockito.mock(ObservationReporter.class); + Mockito.when(mockProviderContext.getObservationReporter()).thenReturn(mockReporter); + + provider.start(mockProviderContext); + + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(Collection.class); + Mockito.verify(mockReporter).reportChanges(startCaptor.capture(), Mockito.eq(false)); + Collection startChanges = startCaptor.getValue(); + assertEquals(1, startChanges.size()); + assertEquals(PATH_ASSET, startChanges.iterator().next().getPath()); + + provider.stop(); + + // reportChanges called once on start and once on stop + Mockito.verify(mockReporter, Mockito.times(2)) + .reportChanges(Mockito.any(), Mockito.eq(false)); + } + + @Test + public void shouldHandleLoginExceptionOnStart() throws LoginException { + ResourceResolverFactory mockFactory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(mockFactory.getServiceResourceResolver(Mockito.anyMap())) + .thenThrow(new LoginException("NOT AN EXCEPTION: Testing login failure handling")); + + RelayProvider provider = RelayProvider + .builder() + .resolverFactory(mockFactory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .reportedPath(PATH_ASSET) + .build(); + + // LoginException must be caught internally; no exception propagates to the caller + provider.start(Mockito.mock(ProviderContext.class)); + } + + @Test + public void shouldApplyUserMapping() throws LoginException { + Resource targetResource = context.create().resource(PATH_TARGET); + + ResourceResolverFactory mockFactory = Mockito.mock(ResourceResolverFactory.class); + ResourceResolver mappedResolver = Mockito.mock(ResourceResolver.class); + Map propertyMap = new HashMap<>(); + Mockito.when(mappedResolver.getResource(PATH_TARGET)).thenReturn(targetResource); + Mockito.when(mappedResolver.getUserID()).thenReturn("mapped-user"); + + ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(basicResolver.getUserID()).thenReturn("testUser"); + Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); + + @SuppressWarnings("unchecked") + ResolveContext resolveContext = Mockito.mock(ResolveContext.class); + Mockito.when(resolveContext.getResourceResolver()).thenReturn(basicResolver); + ResourceContext mockResourceContext = Mockito.mock(ResourceContext.class); + + // Subservice user mapping: target is a plain service name (no colon) + Mockito.when(mockFactory.getServiceResourceResolver(Mockito.anyMap())).thenReturn(mappedResolver); + + RelayProvider providerWithSubservice = RelayProvider + .builder() + .resolverFactory(mockFactory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .userMapping("testUser", "mySubservice") + .build(); + + Resource result = providerWithSubservice.getResource(resolveContext, PATH_SOURCE, mockResourceContext, null); + assertNotNull(result); + assertEquals(PATH_SOURCE, result.getPath()); + Mockito.verify(mockFactory).getServiceResourceResolver( + Mockito.argThat(map -> "mySubservice".equals(map.get(ResourceResolverFactory.SUBSERVICE)))); + + // Credentials user mapping: target is "user:password" (contains colon) + Mockito.reset(mockFactory, mappedResolver); + propertyMap.clear(); + Mockito.when(mappedResolver.getResource(PATH_TARGET)).thenReturn(targetResource); + Mockito.when(mockFactory.getResourceResolver(Mockito.anyMap())).thenReturn(mappedResolver); + + RelayProvider providerWithCredentials = RelayProvider + .builder() + .resolverFactory(mockFactory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .userMapping("testUser", "svcUser:svcPass") + .build(); + + result = providerWithCredentials.getResource(resolveContext, PATH_SOURCE, mockResourceContext, null); + assertNotNull(result); + assertEquals(PATH_SOURCE, result.getPath()); + Mockito.verify(mockFactory).getResourceResolver( + Mockito.argThat(map -> "svcUser".equals(map.get(ResourceResolverFactory.USER)) + && java.util.Arrays.equals( + "svcPass".toCharArray(), + (char[]) map.get(ResourceResolverFactory.PASSWORD)))); + } + + /* --------------- + Utility methods + --------------- */ + + private RelayProvider newProvider() { + return RelayProvider + .builder() + .resolverFactory(Mockito.mock(ResourceResolverFactory.class)) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .build(); + } + + private RelayProvider newProvider(ResourceResolverFactory factory) { + return RelayProvider + .builder() + .resolverFactory(factory) + .source(PATH_SOURCE) + .target(PATH_TARGET) + .build(); + } + + @SuppressWarnings("unchecked") + private ResolveContext newResolveContext() { + ResolveContext resolveContext = Mockito.mock(ResolveContext.class); + Mockito.when(resolveContext.getResourceResolver()).thenReturn(context.resourceResolver()); + return resolveContext; + } + + @SuppressWarnings("unchecked") + private ResolveContext newResolveContextWithParent( + ResourceProvider parentProvider, + ResolveContext parentContext) { + + ResolveContext resolveContext = Mockito.mock(ResolveContext.class); + Mockito.when(resolveContext.getResourceResolver()).thenReturn(context.resourceResolver()); + // doReturn avoids generic wildcard type mismatch between ResourceProvider and ResourceProvider + Mockito.doReturn(parentProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentContext).when(resolveContext).getParentResolveContext(); + return resolveContext; + } + + private static ResourceResolverFactory newMockFactory() throws LoginException { + ResourceResolverFactory mockFactory = Mockito.mock(ResourceResolverFactory.class); + Mockito.when(mockFactory.getServiceResourceResolver(Mockito.anyMap())) + .thenReturn(Mockito.mock(ResourceResolver.class)); + return mockFactory; + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/PathHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/PathHelperTest.java new file mode 100644 index 000000000..b6fc2a7fe --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/PathHelperTest.java @@ -0,0 +1,38 @@ +/* + * 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.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class PathHelperTest { + + private static final String PATH_SOURCE = "/content/source"; + private static final String PATH_TARGET = "/content/target"; + + @Test + public void shouldReplacePrefix() { + assertEquals(PATH_TARGET, PathHelper.replace(PATH_SOURCE, PATH_SOURCE, PATH_TARGET)); + assertEquals(PATH_TARGET + "/child", PathHelper.replace(PATH_SOURCE + "/child", PATH_SOURCE, PATH_TARGET)); + assertEquals(PATH_TARGET + "/a/b/c", PathHelper.replace(PATH_SOURCE + "/a/b/c", PATH_SOURCE, PATH_TARGET)); + } + + @Test + public void shouldReturnPathUnchanged() { + assertEquals("/content/other", PathHelper.replace("/content/other", PATH_SOURCE, PATH_TARGET)); + assertEquals(PATH_SOURCE + "-extra", PathHelper.replace(PATH_SOURCE + "-extra", PATH_SOURCE, PATH_TARGET)); + assertNull(PathHelper.replace(null, PATH_SOURCE, PATH_TARGET)); + } +} diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java new file mode 100644 index 000000000..ffce73334 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java @@ -0,0 +1,195 @@ +/* + * 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 java.util.function.UnaryOperator; + +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +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.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +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.assertNull; +import static org.junit.Assert.assertTrue; + +import com.exadel.aem.toolkit.core.AemContextFactory; +import com.exadel.aem.toolkit.core.relay.models.RelayResource; + +@RunWith(MockitoJUnitRunner.class) +public class ResourceHelperTest { + + 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(); + + // Resource found → onSuccess applied + Resource found = ResourceHelper.getResource( + resolver, + UnaryOperator.identity(), + PATH_TARGET, + resource -> resource, + () -> null); + assertNotNull(found); + assertEquals(PATH_TARGET, found.getPath()); + + // Resource not found → onFailure invoked + Resource notFound = ResourceHelper.getResource( + resolver, + UnaryOperator.identity(), + PATH_TARGET + "/missing", + resource -> resource, + () -> null); + assertNull(notFound); + } + + @Test + public void shouldTrackSubsidiaryResolver() { + Resource targetResource = context.create().resource(PATH_TARGET); + + ResourceResolver modifiedResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(modifiedResolver.getResource(PATH_TARGET)).thenReturn(targetResource); + Mockito.when(modifiedResolver.getUserID()).thenReturn("modified-user"); + + Map propertyMap = new HashMap<>(); + ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); + + // Modified resolver → stored as subsidiary in the basic resolver's property map + ResourceHelper.getResource( + basicResolver, + resolver -> modifiedResolver, + PATH_TARGET, + resource -> resource, + () -> null); + assertEquals(modifiedResolver, propertyMap.get("subsidiary")); + + // Second call with a different modified resolver → existing subsidiary is closed, new one stored + ResourceResolver nextResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(nextResolver.getResource(PATH_TARGET)).thenReturn(targetResource); + Mockito.when(nextResolver.getUserID()).thenReturn("next-user"); + + ResourceHelper.getResource( + basicResolver, + resolver -> nextResolver, + PATH_TARGET, + resource -> resource, + () -> null); + Mockito.verify(modifiedResolver).close(); + assertEquals(nextResolver, propertyMap.get("subsidiary")); + } + + @Test + public void shouldDelegateGetResourceToParentProvider() { + Resource targetResource = context.create().resource(PATH_TARGET); + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext mockResolveContext = newMockResolveContext(); + ResourceContext mockResourceContext = Mockito.mock(ResourceContext.class); + Mockito.when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_TARGET), Mockito.any(), Mockito.any())) + .thenReturn(targetResource); + + // Happy path → delegates to parent provider + Resource result = ResourceHelper.getResource( + mockProvider, + mockResolveContext, + PATH_TARGET, + mockResourceContext, + null); + assertNotNull(result); + assertEquals(PATH_TARGET, result.getPath()); + + // Null provider → null + assertNull(ResourceHelper.getResource(null, mockResolveContext, PATH_TARGET, mockResourceContext, null)); + + // Null context → null + assertNull(ResourceHelper.getResource(mockProvider, null, PATH_TARGET, mockResourceContext, null)); + } + + @Test + public void shouldDelegateListChildrenToParentProvider() { + Resource parent = context.create().resource(PATH_TARGET); + Resource child = context.create().resource(PATH_TARGET + PATH_CHILD); + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext mockResolveContext = newMockResolveContext(); + Mockito.when(mockProvider.listChildren(Mockito.any(), Mockito.eq(parent))) + .thenReturn(Collections.singletonList(child).iterator()); + + // Happy path → delegates to parent provider + Iterator result = ResourceHelper.listChildren(mockProvider, mockResolveContext, parent); + assertNotNull(result); + assertTrue(result.hasNext()); + assertEquals(PATH_TARGET + PATH_CHILD, result.next().getPath()); + + // Null provider → null + assertNull(ResourceHelper.listChildren(null, mockResolveContext, parent)); + + // Null context → null + assertNull(ResourceHelper.listChildren(mockProvider, null, parent)); + } + + @Test + public void shouldListChildrenFromTarget() { + Resource target = context.create().resource(PATH_TARGET); + context.create().resource(PATH_TARGET + PATH_CHILD); + + // Non-RelayResource → children wrapped in RelayResource using the supplied exposed path + Iterator result = ResourceHelper.listChildren(target, PATH_EXPOSED); + assertNotNull(result); + assertTrue(result.hasNext()); + assertEquals(PATH_EXPOSED + PATH_CHILD, result.next().getPath()); + + // RelayResource → listChildren() passthrough; the path argument is ignored and the + // relay resource's own path is used when mapping child names + RelayResource relayTarget = new RelayResource(target, PATH_EXPOSED); + Iterator relayResult = ResourceHelper.listChildren(relayTarget, "/content/ignored"); + assertNotNull(relayResult); + assertTrue(relayResult.hasNext()); + assertEquals(PATH_EXPOSED + PATH_CHILD, relayResult.next().getPath()); + } + + /* --------------- + Utility methods + --------------- */ + + @SuppressWarnings("unchecked") + private static ResourceProvider newMockProvider() { + return Mockito.mock(ResourceProvider.class); + } + + @SuppressWarnings("unchecked") + private static ResolveContext newMockResolveContext() { + return Mockito.mock(ResolveContext.class); + } +} From fbc075624e010dbf843cb2e77b52db72ce09e3a7 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 28 Apr 2026 23:00:47 +0300 Subject: [PATCH 11/92] [EAK-651] Fix incorrect paths --- .../aem/toolkit/core/relay/services/RelayProviderHost.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index a8abdc8f0..2e0cad54f 100644 --- 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 @@ -135,14 +135,14 @@ private void createRelays( .builder() .resolverFactory(resolverFactory) .source(pathMapping.getFrom()) - .target(pathMapping.getFrom()); + .target(pathMapping.getTo()); userMappings.forEach(mapping -> builder.userMapping(mapping.getFrom(), mapping.getTo())); Arrays.stream(reportedPaths).forEach(builder::reportedPath); RelayProvider provider = builder.build(); Dictionary properties = new Hashtable<>(); properties.put(ResourceProvider.PROPERTY_ROOT, pathMapping.getFrom()); - properties.put(ResourceChangeListener.PATHS, new String[]{pathMapping.getFrom()}); + properties.put(ResourceChangeListener.PATHS, new String[]{pathMapping.getTo()}); ServiceRegistration registration = context.registerService( new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, From b5b4e918abaa3c88d354d10693f9ac7213dc4a9e Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 28 Apr 2026 23:01:01 +0300 Subject: [PATCH 12/92] [EAK-651] Update path sampling logic; handle NullPointerException in ResourceHelper --- .../exadel/aem/toolkit/core/relay/services/PathSampler.java | 4 ++-- .../exadel/aem/toolkit/core/relay/utils/ResourceHelper.java | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) 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 index e99b64460..6bcace2dc 100644 --- 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 @@ -139,7 +139,7 @@ private static List resolveXPath(String expression, Session session) { String effectiveExpression = expression.trim(); Matcher matcher = LIMIT_PREDICATE.matcher(effectiveExpression); int limit = -1; - if (matcher.matches()) { + if (matcher.find()) { limit = Integer.parseInt(matcher.group(1)); effectiveExpression = effectiveExpression.replace(matcher.group(), StringUtils.EMPTY).trim(); } @@ -157,7 +157,7 @@ private static List resolveXPath(String expression, Session session) { result.add(next.getPath()); } return result; - } catch (RepositoryException e) { + } catch (RepositoryException | NullPointerException e) { LOG.error("Failed to parse the path sample expression {}", expression, e); return Collections.emptyList(); } diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index 6be23759f..4a71e27a1 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -65,6 +65,9 @@ public static Resource getResource( Resource result = effectiveResolver.getResource(path); if (result == null) { LOG.warn("Could not resolve {} with user {}", path, effectiveResolver.getUserID()); + if (!effectiveResolver.equals(basicResolver)) { + effectiveResolver.close(); + } return onFailure.get(); } if (!effectiveResolver.equals(basicResolver)) { From 9ed73086a0298738e798de4283ea69d6d499ff78 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 28 Apr 2026 23:31:03 +0300 Subject: [PATCH 13/92] [EAK-651] Implement thread-safe SubsidiaryHolder for ResourceResolver management --- .../core/relay/utils/ResourceHelper.java | 64 ++++++++++++++++--- .../core/relay/utils/ResourceHelperTest.java | 57 ++++++++++++++++- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index 4a71e27a1..ea540a2e8 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -13,7 +13,9 @@ */ package com.exadel.aem.toolkit.core.relay.utils; +import java.io.Closeable; import java.util.Iterator; +import java.util.Map; import java.util.function.Function; import java.util.function.Supplier; import java.util.function.UnaryOperator; @@ -38,7 +40,7 @@ public class ResourceHelper { private static final Logger LOG = LoggerFactory.getLogger(ResourceHelper.class); - private static final String KEY_SUBSIDIARY = "subsidiary"; + static final String KEY_SUBSIDIARY = "subsidiary"; /** Default (instantiation-blocking) constructor */ private ResourceHelper() {} @@ -74,14 +76,17 @@ public static Resource getResource( // We have created another {@link ResourceResolver} via the {@code resolverModifier}. We cannot close it // in place -- instead, 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 {@code basicResolver} so that it will be - // automatically closed when the {@code basicResolver} is closed by Sling - // See {@link ResourceResolver#getPropertyMap()}. - ResourceResolver existingSubsidiary = (ResourceResolver) basicResolver.getPropertyMap().get(KEY_SUBSIDIARY); - if (existingSubsidiary != null) { - LOG.warn("A subsidiary resolver for {} will close", existingSubsidiary.getUserID()); - existingSubsidiary.close(); - } - basicResolver.getPropertyMap().put(KEY_SUBSIDIARY, effectiveResolver); + // automatically closed when the {@code basicResolver} is closed by Sling. + // A {@link SubsidiaryHolder} wrapper is used so that the swap-and-close of replaced resolvers is atomic. + // See {@link ResourceResolver#getPropertyMap()} + Map propertyMap = basicResolver.getPropertyMap(); + propertyMap.compute(KEY_SUBSIDIARY, (key, existing) -> { + if (existing instanceof SubsidiaryHolder) { + ((SubsidiaryHolder) existing).swap(effectiveResolver); + return existing; + } + return new SubsidiaryHolder(effectiveResolver); + }); } LOG.debug("Resolved {} to {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); return onSuccess.apply(result); @@ -156,4 +161,45 @@ public static Iterator listChildren(Resource target, String path) { private static void reportMissingContext(String path) { LOG.warn("Missing resolution context for {}", path); } + + /** + * A thread-safe {@link Closeable} wrapper around a subsidiary {@link ResourceResolver}. Stored in the property + * map of a base resolver so that Sling automatically closes the held resolver when the base resolver is closed. + * The {@link #swap(ResourceResolver)} method atomically replaces the held resolver, closing the previous one + */ + static class SubsidiaryHolder implements Closeable { + + private ResourceResolver resolver; + + /** + * Creates a new holder with the provided resolver + * @param resolver Initial subsidiary {@link ResourceResolver} + */ + SubsidiaryHolder(ResourceResolver resolver) { + this.resolver = resolver; + } + + /** + * Atomically replaces the held resolver with a new one, closing the previous resolver if present + * @param newResolver The replacement {@link ResourceResolver} + */ + synchronized void swap(ResourceResolver newResolver) { + if (resolver != null) { + LOG.warn("A subsidiary resolver for {} will close", resolver.getUserID()); + resolver.close(); + } + resolver = newResolver; + } + + /** + * {@inheritDoc} + */ + @Override + public synchronized void close() { + if (resolver != null) { + resolver.close(); + resolver = null; + } + } + } } diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java index ffce73334..a640783c4 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java @@ -17,6 +17,12 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.UnaryOperator; import org.apache.sling.api.resource.Resource; @@ -92,7 +98,7 @@ public void shouldTrackSubsidiaryResolver() { PATH_TARGET, resource -> resource, () -> null); - assertEquals(modifiedResolver, propertyMap.get("subsidiary")); + assertTrue(propertyMap.get("subsidiary") instanceof ResourceHelper.ResolverHolder); // Second call with a different modified resolver → existing subsidiary is closed, new one stored ResourceResolver nextResolver = Mockito.mock(ResourceResolver.class); @@ -106,7 +112,54 @@ public void shouldTrackSubsidiaryResolver() { resource -> resource, () -> null); Mockito.verify(modifiedResolver).close(); - assertEquals(nextResolver, propertyMap.get("subsidiary")); + assertTrue(propertyMap.get(ResourceHelper.KEY_SUBSIDIARY) instanceof ResourceHelper.ResolverHolder); + } + + @Test + public void shouldHandleConcurrentSubsidiaryAccess() throws InterruptedException { + Resource targetResource = context.create().resource(PATH_TARGET); + + int threadCount = 8; + Map propertyMap = new ConcurrentHashMap<>(); + ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); + Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); + + AtomicInteger closedCount = new AtomicInteger(); + ResourceResolver[] resolvers = new ResourceResolver[threadCount]; + for (int i = 0; i < threadCount; i++) { + ResourceResolver mock = Mockito.mock(ResourceResolver.class); + Mockito.when(mock.getResource(PATH_TARGET)).thenReturn(targetResource); + Mockito.when(mock.getUserID()).thenReturn("user-" + i); + Mockito.doAnswer(inv -> { + closedCount.incrementAndGet(); + return null; + }).when(mock).close(); + resolvers[i] = mock; + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + for (ResourceResolver resolver : resolvers) { + executor.submit(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + ResourceHelper.getResource( + basicResolver, + r -> resolver, + PATH_TARGET, + resource -> resource, + () -> null); + }); + } + startLatch.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + assertNotNull(propertyMap.get(ResourceHelper.KEY_SUBSIDIARY)); + assertEquals(threadCount - 1, closedCount.get()); } @Test From aed6e25eb69c8d8285314a3cc728f183e9021bec Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Wed, 29 Apr 2026 00:57:07 +0300 Subject: [PATCH 14/92] [EAK-651] Rename service class --- .../aem/toolkit/core/relay/utils/ResourceHelper.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index ea540a2e8..9e47f69dd 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -81,11 +81,11 @@ public static Resource getResource( // See {@link ResourceResolver#getPropertyMap()} Map propertyMap = basicResolver.getPropertyMap(); propertyMap.compute(KEY_SUBSIDIARY, (key, existing) -> { - if (existing instanceof SubsidiaryHolder) { - ((SubsidiaryHolder) existing).swap(effectiveResolver); + if (existing instanceof ResolverHolder) { + ((ResolverHolder) existing).swap(effectiveResolver); return existing; } - return new SubsidiaryHolder(effectiveResolver); + return new ResolverHolder(effectiveResolver); }); } LOG.debug("Resolved {} to {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); @@ -167,7 +167,7 @@ private static void reportMissingContext(String path) { * map of a base resolver so that Sling automatically closes the held resolver when the base resolver is closed. * The {@link #swap(ResourceResolver)} method atomically replaces the held resolver, closing the previous one */ - static class SubsidiaryHolder implements Closeable { + static class ResolverHolder implements Closeable { private ResourceResolver resolver; From f8f31902a279e43a17bacd233d33ac95b4f88257 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Wed, 29 Apr 2026 00:57:22 +0300 Subject: [PATCH 15/92] [EAK-651] Fix minor linting issues --- .../toolkit/core/relay/services/RelayConfig.java | 8 ++++---- .../toolkit/core/relay/services/RelayProvider.java | 8 ++++---- .../core/relay/services/RelayProviderHost.java | 5 ++++- .../aem/toolkit/core/relay/utils/PathHelper.java | 2 ++ .../toolkit/core/relay/utils/ResourceHelper.java | 14 ++++++++++---- 5 files changed, 24 insertions(+), 13 deletions(-) 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 index 5643ed37c..73dc32657 100644 --- 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 @@ -50,8 +50,8 @@ */ @AttributeDefinition( name = "User mappings", - description = "Optional list of user mapping rules. " + - "The mapping target is either \"user:password\" or \"registered_subservice\"." + description = "Optional list of user mapping rules. " + + "The mapping target is either \"user:password\" or \"registered_subservice\"." ) String[] userMappings() default {}; @@ -62,8 +62,8 @@ */ @AttributeDefinition( name = "Report paths", - description = "Optional list of JCR paths or XPath expressions. " + - "Target paths matching the list will be reported as changed as the relay is enabled or disabled." + description = "Optional list of JCR paths or XPath expressions. " + + "Target paths matching the list will be reported as changed as the relay is enabled or disabled." ) String[] reportedPaths() default {}; 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 index 682c10108..7909cc21e 100644 --- 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 @@ -292,15 +292,15 @@ Builder target(String value) { /** * Adds a user identity mapping from a source user ID to a target user or subservice - * @param source Source user ID - * @param value Target user ID, {@code "user:password"} credential string, or service subservice name + * @param from Source user ID + * @param to Target user ID, {@code "user:password"} credential string, or service subservice name * @return This builder */ - Builder userMapping(String source, String value) { + Builder userMapping(String from, String to) { if (userMappings == null) { userMappings = new HashMap<>(); } - userMappings.put(source, value); + userMappings.put(from, to); return this; } 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 index 2e0cad54f..926a19b38 100644 --- 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 @@ -156,6 +156,9 @@ private void createRelays( * Represents a source-to-target mapping entry used for path or user identity translation */ private static class Mapping { + + private static final int HASH_SEED = 31; + private final String from; private final String to; @@ -204,7 +207,7 @@ public final boolean equals(Object other) { @Override public int hashCode() { int result = Objects.hashCode(from); - result = 31 * result + Objects.hashCode(to); + result = HASH_SEED * result + Objects.hashCode(to); return result; } diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java index 4ef30a8a1..b5c88e2d4 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java @@ -19,6 +19,8 @@ /** * 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 PathHelper { diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index 9e47f69dd..2ff885613 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -35,6 +35,8 @@ /** * 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 ResourceHelper { @@ -74,7 +76,7 @@ public static Resource getResource( } if (!effectiveResolver.equals(basicResolver)) { // We have created another {@link ResourceResolver} via the {@code resolverModifier}. We cannot close it - // in place -- instead, we need it to live as long as the resource(-s) we have resolved with it live. + // in place - instead, 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 {@code basicResolver} so that it will be // automatically closed when the {@code basicResolver} is closed by Sling. // A {@link SubsidiaryHolder} wrapper is used so that the swap-and-close of replaced resolvers is atomic. @@ -114,7 +116,7 @@ public static Resource getResource( return null; } LOG.debug("Falling back to parent resource provider for {}", path); - return ((ResourceProvider)resourceProvider).getResource((ResolveContext) resolveContext, path, resourceContext, parent); + return ((ResourceProvider) resourceProvider).getResource((ResolveContext) resolveContext, path, resourceContext, parent); } /** @@ -134,7 +136,7 @@ public static Iterator listChildren( reportMissingContext(parent.getPath()); return null; } - return ((ResourceProvider)resourceProvider).listChildren((ResolveContext) resolveContext, parent); + return ((ResourceProvider) resourceProvider).listChildren((ResolveContext) resolveContext, parent); } /** @@ -162,6 +164,10 @@ private static void reportMissingContext(String path) { LOG.warn("Missing resolution context for {}", path); } + /* ------------------ + Subsidiary classes + ------------------ */ + /** * A thread-safe {@link Closeable} wrapper around a subsidiary {@link ResourceResolver}. Stored in the property * map of a base resolver so that Sling automatically closes the held resolver when the base resolver is closed. @@ -175,7 +181,7 @@ static class ResolverHolder implements Closeable { * Creates a new holder with the provided resolver * @param resolver Initial subsidiary {@link ResourceResolver} */ - SubsidiaryHolder(ResourceResolver resolver) { + ResolverHolder(ResourceResolver resolver) { this.resolver = resolver; } From b8c173f5eb26d7d07acf42ca9fb40447ee8945d2 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Wed, 29 Apr 2026 10:05:53 +0300 Subject: [PATCH 16/92] [EAK-651] Simplify ResourceHelper method calls --- .../core/relay/utils/ResourceHelper.java | 67 ++++++++++--------- .../core/relay/utils/ResourceHelperTest.java | 59 ++++++++-------- 2 files changed, 66 insertions(+), 60 deletions(-) diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index 2ff885613..823f7686a 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -16,8 +16,6 @@ import java.io.Closeable; import java.util.Iterator; import java.util.Map; -import java.util.function.Function; -import java.util.function.Supplier; import java.util.function.UnaryOperator; import java.util.stream.StreamSupport; @@ -48,22 +46,19 @@ public class ResourceHelper { private ResourceHelper() {} /** - * Resolves a resource at the provided path using a potentially modified {@link ResourceResolver}. Falls back to - * the {@code onFailure} supplier when the path cannot be resolved + * 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. Manages the lifecycle of any + * subsidiary resolver created by the {@code resolverModifier} * @param basicResolver The base {@link ResourceResolver} instance used for resolution * @param resolverModifier A {@code UnaryOperator} that optionally produces an alternative {@code ResourceResolver} * from the provided one * @param path JCR path of the resource to resolve - * @param onSuccess A {@code Function} applied to the resolved resource to produce the final result - * @param onFailure A {@code Supplier} invoked when the resource cannot be resolved * @return A nullable {@link Resource} instance */ public static Resource getResource( ResourceResolver basicResolver, UnaryOperator resolverModifier, - String path, - Function onSuccess, - Supplier onFailure) { + String path) { ResourceResolver effectiveResolver = resolverModifier.apply(basicResolver); Resource result = effectiveResolver.getResource(path); @@ -72,7 +67,7 @@ public static Resource getResource( if (!effectiveResolver.equals(basicResolver)) { effectiveResolver.close(); } - return onFailure.get(); + return null; } if (!effectiveResolver.equals(basicResolver)) { // We have created another {@link ResourceResolver} via the {@code resolverModifier}. We cannot close it @@ -91,52 +86,62 @@ public static Resource getResource( }); } LOG.debug("Resolved {} to {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); - return onSuccess.apply(result); + return result; } /** - * Delegates resource resolution for the provided path to a parent {@link ResourceProvider} - * @param resourceProvider Parent {@code ResourceProvider} instance - * @param resolveContext {@link ResolveContext} associated with the current resolution - * @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 provider or - * context is missing + * Delegates resource resolution for the provided path to a parent {@link ResourceProvider} obtained from the + * given {@link ResolveContext} + * @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( - ResourceProvider resourceProvider, ResolveContext resolveContext, String path, ResourceContext resourceContext, Resource parent) { - if (resourceProvider == null || resolveContext == null) { + if (resolveContext == null) { + reportMissingContext(path); + return null; + } + ResourceProvider resourceProvider = resolveContext.getParentResourceProvider(); + ResolveContext parentContext = resolveContext.getParentResolveContext(); + if (resourceProvider == null || parentContext == null) { reportMissingContext(path); return null; } LOG.debug("Falling back to parent resource provider for {}", path); - return ((ResourceProvider) resourceProvider).getResource((ResolveContext) resolveContext, path, resourceContext, parent); + return ((ResourceProvider) resourceProvider).getResource((ResolveContext) parentContext, path, resourceContext, parent); } /** - * Delegates child listing for the provided parent resource to a parent {@link ResourceProvider} - * @param resourceProvider Parent {@code ResourceProvider} instance - * @param resolveContext {@link ResolveContext} associated with the current child listing - * @param parent Parent {@link Resource} whose children to list - * @return A nullable {@code Iterator} of child {@link Resource} instances, or {@code null} if the provider or - * context is missing + * Delegates child listing for the provided parent resource to a parent {@link ResourceProvider} obtained from the + * given {@link ResolveContext} + * @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( - ResourceProvider resourceProvider, ResolveContext resolveContext, Resource parent) { - if (resourceProvider == null || resolveContext == null) { + if (resolveContext == null) { + reportMissingContext(parent.getPath()); + return null; + } + ResourceProvider resourceProvider = resolveContext.getParentResourceProvider(); + ResolveContext parentContext = resolveContext.getParentResolveContext(); + if (resourceProvider == null || parentContext == null) { reportMissingContext(parent.getPath()); return null; } - return ((ResourceProvider) resourceProvider).listChildren((ResolveContext) resolveContext, parent); + return ((ResourceProvider) resourceProvider).listChildren((ResolveContext) parentContext, parent); } /** diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java index a640783c4..8991e4eb9 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java @@ -59,23 +59,19 @@ public void shouldResolveResource() { context.create().resource(PATH_TARGET); ResourceResolver resolver = context.resourceResolver(); - // Resource found → onSuccess applied + // Resource found → returned directly Resource found = ResourceHelper.getResource( resolver, UnaryOperator.identity(), - PATH_TARGET, - resource -> resource, - () -> null); + PATH_TARGET); assertNotNull(found); assertEquals(PATH_TARGET, found.getPath()); - // Resource not found → onFailure invoked + // Resource not found → null Resource notFound = ResourceHelper.getResource( resolver, UnaryOperator.identity(), - PATH_TARGET + "/missing", - resource -> resource, - () -> null); + PATH_TARGET + "/missing"); assertNull(notFound); } @@ -95,9 +91,7 @@ public void shouldTrackSubsidiaryResolver() { ResourceHelper.getResource( basicResolver, resolver -> modifiedResolver, - PATH_TARGET, - resource -> resource, - () -> null); + PATH_TARGET); assertTrue(propertyMap.get("subsidiary") instanceof ResourceHelper.ResolverHolder); // Second call with a different modified resolver → existing subsidiary is closed, new one stored @@ -108,9 +102,7 @@ public void shouldTrackSubsidiaryResolver() { ResourceHelper.getResource( basicResolver, resolver -> nextResolver, - PATH_TARGET, - resource -> resource, - () -> null); + PATH_TARGET); Mockito.verify(modifiedResolver).close(); assertTrue(propertyMap.get(ResourceHelper.KEY_SUBSIDIARY) instanceof ResourceHelper.ResolverHolder); } @@ -149,9 +141,7 @@ public void shouldHandleConcurrentSubsidiaryAccess() throws InterruptedException ResourceHelper.getResource( basicResolver, r -> resolver, - PATH_TARGET, - resource -> resource, - () -> null); + PATH_TARGET); }); } startLatch.countDown(); @@ -167,14 +157,17 @@ public void shouldDelegateGetResourceToParentProvider() { Resource targetResource = context.create().resource(PATH_TARGET); ResourceProvider mockProvider = newMockProvider(); - ResolveContext mockResolveContext = newMockResolveContext(); + ResolveContext mockParentContext = newMockResolveContext(); ResourceContext mockResourceContext = Mockito.mock(ResourceContext.class); Mockito.when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_TARGET), Mockito.any(), Mockito.any())) .thenReturn(targetResource); + ResolveContext mockResolveContext = newMockResolveContext(); + Mockito.doReturn(mockProvider).when(mockResolveContext).getParentResourceProvider(); + Mockito.doReturn(mockParentContext).when(mockResolveContext).getParentResolveContext(); + // Happy path → delegates to parent provider Resource result = ResourceHelper.getResource( - mockProvider, mockResolveContext, PATH_TARGET, mockResourceContext, @@ -182,11 +175,13 @@ public void shouldDelegateGetResourceToParentProvider() { assertNotNull(result); assertEquals(PATH_TARGET, result.getPath()); - // Null provider → null - assertNull(ResourceHelper.getResource(null, mockResolveContext, PATH_TARGET, mockResourceContext, null)); - // Null context → null - assertNull(ResourceHelper.getResource(mockProvider, null, PATH_TARGET, mockResourceContext, null)); + assertNull(ResourceHelper.getResource(null, PATH_TARGET, mockResourceContext, null)); + + // Null parent provider → null + ResolveContext noProviderContext = newMockResolveContext(); + Mockito.doReturn(null).when(noProviderContext).getParentResourceProvider(); + assertNull(ResourceHelper.getResource(noProviderContext, PATH_TARGET, mockResourceContext, null)); } @Test @@ -195,21 +190,27 @@ public void shouldDelegateListChildrenToParentProvider() { Resource child = context.create().resource(PATH_TARGET + PATH_CHILD); ResourceProvider mockProvider = newMockProvider(); - ResolveContext mockResolveContext = newMockResolveContext(); + ResolveContext mockParentContext = newMockResolveContext(); Mockito.when(mockProvider.listChildren(Mockito.any(), Mockito.eq(parent))) .thenReturn(Collections.singletonList(child).iterator()); + ResolveContext mockResolveContext = newMockResolveContext(); + Mockito.doReturn(mockProvider).when(mockResolveContext).getParentResourceProvider(); + Mockito.doReturn(mockParentContext).when(mockResolveContext).getParentResolveContext(); + // Happy path → delegates to parent provider - Iterator result = ResourceHelper.listChildren(mockProvider, mockResolveContext, parent); + Iterator result = ResourceHelper.listChildren(mockResolveContext, parent); assertNotNull(result); assertTrue(result.hasNext()); assertEquals(PATH_TARGET + PATH_CHILD, result.next().getPath()); - // Null provider → null - assertNull(ResourceHelper.listChildren(null, mockResolveContext, parent)); - // Null context → null - assertNull(ResourceHelper.listChildren(mockProvider, null, parent)); + assertNull(ResourceHelper.listChildren(null, parent)); + + // Null parent provider → null + ResolveContext noProviderContext = newMockResolveContext(); + Mockito.doReturn(null).when(noProviderContext).getParentResourceProvider(); + assertNull(ResourceHelper.listChildren(noProviderContext, parent)); } @Test From 09708439bf4851f0e99de6cba13a3b54a7d252d4 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Wed, 29 Apr 2026 11:52:18 +0300 Subject: [PATCH 17/92] [EAK-651] Modify the Relay config structure --- .../aem/toolkit/core/relay/services/RelayConfig.java | 10 +++++----- .../toolkit/core/relay/services/RelayProviderHost.java | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 index 73dc32657..a1cf7cc36 100644 --- 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 @@ -20,7 +20,7 @@ * OSGi Metatype configuration for the relay provider host. Defines path and user mappings and optional * change-reporting settings */ -@ObjectClassDefinition(name = "EToolbox Authoring Kit - Relay Configuration") +@ObjectClassDefinition(name = "EToolbox Authoring Kit - Relay") public @interface RelayConfig { /** @@ -53,7 +53,7 @@ description = "Optional list of user mapping rules. " + "The mapping target is either \"user:password\" or \"registered_subservice\"." ) - String[] userMappings() default {}; + String[] userMappings(); /** * Gets the optional list of JCR paths or XPath expressions whose matching resources are reported as changed @@ -61,11 +61,11 @@ * @return A non-null array of path or XPath expression strings; might be empty */ @AttributeDefinition( - name = "Report paths", + name = "Announce changes", description = "Optional list of JCR paths or XPath expressions. " - + "Target paths matching the list will be reported as changed as the relay is enabled or disabled." + + "Target paths matching the list will be announced as changed as the relay is enabled or disabled." ) - String[] reportedPaths() default {}; + String[] announced(); } 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 index 926a19b38..9afdac8c9 100644 --- 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 @@ -93,7 +93,7 @@ private void activate(BundleContext context, RelayConfig config) { context, pathMappings, userMappings, - ArrayUtils.nullToEmpty(config.reportedPaths())); + ArrayUtils.nullToEmpty(config.announced())); } finally { lock.unlock(); } From c8b7725541c016cc5fd29a6eeac01860032e6466 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Wed, 29 Apr 2026 13:51:09 +0300 Subject: [PATCH 18/92] [Tech] Introduce ServiceUtil for operations with OSGi components called from outside --- .../models/internal/ConfigAccess.java | 17 +- .../models/internal/ConfigDefinition.java | 61 +++--- .../aem/toolkit/core/utils/ServiceUtil.java | 168 +++++++++++++++++ .../models/internal/RenderConditionTest.java | 2 +- .../servlets/form/ConfigDataSourceTest.java | 7 - .../toolkit/core/utils/ServiceUtilTest.java | 173 ++++++++++++++++++ 6 files changed, 378 insertions(+), 50 deletions(-) create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java 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..562b062eb 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,11 @@ 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); - 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 +307,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/utils/ServiceUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java new file mode 100644 index 000000000..74954aae6 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.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.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 {}"; + + /** + * Default (instantiation-blocking) constructor + */ + private ServiceUtil() { + } + + /** + * 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(serviceClass); + BundleContext context = bundle != null ? bundle.getBundleContext() : null; + if (context == null) { + LOG.error(ERROR_CONTEXT, serviceClass.getName()); + return; + } + withService(serviceClass, context, consumer); + } + + /** + * 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(serviceClass); + BundleContext context = bundle != null ? bundle.getBundleContext() : null; + if (context == null) { + LOG.error(ERROR_CONTEXT, serviceClass.getName()); + return defaultValue; + } + return withService(serviceClass, context, processor, defaultValue); + } + + /** + * 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) { + LOG.error(ERROR_RETRIEVAL, serviceClass.getName()); + return; + } + consumer.accept(service); + } 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 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) { + LOG.error(ERROR_RETRIEVAL, serviceClass.getName()); + return defaultValue; + } + result = processor.apply(service); + } catch (IllegalArgumentException | IllegalStateException e) { + LOG.error(ERROR_HANDLING, serviceClass.getName(), e); + } finally { + ungetService(context, reference); + } + return result; + } + + /** + * 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/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/utils/ServiceUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java new file mode 100644 index 000000000..96d4ad588 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java @@ -0,0 +1,173 @@ +/* + * 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(); + + @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: 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); + + // Function throws: 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); + + // 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); + } + + 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 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; + } + } +} From 644494fab8353128572349c50e8f7a0d575a098b Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Wed, 29 Apr 2026 14:54:27 +0300 Subject: [PATCH 19/92] [Tech] Replace marker annotations for uniformity --- .../core/configurator/servlets/form/ConfigDataSource.java | 4 ++-- .../servlets/replication/ReplicationServlet.java | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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()); From f5e4670521dd17b7651f4eb0d7011186c3fa0fb0 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Wed, 29 Apr 2026 16:03:06 +0300 Subject: [PATCH 20/92] [Tech] Improve exception handling --- .../aem/toolkit/core/utils/ServiceUtil.java | 18 +++++++++++---- .../toolkit/core/utils/ServiceUtilTest.java | 22 +++++++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) 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 index 74954aae6..d8cfa255d 100644 --- 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 @@ -37,6 +37,7 @@ public class ServiceUtil { 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 @@ -54,7 +55,7 @@ private ServiceUtil() { public static void withService( @Nonnull Class serviceClass, @Nonnull Consumer consumer) { - Bundle bundle = FrameworkUtil.getBundle(serviceClass); + Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class); BundleContext context = bundle != null ? bundle.getBundleContext() : null; if (context == null) { LOG.error(ERROR_CONTEXT, serviceClass.getName()); @@ -77,7 +78,7 @@ public static U withService( @Nonnull Class serviceClass, @Nonnull Function processor, U defaultValue) { - Bundle bundle = FrameworkUtil.getBundle(serviceClass); + Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class); BundleContext context = bundle != null ? bundle.getBundleContext() : null; if (context == null) { LOG.error(ERROR_CONTEXT, serviceClass.getName()); @@ -107,7 +108,11 @@ public static void withService( LOG.error(ERROR_RETRIEVAL, serviceClass.getName()); return; } - consumer.accept(service); + 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 { @@ -141,7 +146,12 @@ public static U withService( LOG.error(ERROR_RETRIEVAL, serviceClass.getName()); return defaultValue; } - result = processor.apply(service); + 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 { 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 index 96d4ad588..073095bd5 100644 --- 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 @@ -85,7 +85,7 @@ public void shouldSkipExecutionWhenServiceUnavailable() { @Test public void shouldSwallowExceptionsAndReleaseService() { - // Consumer throws: exception swallowed, service still released in the finally block + // Consumer throws IllegalArgumentException: exception swallowed, service still released in the finally block MockContext consumerCtx = newMockContextReturningService(); ServiceUtil.withService( StubService.class, @@ -93,7 +93,15 @@ public void shouldSwallowExceptionsAndReleaseService() { s -> { throw new IllegalArgumentException(TEST_EXCEPTION_MESSAGE); }); Mockito.verify(consumerCtx.bundleContext).ungetService(consumerCtx.serviceRef); - // Function throws: returns default, service still released in the finally block + // 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, @@ -103,6 +111,16 @@ public void shouldSwallowExceptionsAndReleaseService() { 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); From 57cef56e5a6490aeb3024e66abd65db7a87a02e9 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 30 Apr 2026 14:43:54 +0300 Subject: [PATCH 21/92] [EAK-651] Add method for creating a resolver for a provided user name --- .../aem/toolkit/core/utils/ResolverUtil.java | 93 ++++++++++++++++--- 1 file changed, 82 insertions(+), 11 deletions(-) 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 index 919acdc4c..c259b9da9 100644 --- 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 @@ -13,20 +13,29 @@ */ package com.exadel.aem.toolkit.core.utils; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +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.jetbrains.annotations.NotNull; +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 in the context of - * {@code EToolbox Configurator} - *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own code + * 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 { @@ -39,20 +48,82 @@ private ResolverUtil() { } /** - * Creates a new {@link ResourceResolver} instance for the given username using the provided resource resolver - * factory + * 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 */ - @NotNull - public static ResourceResolver newResolver(@NotNull ResourceResolverFactory factory) throws LoginException { - + @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. The user identifier may be in the format of {@code username@bundleId} or + * {@code login:password} + * @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 { + + if (!StringUtils.contains(user, CoreConstants.SEPARATOR_AT)) { + Map authInfo = new HashMap<>(); + if (StringUtils.contains(user, CoreConstants.SEPARATOR_COLON)) { + authInfo.put( + ResourceResolverFactory.USER, + StringUtils.substringBefore(user, CoreConstants.SEPARATOR_COLON)); + authInfo.put( + ResourceResolverFactory.PASSWORD, + StringUtils.substringAfter(user, CoreConstants.SEPARATOR_COLON).toCharArray()); + return factory.getResourceResolver(authInfo); + } + authInfo.put(ResourceResolverFactory.SUBSERVICE, user); + return factory.getServiceResourceResolver(authInfo); + } + + String localizedUserId = StringUtils.substringBefore(user, CoreConstants.SEPARATOR_AT); + String bundleId = StringUtils.substringAfter(user, CoreConstants.SEPARATOR_AT); + BundleContext bundleContext = FrameworkUtil.getBundle(ResolverUtil.class).getBundleContext(); + Bundle targetBundle = Arrays + .stream(bundleContext.getBundles()) + .filter(b -> b.getSymbolicName().equals(bundleId)) + .findFirst() + .orElse(null); + if (targetBundle == null) { + return factory.getResourceResolver(null); + } + + AtomicReference nestedException = new AtomicReference<>(); + ResourceResolver resolverByTargetBundle = ServiceUtil.withService( + ResourceResolverFactory.class, + targetBundle.getBundleContext(), + f -> { + try { + return f.getServiceResourceResolver( + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, localizedUserId) + ); + } catch (LoginException e) { + nestedException.set(e); + 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 @@ -60,8 +131,8 @@ public static ResourceResolver newResolver(@NotNull ResourceResolverFactory fact * @return New instance of {@code ResourceResolver} * @throws LoginException If the resolver cannot be created */ - @NotNull - public static ResourceResolver newResolver(@NotNull SlingHttpServletRequest request) throws LoginException { + @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; From a98408189e9af6720eb434dc81b4203a260a33e1 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 30 Apr 2026 14:44:38 +0300 Subject: [PATCH 22/92] [EAK-651] Reaorder methods; cosmetic renaming --- .../core/relay/utils/ResourceHelper.java | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index 823f7686a..94565b1b2 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -16,10 +16,10 @@ import java.io.Closeable; import java.util.Iterator; import java.util.Map; +import java.util.Spliterators; import java.util.function.UnaryOperator; import java.util.stream.StreamSupport; -import org.apache.commons.collections4.IteratorUtils; import org.apache.sling.api.resource.Resource; import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.spi.resource.provider.ResolveContext; @@ -90,12 +90,13 @@ public static Resource getResource( } /** - * Delegates resource resolution for the provided path to a parent {@link ResourceProvider} obtained from the - * given {@link ResolveContext} - * @param resolveContext {@link ResolveContext} from which the parent provider and parent context are extracted - * @param path JCR path of the resource to resolve + * 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, UnaryOperator, 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} + * @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 */ @@ -109,23 +110,41 @@ public static Resource getResource( reportMissingContext(path); return null; } - ResourceProvider resourceProvider = resolveContext.getParentResourceProvider(); + ResourceProvider parentResourceProvider = resolveContext.getParentResourceProvider(); ResolveContext parentContext = resolveContext.getParentResolveContext(); - if (resourceProvider == null || parentContext == null) { + if (parentResourceProvider == null || parentContext == null) { reportMissingContext(path); return null; } - LOG.debug("Falling back to parent resource provider for {}", path); - return ((ResourceProvider) resourceProvider).getResource((ResolveContext) parentContext, path, resourceContext, parent); + reportFallingBack(path); + 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) { + if (target instanceof RelayResource) { + return target.listChildren(); + } + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(target.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} + * 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 + * @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( @@ -135,30 +154,14 @@ public static Iterator listChildren( reportMissingContext(parent.getPath()); return null; } - ResourceProvider resourceProvider = resolveContext.getParentResourceProvider(); + ResourceProvider parentResourceProvider = resolveContext.getParentResourceProvider(); ResolveContext parentContext = resolveContext.getParentResolveContext(); - if (resourceProvider == null || parentContext == null) { + if (parentResourceProvider == null || parentContext == null) { reportMissingContext(parent.getPath()); return null; } - return ((ResourceProvider) resourceProvider).listChildren((ResolveContext) parentContext, 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) { - if (target instanceof RelayResource) { - return target.listChildren(); - } - return StreamSupport.stream(IteratorUtils.asIterable(target.listChildren()).spliterator(), false) - .map(child -> new RelayResource(child, path + CoreConstants.SEPARATOR_SLASH + child.getName())) - .map(Resource.class::cast) - .iterator(); + reportFallingBack(parent.getPath()); + return ((ResourceProvider) parentResourceProvider).listChildren((ResolveContext) parentContext, parent); } /** @@ -169,6 +172,14 @@ 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); + } + /* ------------------ Subsidiary classes ------------------ */ From c1e1c4e1670545cb787b190db2f2c428e70a64af Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 30 Apr 2026 14:45:11 +0300 Subject: [PATCH 23/92] [EAK-651] Add utility method --- .../aem/toolkit/core/relay/utils/PathHelper.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java index b5c88e2d4..db085782e 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java @@ -28,6 +28,19 @@ public class PathHelper { private PathHelper() { } + /** + * 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 isSubpath(String path, String root) { + String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); + String normalizedRoot = StringUtils.stripEnd(StringUtils.defaultString(root), CoreConstants.SEPARATOR_SLASH); + return StringUtils.equals(normalizedPath, normalizedRoot) + || StringUtils.startsWith(path, 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} From 3daf1f726cf38ccd98888cd8610a597c2573f88f Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 30 Apr 2026 14:47:47 +0300 Subject: [PATCH 24/92] [EAK-651] Extract subsidiary data models; add a guardrail against shadowing an existing resource provider --- .../core/relay/models/ChangeSample.java | 91 +++++++++ .../core/relay/models/RelayMapping.java | 90 +++++++++ .../relay/services/RelayProviderHost.java | 175 +++++++++--------- 3 files changed, 268 insertions(+), 88 deletions(-) create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java 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..16a366032 --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java @@ -0,0 +1,91 @@ +/* + * 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} + */ + @Override + public final boolean equals(Object other) { + if (!(other instanceof ChangeSample)) { + return false; + } + ChangeSample announcement = (ChangeSample) other; + return Objects.equals(path, announcement.path); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hashCode(path); + } +} 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..b2e294ded --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java @@ -0,0 +1,90 @@ +/* + * 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; + +/** + * Represents a source-to-target mapping entry used for path or user identity translation. + *

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 static final int HASH_SEED = 31; + + 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()); + } + + /** + * {@inheritDoc} + */ + @Override + public final boolean equals(Object other) { + if (!(other instanceof RelayMapping)) { + return false; + } + RelayMapping mapping = (RelayMapping) other; + return Objects.equals(from, mapping.from) && Objects.equals(to, mapping.to); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + int result = Objects.hashCode(from); + result = HASH_SEED * result + Objects.hashCode(to); + return result; + } +} 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 index 9afdac8c9..a5eb3784c 100644 --- 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 @@ -14,31 +14,38 @@ package com.exadel.aem.toolkit.core.relay.services; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Dictionary; +import java.util.HashMap; import java.util.HashSet; import java.util.Hashtable; import java.util.List; -import java.util.Objects; +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.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 com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; +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.RelayMapping; +import com.exadel.aem.toolkit.core.relay.utils.PathHelper; import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; /** @@ -51,6 +58,8 @@ @Designate(ocd = RelayConfig.class, factory = true) public class RelayProviderHost { + private static final Logger LOG = LoggerFactory.getLogger(RelayProviderHost.class); + @Reference private transient ResourceResolverFactory resolverFactory; @@ -72,28 +81,39 @@ private void activate(BundleContext context, RelayConfig config) { if (!config.enabled()) { return; } - Set pathMappings = new HashSet<>(); - Set userMappings = new HashSet<>(); + + Set pathMappings = new HashSet<>(); for (String mappingSource : config.pathMappings()) { - Mapping mapping = ObjectConversionUtil.toObject(mappingSource, Mapping.class); - if (Mapping.isValid(mapping)) { + RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); + if (mapping != null && mapping.isValid()) { pathMappings.add(mapping); } } if (pathMappings.isEmpty()) { return; } + + Set userMappings = new HashSet<>(); for (String mappingSource : config.userMappings()) { - Mapping mapping = ObjectConversionUtil.toObject(mappingSource, Mapping.class); - if (Mapping.isValid(mapping)) { + RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); + if (mapping != null && mapping.isValid()) { userMappings.add(mapping); } } + + Set samples = new HashSet<>(); + for (String sampleSource : config.announcedPaths()) { + ChangeSample announcement = ObjectConversionUtil.toObject(sampleSource, ChangeSample.class); + if (announcement != null && StringUtils.isNotBlank(announcement.getPath())) { + samples.add(announcement); + } + } + createRelays( context, pathMappings, userMappings, - ArrayUtils.nullToEmpty(config.announced())); + samples); } finally { lock.unlock(); } @@ -118,27 +138,41 @@ private void deactivate() { /** * Creates and registers a {@link RelayProvider} service instance for each entry in the provided path mappings * collection - * @param context OSGi {@link BundleContext} used to register provider services - * @param pathMappings Collection of path mapping rules defining source-to-target path redirections - * @param userMappings Collection of user mapping rules defining source-to-target user identity redirections - * @param reportedPaths Array of JCR paths to report as changed when a provider starts or stops + * @param context OSGi {@link BundleContext} used to register provider services + * @param pathMappings Collection of path mapping rules defining source-to-target path redirections + * @param userMappings Collection of user mapping rules defining source-to-target user identity redirections + * @param samples Collection of rules defining JCR paths or XPath expressions to report as changed when the + * relay is enabled or disabled */ private void createRelays( BundleContext context, - Collection pathMappings, - Collection userMappings, - String[] reportedPaths) { - - for (Mapping pathMapping : pathMappings) { - - RelayProvider.Builder builder = RelayProvider + Collection pathMappings, + Collection userMappings, + Collection samples) { + + Map providedPaths = getExternallyProvidedPaths(context); + + for (RelayMapping pathMapping : pathMappings) { + String from = pathMapping.getFrom(); + String shadowedEntries = providedPaths.entrySet().stream() + .filter(e -> PathHelper.isSubpath(e.getKey(), from)) + .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 {}", + from, + shadowedEntries); + continue; + } + RelayProvider provider = RelayProvider .builder() .resolverFactory(resolverFactory) .source(pathMapping.getFrom()) - .target(pathMapping.getTo()); - userMappings.forEach(mapping -> builder.userMapping(mapping.getFrom(), mapping.getTo())); - Arrays.stream(reportedPaths).forEach(builder::reportedPath); - RelayProvider provider = builder.build(); + .target(pathMapping.getTo()) + .userMappings(userMappings) + .samples(samples) + .build(); Dictionary properties = new Hashtable<>(); properties.put(ResourceProvider.PROPERTY_ROOT, pathMapping.getFrom()); @@ -153,72 +187,37 @@ private void createRelays( } /** - * Represents a source-to-target mapping entry used for path or user identity translation + * Collects the JCR paths already provided by 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 class Mapping { - - private static final int HASH_SEED = 31; - - 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 - Mapping(@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 - */ - String getFrom() { - return from; + private static Map getExternallyProvidedPaths(BundleContext context) { + 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); } - - /** - * Gets the target value of this mapping - * @return A nullable target string - */ - String getTo() { - return to; + if (serviceReferences == null) { + return Collections.emptyMap(); } - - /** - * {@inheritDoc} - */ - @Override - public final boolean equals(Object other) { - if (!(other instanceof Mapping)) { - return false; + Map result = new HashMap<>(); + for (ServiceReference ref : serviceReferences) { + if (ref.toString().startsWith(CoreConstants.ROOT_PACKAGE)) { + continue; + } + Object providedPath = ref.getProperty(ResourceProvider.PROPERTY_ROOT); + Object providerId = ref.getProperty(ResourceProvider.PROPERTY_NAME); + if (providerId == null || providerId.toString().isEmpty()) { + 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()); } - Mapping mapping = (Mapping) other; - return Objects.equals(from, mapping.from) && Objects.equals(to, mapping.to); - } - - /** - * {@inheritDoc} - */ - @Override - public int hashCode() { - int result = Objects.hashCode(from); - result = HASH_SEED * result + Objects.hashCode(to); - return result; - } - - /** - * Gets whether the provided mapping is non-null and has non-blank source and target values - * @param value Nullable {@code Mapping} instance to validate - * @return True or false - */ - static boolean isValid(Mapping value) { - return value != null && StringUtils.isNoneBlank(value.getFrom(), value.getTo()); } + return result; } } From 469fc47c7977e7a3b6f6a5f0722ef55b8a5bfe25 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 30 Apr 2026 14:48:35 +0300 Subject: [PATCH 25/92] [EAK-651] Refactor PathSampler; add support for specifying a user --- .../core/relay/services/PathSampler.java | 232 +++++++++++++----- .../core/relay/services/RelayProvider.java | 114 ++++----- 2 files changed, 224 insertions(+), 122 deletions(-) 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 index 6bcace2dc..5125beed5 100644 --- 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 @@ -16,9 +16,8 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.jcr.RepositoryException; import javax.jcr.Session; @@ -29,11 +28,17 @@ 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.PathHelper; +import com.exadel.aem.toolkit.core.utils.ResolverUtil; /** * Resolves a list of JCR paths or XPath expressions to concrete JCR paths and produces @@ -43,41 +48,63 @@ class PathSampler { private static final Logger LOG = LoggerFactory.getLogger(PathSampler.class); - private static final Pattern LIMIT_PREDICATE = Pattern.compile("\\s+limit\\s+(\\d+)$", Pattern.CASE_INSENSITIVE); + private static final String PROPERTY_USER_ID = "userId"; + + private ResourceResolverFactory resolverFactory; + private Collection samples; + private String source; + private String target; - private final List samples; private Collection paths; /** - * Creates a new {@code PathSampler} with the provided list of path samples - * @param samples Nullable list of JCR paths or XPath expressions + * Default (instantiation-restricting) constructor */ - PathSampler(List samples) { - this.samples = samples; + private PathSampler() { } /** - * Resolves the configured path samples against the provided JCR session and produces a collection of - * {@link ResourceChange} notifications - * @param session JCR {@link Session} used to execute XPath queries + * Resolves the configured path samples and produces a collection of {@link ResourceChange} notifications * @return A non-null, possibly empty collection of {@link ResourceChange} instances */ - Collection createChanges(Session session) { - if (paths == null) { - paths = CollectionUtils.emptyIfNull(samples) + Collection createChanges() { + if (paths != null) { + return paths .stream() - .flatMap(s -> resolve(s, session).stream()) - .collect(Collectors.toSet()); + .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) + .collect(Collectors.toList()); } - return createChanges(); - } - /** - * Creates a collection of {@link ResourceChange} notifications from the previously resolved paths - * @return A non-null, possibly empty collection of {@link ResourceChange} instances - */ - Collection createChanges() { - return CollectionUtils.emptyIfNull(paths) + paths = new HashSet<>(); + + ResourceResolver resolver = null; + 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()); + } + } + if (resolver != null) { + resolver.close(); + } + // All resolved paths must point to {@code source} instead of {@code target} + paths = paths + .stream() + .map(p -> PathHelper.isSubpath(p, target) ? PathHelper.replace(p, target, source) : p) + .collect(Collectors.toSet()); + + return paths .stream() .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) .collect(Collectors.toList()); @@ -105,7 +132,7 @@ private static boolean isJcrPath(String value) { * @param value Arbitrary string to check * @return True or false */ - private static boolean isXPath(String value) { + private static boolean isXpath(String value) { if (StringUtils.isBlank(value)) { return false; } @@ -114,40 +141,18 @@ private static boolean isXPath(String value) { } /** - * Resolves the provided path or XPath expression to a list of concrete JCR paths - * @param pathOrExpression JCR path or XPath expression to resolve - * @param session JCR {@link Session} used for XPath query execution - * @return A non-null, possibly empty list of resolved JCR path strings - */ - private static List resolve(String pathOrExpression, Session session) { - if (isXPath(pathOrExpression)) { - return resolveXPath(pathOrExpression, session); - } else if (isJcrPath(pathOrExpression)) { - return Collections.singletonList(pathOrExpression); - } - return Collections.emptyList(); - } - - /** - * Executes the provided XPath expression against the given session and collects the resulting JCR paths. - * Supports an optional {@code limit N} suffix to cap the number of results - * @param expression XPath expression to execute - * @param session JCR {@link Session} used for query execution + * 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(String expression, Session session) { - String effectiveExpression = expression.trim(); - Matcher matcher = LIMIT_PREDICATE.matcher(effectiveExpression); - int limit = -1; - if (matcher.find()) { - limit = Integer.parseInt(matcher.group(1)); - effectiveExpression = effectiveExpression.replace(matcher.group(), StringUtils.EMPTY).trim(); - } + private static List resolveXpath(ChangeSample sample, Session session) { try { @SuppressWarnings("deprecation") - Query query = session.getWorkspace().getQueryManager().createQuery(effectiveExpression, Query.XPATH); - if (limit > 0) { - query.setLimit(limit); + 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<>(); @@ -158,8 +163,123 @@ private static List resolveXPath(String expression, Session session) { } return result; } catch (RepositoryException | NullPointerException e) { - LOG.error("Failed to parse the path sample expression {}", expression, 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, or the preexisting resolver if still valid + */ + private ResourceResolver rotateResolver(ResourceResolver existing, String userId) { + if (existing != null) { + if (StringUtils.isEmpty(userId) || userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID))) { + return existing; + } + existing.close(); + } + try { + ResourceResolver newResolver = ResolverUtil.newResolver(resolverFactory, userId); + newResolver.getPropertyMap().put(PROPERTY_USER_ID, userId); + if (existing != null) { + existing.close(); + } + return newResolver; + } catch (LoginException e) { + LOG.error("Failed to create a resource resolver for user {}", + StringUtils.contains(userId, CoreConstants.SEPARATOR_COLON) + ? StringUtils.substringBefore(userId, CoreConstants.SEPARATOR_COLON) + : userId, + e); + return existing; + } + } + + /* ------- + 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/RelayProvider.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java index 7909cc21e..4c607b179 100644 --- 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 @@ -13,17 +13,14 @@ */ package com.exadel.aem.toolkit.core.relay.services; -import java.util.ArrayList; 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 java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import javax.jcr.Session; import org.apache.commons.lang3.StringUtils; import org.apache.sling.api.resource.LoginException; @@ -41,6 +38,8 @@ 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.RelayMapping; import com.exadel.aem.toolkit.core.relay.models.RelayResource; import com.exadel.aem.toolkit.core.relay.utils.PathHelper; import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; @@ -56,13 +55,13 @@ class RelayProvider extends ResourceProvider implements ResourceChangeList private static final Logger LOG = LoggerFactory.getLogger(RelayProvider.class); private ResourceResolverFactory resolverFactory; - private PathSampler pathSampler; + private PathSampler sampler; private String source; private String target; private Map userMappings; /** - * Default (instantiation-blocking) constructor + * Default (instantiation-restricting) constructor */ private RelayProvider() { } @@ -85,17 +84,14 @@ public Resource getResource( return null; } String targetPath = target + StringUtils.substring(path, source.length()); - return ResourceHelper.getResource( + Resource resolved = ResourceHelper.getResource( context.getResourceResolver(), this::getMappedResourceResolver, - targetPath, - resource -> resource != null ? new RelayResource(resource, path) : null, - () -> ResourceHelper.getResource( - context.getParentResourceProvider(), - context.getParentResolveContext(), - path, - resourceContext, - parent)); + targetPath); + if (resolved != null) { + return new RelayResource(resolved, path); + } + return ResourceHelper.getResource(context, path, resourceContext, parent); } /** @@ -109,22 +105,17 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N Resource targetResource = ResourceHelper.getResource( context.getResourceResolver(), this::getMappedResourceResolver, - targetPath, - resource -> resource != null ? new RelayResource(resource, path) : null, - () -> ResourceHelper.getResource( - context.getParentResourceProvider(), - context.getParentResolveContext(), - path, - null, - parent)); + targetPath); + if (targetResource != null) { + targetResource = new RelayResource(targetResource, path); + } else { + targetResource = ResourceHelper.getResource(context, path, null, parent); + } if (targetResource == null) { return null; } else if (targetResource.getPath().equals(parent.getPath())) { // We have fallen back to an "original" resource, so we should iterate through it without any mapping - return ResourceHelper.listChildren( - context.getParentResourceProvider(), - context.getParentResolveContext(), - parent); + return ResourceHelper.listChildren(context, parent); } return ResourceHelper.listChildren(targetResource, parent.getPath()); } @@ -136,14 +127,9 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N public void start(@Nonnull ProviderContext providerContext) { LOG.info("Relay provider for {} -> {} is starting", source, target); super.start(providerContext); - try (ResourceResolver resolver = ResolverUtil.newResolver(resolverFactory)) { - Session session = resolver.adaptTo(Session.class); - Collection changes = pathSampler.createChanges(session); - if (!changes.isEmpty()) { - providerContext.getObservationReporter().reportChanges(changes, false); - } - } catch (LoginException e) { - LOG.error("Failed to create a JCR session for script sampling at {}", target, e); + Collection changes = sampler.createChanges(); + if (!changes.isEmpty()) { + providerContext.getObservationReporter().reportChanges(changes, false); } } @@ -153,8 +139,8 @@ public void start(@Nonnull ProviderContext providerContext) { @Override public void stop() { LOG.info("Relay provider for {} -> {} is stopping", source, target); - if (getProviderContext() != null && !pathSampler.isEmpty()) { - Collection declaredChanges = pathSampler.createChanges(); + if (getProviderContext() != null && !sampler.isEmpty()) { + Collection declaredChanges = sampler.createChanges(); if (!declaredChanges.isEmpty()) { getProviderContext().getObservationReporter().reportChanges(declaredChanges, false); } @@ -196,21 +182,14 @@ public void onChange(@Nonnull List changes) { private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { String mappedId = userMappings.get(resolver.getUserID()); if (mappedId != null && !mappedId.equals(resolver.getUserID())) { - Map authInfo = new HashMap<>(); try { - if (StringUtils.contains(mappedId, CoreConstants.SEPARATOR_COLON)) { - authInfo.put(ResourceResolverFactory.USER, StringUtils.substringBefore(mappedId, CoreConstants.SEPARATOR_COLON)); - authInfo.put(ResourceResolverFactory.PASSWORD, StringUtils.substringAfter(mappedId, CoreConstants.SEPARATOR_COLON).toCharArray()); - return resolverFactory.getResourceResolver(authInfo); - } - authInfo.put(ResourceResolverFactory.SUBSERVICE, mappedId); - return resolverFactory.getServiceResourceResolver(authInfo); + return ResolverUtil.newResolver(resolverFactory, mappedId); } catch (LoginException e) { LOG.error( "Failed to create a resource resolver for user {}", - authInfo.containsKey(ResourceResolverFactory.USER) - ? authInfo.get(ResourceResolverFactory.USER) - : authInfo.get(ResourceResolverFactory.SUBSERVICE), + StringUtils.contains(mappedId, CoreConstants.SEPARATOR_COLON) + ? StringUtils.substringBefore(mappedId, CoreConstants.SEPARATOR_COLON) + : mappedId, e); } } @@ -235,11 +214,11 @@ static Builder builder() { @SuppressWarnings({"UnusedReturnValue"}) static class Builder { - private List reportedPaths; + private Collection announcements; private ResourceResolverFactory resolverFactory; private String source; private String target; - private Map userMappings; + private Collection userMappings; /** * Default (instantiation-restricting) constructor @@ -248,15 +227,13 @@ private Builder() { } /** - * Adds a JCR path to report as changed when the provider starts or stops - * @param value JCR path to include in change reports + * Sets the collection of {@link ChangeSample} instances defining the paths to report as changed when the + * provider is enabled or disabled + * @param value Collection of {@code ChangeAnnouncement} instances the provider is enabled or disabled * @return This builder */ - Builder reportedPath(String value) { - if (reportedPaths == null) { - reportedPaths = new ArrayList<>(); - } - reportedPaths.add(value); + Builder samples(Collection value) { + announcements = value; return this; } @@ -291,16 +268,13 @@ Builder target(String value) { } /** - * Adds a user identity mapping from a source user ID to a target user or subservice - * @param from Source user ID - * @param to Target user ID, {@code "user:password"} credential string, or service subservice name + * Sets the collection of {@link RelayMapping} instances defining the user identity mappings to apply when + * creating mapped resource resolvers + * @param value Collection of {@code RelayMapping} instances defining user identity mappings * @return This builder */ - Builder userMapping(String from, String to) { - if (userMappings == null) { - userMappings = new HashMap<>(); - } - userMappings.put(from, to); + Builder userMappings(Collection value) { + userMappings = value; return this; } @@ -310,11 +284,19 @@ Builder userMapping(String from, String to) { */ RelayProvider build() { RelayProvider result = new RelayProvider(); - result.pathSampler = new PathSampler(reportedPaths); + result.sampler = PathSampler + .builder() + .source(source) + .target(target) + .resolverFactory(resolverFactory) + .samples(announcements) + .build(); result.resolverFactory = resolverFactory; result.source = source; result.target = target; - result.userMappings = userMappings != null ? userMappings : Collections.emptyMap(); + result.userMappings = userMappings == null + ? Collections.emptyMap() + : userMappings.stream().filter(RelayMapping::isValid).collect(Collectors.toMap(RelayMapping::getFrom, RelayMapping::getTo)); return result; } } From 8cd5426d4a6ed4fe315e62152c8c5a888b452cf6 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Thu, 30 Apr 2026 14:48:48 +0300 Subject: [PATCH 26/92] [EAK-651] Cosmetic config model change --- .../exadel/aem/toolkit/core/relay/services/RelayConfig.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index a1cf7cc36..72d60419b 100644 --- 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 @@ -14,6 +14,7 @@ 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; /** @@ -29,7 +30,8 @@ */ @AttributeDefinition( name = "Enable", - description = "Is the relay enabled?" + description = "Is the relay enabled?", + type = AttributeType.BOOLEAN ) boolean enabled(); @@ -66,6 +68,6 @@ + "Target paths matching the list will be announced as changed as the relay is enabled or disabled." ) - String[] announced(); + String[] announcedPaths(); } From f5df5ed8675d10819e924233edccb952bf3e720f Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 00:30:48 +0300 Subject: [PATCH 27/92] [EAK-651] Introduce RelayInfo model --- .../toolkit/core/relay/models/RelayInfo.java | 74 ++++++++ .../core/relay/services/RelayProvider.java | 140 ++++----------- .../relay/services/RelayProviderHost.java | 170 +++++++++--------- 3 files changed, 190 insertions(+), 194 deletions(-) create mode 100644 core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java 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..f268ef39b --- /dev/null +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java @@ -0,0 +1,74 @@ +/* + * 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; + +/** + * Represents the set of data required to configure and operate a {@code RelayProvider}. + *

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-to-target mapping entry for path translation + * @return A non-null {@link RelayMapping} instance + */ + @Nonnull + public RelayMapping getPathMapping() { + return pathMapping; + } + + /** + * Gets the collection of source-to-target mapping entries for user identity translation + * @return A collection of {@link RelayMapping} instances + */ + public Collection getUserMappings() { + return userMappings; + } + + /** + * 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/services/RelayProvider.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java index 4c607b179..bcfc6c2ab 100644 --- 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 @@ -14,7 +14,6 @@ package com.exadel.aem.toolkit.core.relay.services; import java.util.Collection; -import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -38,7 +37,7 @@ 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.models.RelayResource; import com.exadel.aem.toolkit.core.relay.utils.PathHelper; @@ -54,16 +53,42 @@ class RelayProvider extends ResourceProvider implements ResourceChangeList private static final Logger LOG = LoggerFactory.getLogger(RelayProvider.class); - private ResourceResolverFactory resolverFactory; + private final ResourceResolverFactory resolverFactory; private PathSampler sampler; private String source; private String target; private Map userMappings; /** - * Default (instantiation-restricting) constructor + * 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 model The {@link RelayInfo} model containing the configuration for this provider */ - private RelayProvider() { + RelayProvider(ResourceResolverFactory resolverFactory, RelayInfo model) { + this.resolverFactory = resolverFactory; + update(model); + } + + /** + * 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 + */ + void update(RelayInfo model) { + this.sampler = PathSampler + .builder() + .resolverFactory(resolverFactory) + .source(model.getPathMapping().getFrom()) + .target(model.getPathMapping().getTo()) + .samples(model.getChangeSamples()) + .build(); + this.source = model.getPathMapping().getFrom(); + this.target = model.getPathMapping().getTo(); + this.userMappings = model + .getUserMappings() + .stream() + .collect(Collectors.toMap(RelayMapping::getFrom, RelayMapping::getTo)); } /* ------------------------ @@ -195,110 +220,5 @@ private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { } return resolver; } - - /* ------------- - Factory logic - ------------- */ - - /** - * Creates a new {@link Builder} for configuring and instantiating a {@link RelayProvider} - * @return A new {@code Builder} instance - */ - static Builder builder() { - return new Builder(); - } - - /** - * Constructs {@link RelayProvider} instances with the required configuration - */ - @SuppressWarnings({"UnusedReturnValue"}) - static class Builder { - - private Collection announcements; - private ResourceResolverFactory resolverFactory; - private String source; - private String target; - private Collection userMappings; - - /** - * Default (instantiation-restricting) constructor - */ - private Builder() { - } - - /** - * Sets the collection of {@link ChangeSample} instances defining the paths to report as changed when the - * provider is enabled or disabled - * @param value Collection of {@code ChangeAnnouncement} instances the provider is enabled or disabled - * @return This builder - */ - Builder samples(Collection value) { - announcements = value; - return this; - } - - /** - * Sets the {@link ResourceResolverFactory} used to create user-mapped resolvers - * @param value {@link ResourceResolverFactory} instance - * @return This builder - */ - Builder resolverFactory(ResourceResolverFactory value) { - resolverFactory = value; - return this; - } - - /** - * Sets the JCR source path handled by this provider - * @param value Source JCR path - * @return This builder - */ - Builder source(String value) { - this.source = value; - return this; - } - - /** - * Sets the JCR target path to which resource resolution is delegated - * @param value Target JCR path - * @return This builder - */ - Builder target(String value) { - this.target = value; - return this; - } - - /** - * Sets the collection of {@link RelayMapping} instances defining the user identity mappings to apply when - * creating mapped resource resolvers - * @param value Collection of {@code RelayMapping} instances defining user identity mappings - * @return This builder - */ - Builder userMappings(Collection value) { - userMappings = value; - return this; - } - - /** - * Creates a configured {@link RelayProvider} from the current builder state - * @return A new {@link RelayProvider} instance - */ - RelayProvider build() { - RelayProvider result = new RelayProvider(); - result.sampler = PathSampler - .builder() - .source(source) - .target(target) - .resolverFactory(resolverFactory) - .samples(announcements) - .build(); - result.resolverFactory = resolverFactory; - result.source = source; - result.target = target; - result.userMappings = userMappings == null - ? Collections.emptyMap() - : userMappings.stream().filter(RelayMapping::isValid).collect(Collectors.toMap(RelayMapping::getFrom, RelayMapping::getTo)); - return result; - } - } } 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 index a5eb3784c..58956c233 100644 --- 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 @@ -13,13 +13,12 @@ */ package com.exadel.aem.toolkit.core.relay.services; -import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.Dictionary; import java.util.HashMap; import java.util.HashSet; import java.util.Hashtable; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -44,6 +43,7 @@ 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.PathHelper; import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; @@ -63,7 +63,7 @@ public class RelayProviderHost { @Reference private transient ResourceResolverFactory resolverFactory; - private final List> registrations = new ArrayList<>(); + private final Map, RelayProvider> registrations = new HashMap<>(); private final ReentrantLock lock = new ReentrantLock(); @@ -75,45 +75,59 @@ public class RelayProviderHost { */ @Activate private void activate(BundleContext context, RelayConfig config) { + List relays = parseConfig(config); lock.lock(); try { - deactivate(); - if (!config.enabled()) { - return; - } - - Set pathMappings = new HashSet<>(); - for (String mappingSource : config.pathMappings()) { - RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); - if (mapping != null && mapping.isValid()) { - pathMappings.add(mapping); + 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 -> relay.getPathMapping().getFrom().equals(existingRoot)) + .findFirst() + .orElse(null); + if (matchingRelay != null) { + registrations.get(registration).update(matchingRelay); + relays.remove(matchingRelay); + } else { + try { + registration.unregister(); + iterator.remove(); + } catch (IllegalStateException e) { + LOG.warn("Could not unregister relay provider for path {}", existingRoot, e); + } } } - if (pathMappings.isEmpty()) { + if (relays.isEmpty()) { return; } - Set userMappings = new HashSet<>(); - for (String mappingSource : config.userMappings()) { - RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); - if (mapping != null && mapping.isValid()) { - userMappings.add(mapping); + // Register providers for the remaining new relays that didn't match any existing ones + Map providedPaths = getExternallyProvidedPaths(context); + for (RelayInfo relay : relays) { + String from = relay.getPathMapping().getFrom(); + String shadowedEntries = providedPaths.entrySet().stream() + .filter(e -> PathHelper.isSubpath(e.getKey(), from)) + .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 {}", + from, + shadowedEntries); + continue; } + RelayProvider provider = new RelayProvider(resolverFactory, relay); + Dictionary properties = new Hashtable<>(); + properties.put(ResourceProvider.PROPERTY_ROOT, from); + ServiceRegistration registration = context.registerService( + new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, + provider, + properties); + registrations.put(registration, provider); } - - Set samples = new HashSet<>(); - for (String sampleSource : config.announcedPaths()) { - ChangeSample announcement = ObjectConversionUtil.toObject(sampleSource, ChangeSample.class); - if (announcement != null && StringUtils.isNotBlank(announcement.getPath())) { - samples.add(announcement); - } - } - - createRelays( - context, - pathMappings, - userMappings, - samples); } finally { lock.unlock(); } @@ -126,7 +140,7 @@ private void activate(BundleContext context, RelayConfig config) { private void deactivate() { lock.lock(); try { - for (ServiceRegistration registration : registrations) { + for (ServiceRegistration registration : registrations.keySet()) { registration.unregister(); } registrations.clear(); @@ -136,59 +150,50 @@ private void deactivate() { } /** - * Creates and registers a {@link RelayProvider} service instance for each entry in the provided path mappings - * collection - * @param context OSGi {@link BundleContext} used to register provider services - * @param pathMappings Collection of path mapping rules defining source-to-target path redirections - * @param userMappings Collection of user mapping rules defining source-to-target user identity redirections - * @param samples Collection of rules defining JCR paths or XPath expressions to report as changed when the - * relay is enabled or disabled + * 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 void createRelays( - BundleContext context, - Collection pathMappings, - Collection userMappings, - Collection samples) { - - Map providedPaths = getExternallyProvidedPaths(context); - - for (RelayMapping pathMapping : pathMappings) { - String from = pathMapping.getFrom(); - String shadowedEntries = providedPaths.entrySet().stream() - .filter(e -> PathHelper.isSubpath(e.getKey(), from)) - .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 {}", - from, - shadowedEntries); - continue; + private static List parseConfig(RelayConfig config) { + if (!config.enabled()) { + return Collections.emptyList(); + } + + Set pathMappings = new HashSet<>(); + for (String mappingSource : config.pathMappings()) { + RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); + if (mapping != null && mapping.isValid()) { + pathMappings.add(mapping); } - RelayProvider provider = RelayProvider - .builder() - .resolverFactory(resolverFactory) - .source(pathMapping.getFrom()) - .target(pathMapping.getTo()) - .userMappings(userMappings) - .samples(samples) - .build(); - - Dictionary properties = new Hashtable<>(); - properties.put(ResourceProvider.PROPERTY_ROOT, pathMapping.getFrom()); - properties.put(ResourceChangeListener.PATHS, new String[]{pathMapping.getTo()}); - - ServiceRegistration registration = context.registerService( - new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, - provider, - properties); - registrations.add(registration); } + if (pathMappings.isEmpty()) { + return Collections.emptyList(); + } + + Set userMappings = new HashSet<>(); + for (String mappingSource : config.userMappings()) { + RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); + if (mapping != null && mapping.isValid()) { + userMappings.add(mapping); + } + } + + Set samples = new HashSet<>(); + for (String sampleSource : config.announcedPaths()) { + ChangeSample changeSample = ObjectConversionUtil.toObject(sampleSource, ChangeSample.class); + if (changeSample != null && StringUtils.isNotBlank(changeSample.getPath())) { + samples.add(changeSample); + } + } + + return pathMappings.stream() + .map(m -> new RelayInfo(m, userMappings, samples)) + .collect(Collectors.toList()); } /** - * Collects the JCR paths already provided by registered {@link ResourceProvider} services to avoid "shadowing" - * external providers + * 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 */ @@ -204,9 +209,6 @@ private static Map getExternallyProvidedPaths(BundleContext cont } Map result = new HashMap<>(); for (ServiceReference ref : serviceReferences) { - if (ref.toString().startsWith(CoreConstants.ROOT_PACKAGE)) { - continue; - } Object providedPath = ref.getProperty(ResourceProvider.PROPERTY_ROOT); Object providerId = ref.getProperty(ResourceProvider.PROPERTY_NAME); if (providerId == null || providerId.toString().isEmpty()) { From 67d4533cc29435224179c5aefdd90ce198a3d8c8 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 02:03:04 +0300 Subject: [PATCH 28/92] [EAK-651] Improve thread-safety on updating RelayInfo data --- .../toolkit/core/relay/models/RelayInfo.java | 37 ++++++++++--- .../core/relay/services/RelayProvider.java | 52 ++++++++----------- .../relay/services/RelayProviderHost.java | 9 ++-- 3 files changed, 55 insertions(+), 43 deletions(-) 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 index f268ef39b..686bf0293 100644 --- 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 @@ -17,6 +17,9 @@ 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}. *

Note: This class is not a part of the public API and is subject to change. Do not use it in your own @@ -47,20 +50,38 @@ public RelayInfo( } /** - * Gets the source-to-target mapping entry for path translation - * @return A non-null {@link RelayMapping} instance + * 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 RelayMapping getPathMapping() { - return pathMapping; + public String getTarget() { + return StringUtils.defaultString(pathMapping.getTo()); } /** - * Gets the collection of source-to-target mapping entries for user identity translation - * @return A collection of {@link RelayMapping} instances + * 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 Collection getUserMappings() { - return userMappings; + 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); } /** 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 index bcfc6c2ab..5833ce184 100644 --- 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 @@ -16,7 +16,6 @@ import java.util.Collection; import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -38,7 +37,6 @@ import com.exadel.aem.toolkit.core.CoreConstants; 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.relay.utils.PathHelper; import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; @@ -54,20 +52,18 @@ class RelayProvider extends ResourceProvider implements ResourceChangeList private static final Logger LOG = LoggerFactory.getLogger(RelayProvider.class); private final ResourceResolverFactory resolverFactory; + private volatile RelayInfo relay; private PathSampler sampler; - private String source; - private String target; - private Map userMappings; /** * 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 model The {@link RelayInfo} model containing the configuration for this provider + * @param relay The {@link RelayInfo} model containing the configuration for this provider */ - RelayProvider(ResourceResolverFactory resolverFactory, RelayInfo model) { + RelayProvider(ResourceResolverFactory resolverFactory, RelayInfo relay) { this.resolverFactory = resolverFactory; - update(model); + update(relay); } /** @@ -76,19 +72,7 @@ class RelayProvider extends ResourceProvider implements ResourceChangeList * @param model The {@link RelayInfo} model containing the new configuration for this provider */ void update(RelayInfo model) { - this.sampler = PathSampler - .builder() - .resolverFactory(resolverFactory) - .source(model.getPathMapping().getFrom()) - .target(model.getPathMapping().getTo()) - .samples(model.getChangeSamples()) - .build(); - this.source = model.getPathMapping().getFrom(); - this.target = model.getPathMapping().getTo(); - this.userMappings = model - .getUserMappings() - .stream() - .collect(Collectors.toMap(RelayMapping::getFrom, RelayMapping::getTo)); + this.relay = model; } /* ------------------------ @@ -105,10 +89,10 @@ public Resource getResource( @Nonnull ResourceContext resourceContext, @Nullable Resource parent) { - if (!StringUtils.equals(path, source) && !StringUtils.startsWith(path, source + CoreConstants.SEPARATOR_SLASH)) { + if (!StringUtils.equals(path, relay.getSource()) && !StringUtils.startsWith(path, relay.getSource() + CoreConstants.SEPARATOR_SLASH)) { return null; } - String targetPath = target + StringUtils.substring(path, source.length()); + String targetPath = relay.getTarget() + StringUtils.substring(path, relay.getSource().length()); Resource resolved = ResourceHelper.getResource( context.getResourceResolver(), this::getMappedResourceResolver, @@ -126,7 +110,7 @@ public Resource getResource( @Nullable public Iterator listChildren(@Nonnull ResolveContext context, @Nonnull Resource parent) { String path = parent.getPath(); - String targetPath = PathHelper.replace(path, source, target); + String targetPath = PathHelper.replace(path, relay.getSource(), relay.getTarget()); Resource targetResource = ResourceHelper.getResource( context.getResourceResolver(), this::getMappedResourceResolver, @@ -138,7 +122,7 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N } if (targetResource == null) { return null; - } else if (targetResource.getPath().equals(parent.getPath())) { + } else if (!(targetResource instanceof RelayResource)) { // We have fallen back to an "original" resource, so we should iterate through it without any mapping return ResourceHelper.listChildren(context, parent); } @@ -150,8 +134,16 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N */ @Override public void start(@Nonnull ProviderContext providerContext) { - LOG.info("Relay provider for {} -> {} is starting", source, target); + LOG.info("Relay provider for {} -> {} is starting", relay.getSource(), relay.getTarget()); super.start(providerContext); + sampler = PathSampler + .builder() + .resolverFactory(resolverFactory) + .source(relay.getSource()) + .target(relay.getTarget()) + .samples(relay.getChangeSamples()) + .build(); + Collection changes = sampler.createChanges(); if (!changes.isEmpty()) { providerContext.getObservationReporter().reportChanges(changes, false); @@ -163,8 +155,8 @@ public void start(@Nonnull ProviderContext providerContext) { */ @Override public void stop() { - LOG.info("Relay provider for {} -> {} is stopping", source, target); - if (getProviderContext() != null && !sampler.isEmpty()) { + LOG.info("Relay provider for {} -> {} is stopping", relay.getSource(), relay.getTarget()); + if (getProviderContext() != null && sampler != null && !sampler.isEmpty()) { Collection declaredChanges = sampler.createChanges(); if (!declaredChanges.isEmpty()) { getProviderContext().getObservationReporter().reportChanges(declaredChanges, false); @@ -188,7 +180,7 @@ public void onChange(@Nonnull List changes) { List mappedChanges = changes.stream() .map(change -> new ResourceChange( change.getType(), - PathHelper.replace(change.getPath(), target, source), + PathHelper.replace(change.getPath(), relay.getTarget(), relay.getSource()), change.isExternal())) .collect(Collectors.toList()); getProviderContext().getObservationReporter().reportChanges(mappedChanges, false); @@ -205,7 +197,7 @@ public void onChange(@Nonnull List changes) { * @return A non-null {@code ResourceResolver} instance; may be the original if no mapping applies */ private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { - String mappedId = userMappings.get(resolver.getUserID()); + String mappedId = relay.getUserMapping(resolver.getUserID()); if (mappedId != null && !mappedId.equals(resolver.getUserID())) { try { return ResolverUtil.newResolver(resolverFactory, mappedId); 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 index 58956c233..4935e18fd 100644 --- 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 @@ -85,7 +85,7 @@ private void activate(BundleContext context, RelayConfig config) { .getReference() .getProperty(ResourceProvider.PROPERTY_ROOT); RelayInfo matchingRelay = relays.stream() - .filter(relay -> relay.getPathMapping().getFrom().equals(existingRoot)) + .filter(relay -> StringUtils.equals(relay.getSource(), existingRoot)) .findFirst() .orElse(null); if (matchingRelay != null) { @@ -107,21 +107,20 @@ private void activate(BundleContext context, RelayConfig config) { // Register providers for the remaining new relays that didn't match any existing ones Map providedPaths = getExternallyProvidedPaths(context); for (RelayInfo relay : relays) { - String from = relay.getPathMapping().getFrom(); String shadowedEntries = providedPaths.entrySet().stream() - .filter(e -> PathHelper.isSubpath(e.getKey(), from)) + .filter(e -> PathHelper.isSubpath(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 {}", - from, + relay.getSource(), shadowedEntries); continue; } RelayProvider provider = new RelayProvider(resolverFactory, relay); Dictionary properties = new Hashtable<>(); - properties.put(ResourceProvider.PROPERTY_ROOT, from); + properties.put(ResourceProvider.PROPERTY_ROOT, relay.getSource()); ServiceRegistration registration = context.registerService( new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, provider, From 165a1b19679a4285e3dabaefad7dd02930c628b7 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 02:03:40 +0300 Subject: [PATCH 29/92] [EAK-651] Lower service retrieval failure log level --- .../com/exadel/aem/toolkit/core/utils/ServiceUtil.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index d8cfa255d..7b92d9d7f 100644 --- 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 @@ -105,7 +105,9 @@ public static void withService( reference = context.getServiceReference(serviceClass); T service = reference != null ? context.getService(reference) : null; if (service == null) { - LOG.error(ERROR_RETRIEVAL, serviceClass.getName()); + // This is rather 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 { @@ -143,7 +145,9 @@ public static U withService( reference = context.getServiceReference(serviceClass); T service = reference != null ? context.getService(reference) : null; if (service == null) { - LOG.error(ERROR_RETRIEVAL, serviceClass.getName()); + // This is rather 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 { From 43981ed93edf22d7a8360c55b684f684658dca82 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 02:06:22 +0300 Subject: [PATCH 30/92] [EAK-651] Handle an empty user ID; improve OSGi bundle context checks --- .../exadel/aem/toolkit/core/utils/ResolverUtil.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index c259b9da9..fe8bc3c7e 100644 --- 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 @@ -74,6 +74,10 @@ public static ResourceResolver newResolver( @Nonnull ResourceResolverFactory factory, String user) throws LoginException { + if (StringUtils.isBlank(user)) { + return newResolver(factory); // Use the default eak-service resolver + } + if (!StringUtils.contains(user, CoreConstants.SEPARATOR_AT)) { Map authInfo = new HashMap<>(); if (StringUtils.contains(user, CoreConstants.SEPARATOR_COLON)) { @@ -91,14 +95,18 @@ public static ResourceResolver newResolver( String localizedUserId = StringUtils.substringBefore(user, CoreConstants.SEPARATOR_AT); String bundleId = StringUtils.substringAfter(user, CoreConstants.SEPARATOR_AT); - BundleContext bundleContext = FrameworkUtil.getBundle(ResolverUtil.class).getBundleContext(); + Bundle bundle = FrameworkUtil.getBundle(ResolverUtil.class); + BundleContext bundleContext = bundle != null ? bundle.getBundleContext() : null; + if (bundleContext == null) { + throw new LoginException("Not running in an OSGi container"); + } Bundle targetBundle = Arrays .stream(bundleContext.getBundles()) .filter(b -> b.getSymbolicName().equals(bundleId)) .findFirst() .orElse(null); if (targetBundle == null) { - return factory.getResourceResolver(null); + throw new LoginException("Could not locate required bundle: " + bundleId); } AtomicReference nestedException = new AtomicReference<>(); From a1d90a74f040d8914081f6f32e427deaa57ae5ca Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 02:09:20 +0300 Subject: [PATCH 31/92] [EAK-651] Use normalized path for prefix check --- .../com/exadel/aem/toolkit/core/relay/utils/PathHelper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java index db085782e..b7c96fcb0 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java @@ -38,7 +38,7 @@ public static boolean isSubpath(String path, String root) { String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); String normalizedRoot = StringUtils.stripEnd(StringUtils.defaultString(root), CoreConstants.SEPARATOR_SLASH); return StringUtils.equals(normalizedPath, normalizedRoot) - || StringUtils.startsWith(path, normalizedRoot + CoreConstants.SEPARATOR_SLASH); + || StringUtils.startsWith(normalizedPath, normalizedRoot + CoreConstants.SEPARATOR_SLASH); } /** @@ -47,7 +47,7 @@ public static boolean isSubpath(String path, String root) { * @param path JCR path to transform * @param source Prefix to replace * @param target Replacement prefix - * @return A non-null transformed path string + * @return A transformed path string */ public static String replace(String path, String source, String target) { if (StringUtils.equals(path, source)) { From 58e59fa15f073f12ab3562587cc1889d127c1e9f Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 02:10:13 +0300 Subject: [PATCH 32/92] [EAK-651] Improve handling exceptions during deactivation --- .../core/relay/services/RelayProviderHost.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index 4935e18fd..4507980cd 100644 --- 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 @@ -139,10 +139,16 @@ private void activate(BundleContext context, RelayConfig config) { private void deactivate() { lock.lock(); try { - for (ServiceRegistration registration : registrations.keySet()) { - registration.unregister(); + 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); + } } - registrations.clear(); } finally { lock.unlock(); } From 247f28afb22dae212f8b65e27256b630b51a2257 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 02:10:30 +0300 Subject: [PATCH 33/92] [EAK-651] Cosmetic wording change --- .../exadel/aem/toolkit/core/relay/services/RelayConfig.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 72d60419b..bc57bd3bf 100644 --- 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 @@ -52,8 +52,9 @@ */ @AttributeDefinition( name = "User mappings", - description = "Optional list of user mapping rules. " - + "The mapping target is either \"user:password\" or \"registered_subservice\"." + description = "List of user mapping rules (optional). " + + "Each entry is either a credential string or a registered subservice name " + + " with or without a \"@bundle_id\" suffix." ) String[] userMappings(); From 3d3e3253ea087e949ef7b5e56e03b1cd8fa621bf Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 02:11:12 +0300 Subject: [PATCH 34/92] [EAK-651] Improve sampling routine --- .../toolkit/core/relay/services/PathSampler.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 index 5125beed5..146245c9b 100644 --- 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 @@ -99,23 +99,19 @@ Collection createChanges() { resolver.close(); } // All resolved paths must point to {@code source} instead of {@code target} - paths = paths - .stream() - .map(p -> PathHelper.isSubpath(p, target) ? PathHelper.replace(p, target, source) : p) - .collect(Collectors.toSet()); - return paths .stream() + .map(p -> PathHelper.isSubpath(p, target) ? PathHelper.replace(p, target, source) : p) .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) .collect(Collectors.toList()); } /** - * Gets whether this sampler has no configured path samples + * Gets whether this sampler has no paths to report as changed * @return True or false */ public boolean isEmpty() { - return CollectionUtils.isEmpty(samples); + return paths == null; } /** @@ -177,10 +173,11 @@ private static List resolveXpath(ChangeSample sample, Session session) { */ private ResourceResolver rotateResolver(ResourceResolver existing, String userId) { if (existing != null) { - if (StringUtils.isEmpty(userId) || userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID))) { + boolean userIdMatches = StringUtils.isEmpty(userId) + || userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID)); + if (userIdMatches) { return existing; } - existing.close(); } try { ResourceResolver newResolver = ResolverUtil.newResolver(resolverFactory, userId); From 2013d14a8a87ba7dcc683475ce9182ef43700493 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 03:07:27 +0300 Subject: [PATCH 35/92] [EAK-651] Minor Javadoc fixes --- .../aem/toolkit/core/relay/models/ChangeSample.java | 4 ++++ .../aem/toolkit/core/relay/models/RelayResource.java | 12 +++++++++--- .../aem/toolkit/core/relay/services/PathSampler.java | 3 +-- .../aem/toolkit/core/relay/utils/PathHelper.java | 4 +++- .../aem/toolkit/core/relay/utils/ResourceHelper.java | 6 ++++-- 5 files changed, 21 insertions(+), 8 deletions(-) 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 index 16a366032..f442c6518 100644 --- 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 @@ -71,6 +71,8 @@ public String getUser() { /** * {@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) { @@ -83,6 +85,8 @@ public final boolean equals(Object other) { /** * {@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() { 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 index 418f14a17..aff649ca9 100644 --- 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 @@ -49,21 +49,27 @@ public RelayResource(@Nonnull Resource original, @Nonnull String path) { } } - /** {@inheritDoc} */ + /** + * {@inheritDoc} + */ @Override @Nonnull public String getPath() { return path; } - /** {@inheritDoc} */ + /** + * {@inheritDoc} + */ @Override @Nonnull public ResourceMetadata getResourceMetadata() { return resourceMetadata; } - /** {@inheritDoc} */ + /** + * {@inheritDoc} + */ @Override public @Nonnull Iterator listChildren() { return ResourceHelper.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 index 146245c9b..4278eaf92 100644 --- 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 @@ -65,7 +65,7 @@ private PathSampler() { /** * Resolves the configured path samples and produces a collection of {@link ResourceChange} notifications - * @return A non-null, possibly empty collection of {@link ResourceChange} instances + * @return A non-null, possibly empty collection of {@code ResourceChange} instances */ Collection createChanges() { if (paths != null) { @@ -235,7 +235,6 @@ Builder resolverFactory(ResourceResolverFactory 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 diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java index b7c96fcb0..d608838ee 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java @@ -24,7 +24,9 @@ */ public class PathHelper { - /** Default (instantiation-blocking) constructor */ + /** + * Default (instantiation-blocking) constructor + */ private PathHelper() { } diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index 94565b1b2..32f3310f7 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -42,7 +42,9 @@ public class ResourceHelper { static final String KEY_SUBSIDIARY = "subsidiary"; - /** Default (instantiation-blocking) constructor */ + /** + * Default (instantiation-blocking) constructor + */ private ResourceHelper() {} /** @@ -75,7 +77,7 @@ public static Resource getResource( // To achieve that, we put it into the property map of the {@code basicResolver} so that it will be // automatically closed when the {@code basicResolver} is closed by Sling. // A {@link SubsidiaryHolder} wrapper is used so that the swap-and-close of replaced resolvers is atomic. - // See {@link ResourceResolver#getPropertyMap()} + // See https://sling.apache.org/apidocs/sling12/org/apache/sling/api/resource/ResourceResolver.html#getPropertyMap Map propertyMap = basicResolver.getPropertyMap(); propertyMap.compute(KEY_SUBSIDIARY, (key, existing) -> { if (existing instanceof ResolverHolder) { From f995b8162bdeb1a318934913b6d1c39c5c3b80af Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 03:08:05 +0300 Subject: [PATCH 36/92] [EAK-651] Improve log reporting --- .../aem/toolkit/core/relay/utils/ResourceHelper.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java index 32f3310f7..3c84c8ee5 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java @@ -65,7 +65,7 @@ public static Resource getResource( ResourceResolver effectiveResolver = resolverModifier.apply(basicResolver); Resource result = effectiveResolver.getResource(path); if (result == null) { - LOG.warn("Could not resolve {} with user {}", path, effectiveResolver.getUserID()); + LOG.debug("Could not resolve {} with user {}", path, effectiveResolver.getUserID()); if (!effectiveResolver.equals(basicResolver)) { effectiveResolver.close(); } @@ -108,6 +108,7 @@ public static Resource getResource( String path, ResourceContext resourceContext, Resource parent) { + reportFallingBack(path); if (resolveContext == null) { reportMissingContext(path); return null; @@ -118,7 +119,6 @@ public static Resource getResource( reportMissingContext(path); return null; } - reportFallingBack(path); return ((ResourceProvider) parentResourceProvider).getResource((ResolveContext) parentContext, path, resourceContext, parent); } @@ -152,6 +152,7 @@ public static Iterator listChildren(Resource target, String path) { public static Iterator listChildren( ResolveContext resolveContext, Resource parent) { + reportFallingBack(parent.getPath()); if (resolveContext == null) { reportMissingContext(parent.getPath()); return null; @@ -162,7 +163,6 @@ public static Iterator listChildren( reportMissingContext(parent.getPath()); return null; } - reportFallingBack(parent.getPath()); return ((ResourceProvider) parentResourceProvider).listChildren((ResolveContext) parentContext, parent); } @@ -193,7 +193,7 @@ private static void reportFallingBack(String path) { */ static class ResolverHolder implements Closeable { - private ResourceResolver resolver; + private volatile ResourceResolver resolver; /** * Creates a new holder with the provided resolver From 3f3b8ac752a21da6786c3b4ee46245fce2b6a226 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 03:09:17 +0300 Subject: [PATCH 37/92] [EAK-651] Add a guard against a ResourceResolver leak --- .../core/relay/services/PathSampler.java | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) 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 index 4278eaf92..34c94e4c7 100644 --- 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 @@ -78,25 +78,28 @@ Collection createChanges() { paths = new HashSet<>(); ResourceResolver resolver = null; - 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; + 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()); } - paths.addAll(resolveXpath(sample, session)); - } else if (isJcrPath(sample.getPath())) { - paths.add(sample.getPath()); } - } - if (resolver != null) { - resolver.close(); + } finally { + if (resolver != null) { + resolver.close(); + } } // All resolved paths must point to {@code source} instead of {@code target} return paths @@ -110,7 +113,7 @@ Collection createChanges() { * Gets whether this sampler has no paths to report as changed * @return True or false */ - public boolean isEmpty() { + boolean isEmpty() { return paths == null; } @@ -158,7 +161,7 @@ private static List resolveXpath(ChangeSample sample, Session session) { result.add(next.getPath()); } return result; - } catch (RepositoryException | NullPointerException e) { + } catch (RepositoryException e) { LOG.error("Failed to execute the XPath expression {}", sample.getPath(), e); return Collections.emptyList(); } From 7c62ff6e683aa66af439149d6053c53d7d65eb04 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 03:09:53 +0300 Subject: [PATCH 38/92] [EAK-651] Add missing Sling attribute --- .../aem/toolkit/core/relay/services/RelayProviderHost.java | 1 + 1 file changed, 1 insertion(+) 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 index 4507980cd..054626482 100644 --- 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 @@ -121,6 +121,7 @@ private void activate(BundleContext context, RelayConfig config) { 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()}, provider, From eb4082c81428fd83cc66950cb1b518742b456fd4 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 03:10:38 +0300 Subject: [PATCH 39/92] [EAK-651] Cosmetic syntax changes --- .../exadel/aem/toolkit/core/relay/services/RelayProvider.java | 2 +- .../aem/toolkit/core/relay/services/RelayProviderHost.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 5833ce184..7ce4300d0 100644 --- 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 @@ -53,7 +53,7 @@ class RelayProvider extends ResourceProvider implements ResourceChangeList private final ResourceResolverFactory resolverFactory; private volatile RelayInfo relay; - private PathSampler sampler; + private volatile PathSampler sampler; /** * Creates a new {@code RelayProvider} instance for the given relay model 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 index 054626482..122497633 100644 --- 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 @@ -121,7 +121,7 @@ private void activate(BundleContext context, RelayConfig config) { 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() }); + properties.put(ResourceChangeListener.PATHS, new String[]{relay.getTarget()}); ServiceRegistration registration = context.registerService( new String[]{ResourceProvider.class.getName(), ResourceChangeListener.class.getName()}, provider, @@ -217,7 +217,7 @@ private static Map getExternallyProvidedPaths(BundleContext cont for (ServiceReference ref : serviceReferences) { Object providedPath = ref.getProperty(ResourceProvider.PROPERTY_ROOT); Object providerId = ref.getProperty(ResourceProvider.PROPERTY_NAME); - if (providerId == null || providerId.toString().isEmpty()) { + if (providerId == null || StringUtils.isEmpty(providerId.toString())) { providerId = ref.getBundle().getSymbolicName(); } String providedPathString = providedPath instanceof String ? (String) providedPath : null; From 7f26451e15bbb0c2c3e3f4f19ff932f45dc9184d Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 14:41:24 +0300 Subject: [PATCH 40/92] [EAK-651] Remove plain-text credential processing --- .../core/relay/services/PathSampler.java | 6 +----- .../core/relay/services/RelayConfig.java | 8 ++------ .../core/relay/services/RelayProvider.java | 7 +------ .../aem/toolkit/core/utils/ResolverUtil.java | 17 ++--------------- 4 files changed, 6 insertions(+), 32 deletions(-) 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 index 34c94e4c7..f9a11e730 100644 --- 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 @@ -190,11 +190,7 @@ private ResourceResolver rotateResolver(ResourceResolver existing, String userId } return newResolver; } catch (LoginException e) { - LOG.error("Failed to create a resource resolver for user {}", - StringUtils.contains(userId, CoreConstants.SEPARATOR_COLON) - ? StringUtils.substringBefore(userId, CoreConstants.SEPARATOR_COLON) - : userId, - e); + LOG.error("Failed to create a resource resolver for {}", userId, e); return existing; } } 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 index bc57bd3bf..5c2d344f7 100644 --- 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 @@ -46,28 +46,24 @@ String[] pathMappings(); /** - * Gets the optional list of user mapping rules. Each entry is either a {@code "user:password"} credential - * string or a registered subservice name + * 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). " - + "Each entry is either a credential string or a registered subservice name " - + " with or without a \"@bundle_id\" suffix." + + "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 - * when the relay is enabled or disabled * @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 index 7ce4300d0..f4d2de1c2 100644 --- 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 @@ -202,12 +202,7 @@ private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { try { return ResolverUtil.newResolver(resolverFactory, mappedId); } catch (LoginException e) { - LOG.error( - "Failed to create a resource resolver for user {}", - StringUtils.contains(mappedId, CoreConstants.SEPARATOR_COLON) - ? StringUtils.substringBefore(mappedId, CoreConstants.SEPARATOR_COLON) - : mappedId, - e); + LOG.error("Failed to create a resource resolver for {}", mappedId, e); } } return resolver; 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 index fe8bc3c7e..97e1a8a27 100644 --- 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 @@ -15,8 +15,6 @@ import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; -import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import javax.annotation.Nonnull; @@ -79,18 +77,7 @@ public static ResourceResolver newResolver( } if (!StringUtils.contains(user, CoreConstants.SEPARATOR_AT)) { - Map authInfo = new HashMap<>(); - if (StringUtils.contains(user, CoreConstants.SEPARATOR_COLON)) { - authInfo.put( - ResourceResolverFactory.USER, - StringUtils.substringBefore(user, CoreConstants.SEPARATOR_COLON)); - authInfo.put( - ResourceResolverFactory.PASSWORD, - StringUtils.substringAfter(user, CoreConstants.SEPARATOR_COLON).toCharArray()); - return factory.getResourceResolver(authInfo); - } - authInfo.put(ResourceResolverFactory.SUBSERVICE, user); - return factory.getServiceResourceResolver(authInfo); + return factory.getServiceResourceResolver(Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, user)); } String localizedUserId = StringUtils.substringBefore(user, CoreConstants.SEPARATOR_AT); @@ -102,7 +89,7 @@ public static ResourceResolver newResolver( } Bundle targetBundle = Arrays .stream(bundleContext.getBundles()) - .filter(b -> b.getSymbolicName().equals(bundleId)) + .filter(b -> StringUtils.equals(b.getSymbolicName(), bundleId)) .findFirst() .orElse(null); if (targetBundle == null) { From 3b847e42b6b57d953f2eccd7f5bba6c6bcb924fd Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 15:22:32 +0300 Subject: [PATCH 41/92] [EAK-651] Add an NPE guard --- .../aem/toolkit/core/relay/services/RelayProviderHost.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 122497633..828d949ee 100644 --- 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 @@ -25,6 +25,7 @@ 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.ResourceChangeListener; @@ -166,7 +167,7 @@ private static List parseConfig(RelayConfig config) { } Set pathMappings = new HashSet<>(); - for (String mappingSource : config.pathMappings()) { + for (String mappingSource : ArrayUtils.nullToEmpty(config.pathMappings())) { RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); if (mapping != null && mapping.isValid()) { pathMappings.add(mapping); @@ -177,7 +178,7 @@ private static List parseConfig(RelayConfig config) { } Set userMappings = new HashSet<>(); - for (String mappingSource : config.userMappings()) { + for (String mappingSource : ArrayUtils.nullToEmpty(config.userMappings())) { RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); if (mapping != null && mapping.isValid()) { userMappings.add(mapping); @@ -185,7 +186,7 @@ private static List parseConfig(RelayConfig config) { } Set samples = new HashSet<>(); - for (String sampleSource : config.announcedPaths()) { + for (String sampleSource : ArrayUtils.nullToEmpty(config.announcedPaths())) { ChangeSample changeSample = ObjectConversionUtil.toObject(sampleSource, ChangeSample.class); if (changeSample != null && StringUtils.isNotBlank(changeSample.getPath())) { samples.add(changeSample); From fb3563d365009cfc69a1bf8f7b75e0ede85f5e4a Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 15:23:56 +0300 Subject: [PATCH 42/92] [EAK-651] Reduce PathSampler's "isEmpty" logic; add local copy of volatile field --- .../aem/toolkit/core/relay/services/PathSampler.java | 10 +--------- .../toolkit/core/relay/services/RelayProvider.java | 11 ++++++++--- 2 files changed, 9 insertions(+), 12 deletions(-) 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 index f9a11e730..ed9b04d5d 100644 --- 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 @@ -101,7 +101,7 @@ Collection createChanges() { resolver.close(); } } - // All resolved paths must point to {@code source} instead of {@code target} + // All resolved paths must point to source instead of target return paths .stream() .map(p -> PathHelper.isSubpath(p, target) ? PathHelper.replace(p, target, source) : p) @@ -109,14 +109,6 @@ Collection createChanges() { .collect(Collectors.toList()); } - /** - * Gets whether this sampler has no paths to report as changed - * @return True or false - */ - boolean isEmpty() { - return paths == null; - } - /** * Gets whether the provided value represents a plain JCR path * @param value Arbitrary string to check 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 index f4d2de1c2..050cbd9a6 100644 --- 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 @@ -35,7 +35,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.exadel.aem.toolkit.core.CoreConstants; 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.PathHelper; @@ -89,7 +88,8 @@ public Resource getResource( @Nonnull ResourceContext resourceContext, @Nullable Resource parent) { - if (!StringUtils.equals(path, relay.getSource()) && !StringUtils.startsWith(path, relay.getSource() + CoreConstants.SEPARATOR_SLASH)) { + RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method + if (!PathHelper.isSubpath(path, relay.getSource())) { return null; } String targetPath = relay.getTarget() + StringUtils.substring(path, relay.getSource().length()); @@ -109,6 +109,8 @@ public Resource getResource( @Override @Nullable public Iterator listChildren(@Nonnull ResolveContext context, @Nonnull Resource parent) { + + RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method String path = parent.getPath(); String targetPath = PathHelper.replace(path, relay.getSource(), relay.getTarget()); Resource targetResource = ResourceHelper.getResource( @@ -134,6 +136,7 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N */ @Override public void start(@Nonnull ProviderContext providerContext) { + RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method LOG.info("Relay provider for {} -> {} is starting", relay.getSource(), relay.getTarget()); super.start(providerContext); sampler = PathSampler @@ -155,8 +158,9 @@ public void start(@Nonnull ProviderContext providerContext) { */ @Override public void stop() { + RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method LOG.info("Relay provider for {} -> {} is stopping", relay.getSource(), relay.getTarget()); - if (getProviderContext() != null && sampler != null && !sampler.isEmpty()) { + if (getProviderContext() != null && sampler != null) { Collection declaredChanges = sampler.createChanges(); if (!declaredChanges.isEmpty()) { getProviderContext().getObservationReporter().reportChanges(declaredChanges, false); @@ -177,6 +181,7 @@ public void onChange(@Nonnull List changes) { if (getProviderContext() == null) { return; } + RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method List mappedChanges = changes.stream() .map(change -> new ResourceChange( change.getType(), From 4c04831ebb2b0a2d0dd5489251e42df57fdc00a0 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 15:28:37 +0300 Subject: [PATCH 43/92] [EAK-651] Minor doc change --- docs/content/dev-tools/configurator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 869d8cf5ec6e2c3d8a5934ab864c5149ca91c8d3 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 15:54:29 +0300 Subject: [PATCH 44/92] [EAK-651] Enhance service retrieval with callback support --- .../aem/toolkit/core/utils/ResolverUtil.java | 13 +- .../aem/toolkit/core/utils/ServiceUtil.java | 129 +++++++++++++++--- 2 files changed, 119 insertions(+), 23 deletions(-) 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 index 97e1a8a27..5487b30e6 100644 --- 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 @@ -60,8 +60,7 @@ public static ResourceResolver newResolver(@Nonnull ResourceResolverFactory fact /** * Creates a new {@link ResourceResolver} instance for the given user identifier using the provided resource - * resolver factory. The user identifier may be in the format of {@code username@bundleId} or - * {@code login:password} + * resolver factory * @param factory The {@code ResourceResolverFactory} instance * @param user The user identifier string * @return New instance of {@code ResourceResolver} @@ -100,13 +99,19 @@ public static ResourceResolver newResolver( ResourceResolver resolverByTargetBundle = ServiceUtil.withService( ResourceResolverFactory.class, targetBundle.getBundleContext(), - f -> { + (f, callback) -> { try { - return f.getServiceResourceResolver( + 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", (AutoCloseable) callback::run); + return resolver; } catch (LoginException e) { nestedException.set(e); + callback.run(); return null; } }, 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 index 7b92d9d7f..b59a7e2ba 100644 --- 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 @@ -13,6 +13,8 @@ */ package com.exadel.aem.toolkit.core.utils; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; import javax.annotation.Nonnull; @@ -45,6 +47,10 @@ public class ServiceUtil { 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 @@ -58,12 +64,86 @@ public static void withService( Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class); BundleContext context = bundle != null ? bundle.getBundleContext() : null; if (context == null) { - LOG.error(ERROR_CONTEXT, serviceClass.getName()); + // 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; + } + try { + consumer.accept(service, () -> ungetService(context, reference)); + } catch (RuntimeException e) { + LOG.error(ERROR_RUNNING, serviceClass.getName(), e); + 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 @@ -81,53 +161,62 @@ public static U withService( Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class); BundleContext context = bundle != null ? bundle.getBundleContext() : null; if (context == null) { - LOG.error(ERROR_CONTEXT, serviceClass.getName()); + // 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, if available. The service is - * automatically obtained and released after the consumer is executed + * 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 consumer The routine to be executed with the service instance + * @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 void withService( + public static U withService( @Nonnull Class serviceClass, @Nonnull BundleContext context, - @Nonnull Consumer consumer) { + @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 rather an anticipated case because a service might not be registered due to a particular config, + // 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; + return defaultValue; } try { - consumer.accept(service); + 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 and released after the processor is executed + * 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 + * @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 @@ -136,34 +225,36 @@ public static void withService( public static U withService( @Nonnull Class serviceClass, @Nonnull BundleContext context, - @Nonnull Function processor, + @Nonnull BiFunction processor, U defaultValue) { U result = defaultValue; - ServiceReference reference = null; try { - reference = context.getServiceReference(serviceClass); + ServiceReference reference = context.getServiceReference(serviceClass); T service = reference != null ? context.getService(reference) : null; if (service == null) { - // This is rather an anticipated case because a service might not be registered due to a particular config, + // 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); + result = processor.apply(service, () -> ungetService(context, reference)); } catch (RuntimeException e) { LOG.error(ERROR_RUNNING, serviceClass.getName(), e); + ungetService(context, reference); return defaultValue; } } catch (IllegalArgumentException | IllegalStateException e) { LOG.error(ERROR_HANDLING, serviceClass.getName(), e); - } finally { - ungetService(context, reference); } 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 From 155f6b90e10a0a4221f77072c63fa69840127acb Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 16:45:14 +0300 Subject: [PATCH 45/92] [EAK-651] Rename helper classes --- .../core/relay/models/RelayResource.java | 4 +- .../core/relay/services/PathSampler.java | 4 +- .../core/relay/services/RelayProvider.java | 44 +++++++++---------- .../relay/services/RelayProviderHost.java | 4 +- .../{PathHelper.java => RelayPathHelper.java} | 4 +- ...ceHelper.java => RelayResourceHelper.java} | 6 +-- ...lperTest.java => RelayPathHelperTest.java} | 14 +++--- ...Test.java => RelayResourceHelperTest.java} | 34 +++++++------- 8 files changed, 57 insertions(+), 57 deletions(-) rename core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/{PathHelper.java => RelayPathHelper.java} (97%) rename core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/{ResourceHelper.java => RelayResourceHelper.java} (99%) rename core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/{PathHelperTest.java => RelayPathHelperTest.java} (58%) rename core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/{ResourceHelperTest.java => RelayResourceHelperTest.java} (87%) 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 index aff649ca9..e7a62ae3e 100644 --- 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 @@ -20,7 +20,7 @@ import org.apache.sling.api.resource.ResourceMetadata; import org.apache.sling.api.resource.ResourceWrapper; -import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; +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. @@ -72,7 +72,7 @@ public ResourceMetadata getResourceMetadata() { */ @Override public @Nonnull Iterator listChildren() { - return ResourceHelper.listChildren(getResource(), path); + 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 index ed9b04d5d..dc1e4a034 100644 --- 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 @@ -37,7 +37,7 @@ import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.core.relay.models.ChangeSample; -import com.exadel.aem.toolkit.core.relay.utils.PathHelper; +import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; import com.exadel.aem.toolkit.core.utils.ResolverUtil; /** @@ -104,7 +104,7 @@ Collection createChanges() { // All resolved paths must point to source instead of target return paths .stream() - .map(p -> PathHelper.isSubpath(p, target) ? PathHelper.replace(p, target, source) : p) + .map(p -> RelayPathHelper.isSubpath(p, target) ? RelayPathHelper.replace(p, target, source) : p) .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) .collect(Collectors.toList()); } 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 index 050cbd9a6..d1d035e68 100644 --- 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 @@ -37,8 +37,8 @@ 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.PathHelper; -import com.exadel.aem.toolkit.core.relay.utils.ResourceHelper; +import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; +import com.exadel.aem.toolkit.core.relay.utils.RelayResourceHelper; import com.exadel.aem.toolkit.core.utils.ResolverUtil; /** @@ -88,19 +88,19 @@ public Resource getResource( @Nonnull ResourceContext resourceContext, @Nullable Resource parent) { - RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method - if (!PathHelper.isSubpath(path, relay.getSource())) { + RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method + if (!RelayPathHelper.isSubpath(path, localRelay.getSource())) { return null; } - String targetPath = relay.getTarget() + StringUtils.substring(path, relay.getSource().length()); - Resource resolved = ResourceHelper.getResource( + String targetPath = localRelay.getTarget() + StringUtils.substring(path, localRelay.getSource().length()); + Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), this::getMappedResourceResolver, targetPath); if (resolved != null) { return new RelayResource(resolved, path); } - return ResourceHelper.getResource(context, path, resourceContext, parent); + return RelayResourceHelper.getResource(context, path, resourceContext, parent); } /** @@ -110,25 +110,25 @@ public Resource getResource( @Nullable public Iterator listChildren(@Nonnull ResolveContext context, @Nonnull Resource parent) { - RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method + RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method String path = parent.getPath(); - String targetPath = PathHelper.replace(path, relay.getSource(), relay.getTarget()); - Resource targetResource = ResourceHelper.getResource( + String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); + Resource targetResource = RelayResourceHelper.getResource( context.getResourceResolver(), this::getMappedResourceResolver, targetPath); if (targetResource != null) { targetResource = new RelayResource(targetResource, path); } else { - targetResource = ResourceHelper.getResource(context, path, null, parent); + targetResource = RelayResourceHelper.getResource(context, path, null, parent); } if (targetResource == null) { return null; } else if (!(targetResource instanceof RelayResource)) { // We have fallen back to an "original" resource, so we should iterate through it without any mapping - return ResourceHelper.listChildren(context, parent); + return RelayResourceHelper.listChildren(context, parent); } - return ResourceHelper.listChildren(targetResource, parent.getPath()); + return RelayResourceHelper.listChildren(targetResource, parent.getPath()); } /** @@ -136,15 +136,15 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N */ @Override public void start(@Nonnull ProviderContext providerContext) { - RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method - LOG.info("Relay provider for {} -> {} is starting", relay.getSource(), relay.getTarget()); + 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(relay.getSource()) - .target(relay.getTarget()) - .samples(relay.getChangeSamples()) + .source(localRelay.getSource()) + .target(localRelay.getTarget()) + .samples(localRelay.getChangeSamples()) .build(); Collection changes = sampler.createChanges(); @@ -158,8 +158,8 @@ public void start(@Nonnull ProviderContext providerContext) { */ @Override public void stop() { - RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method - LOG.info("Relay provider for {} -> {} is stopping", relay.getSource(), relay.getTarget()); + 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 (getProviderContext() != null && sampler != null) { Collection declaredChanges = sampler.createChanges(); if (!declaredChanges.isEmpty()) { @@ -181,11 +181,11 @@ public void onChange(@Nonnull List changes) { if (getProviderContext() == null) { return; } - RelayInfo relay = this.relay; // Use a local copy to avoid potential race conditions with the update() method + 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(), - PathHelper.replace(change.getPath(), relay.getTarget(), relay.getSource()), + RelayPathHelper.replace(change.getPath(), localRelay.getTarget(), localRelay.getSource()), change.isExternal())) .collect(Collectors.toList()); getProviderContext().getObservationReporter().reportChanges(mappedChanges, 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 index 828d949ee..14171c31e 100644 --- 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 @@ -46,7 +46,7 @@ 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.PathHelper; +import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; /** @@ -109,7 +109,7 @@ private void activate(BundleContext context, RelayConfig config) { Map providedPaths = getExternallyProvidedPaths(context); for (RelayInfo relay : relays) { String shadowedEntries = providedPaths.entrySet().stream() - .filter(e -> PathHelper.isSubpath(e.getKey(), relay.getSource())) + .filter(e -> RelayPathHelper.isSubpath(e.getKey(), relay.getSource())) .map(e -> e.getKey() + " by " + e.getValue()) .collect(Collectors.joining(CoreConstants.SEPARATOR_COMMA + StringUtils.SPACE)); if (!shadowedEntries.isEmpty()) { diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java similarity index 97% rename from core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java rename to core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java index d608838ee..69a16dba2 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/PathHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java @@ -22,12 +22,12 @@ *

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 PathHelper { +public class RelayPathHelper { /** * Default (instantiation-blocking) constructor */ - private PathHelper() { + private RelayPathHelper() { } /** diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java similarity index 99% rename from core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java rename to core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java index 3c84c8ee5..0b437b175 100644 --- a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelper.java +++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java @@ -36,16 +36,16 @@ *

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 ResourceHelper { +public class RelayResourceHelper { - private static final Logger LOG = LoggerFactory.getLogger(ResourceHelper.class); + private static final Logger LOG = LoggerFactory.getLogger(RelayResourceHelper.class); static final String KEY_SUBSIDIARY = "subsidiary"; /** * Default (instantiation-blocking) constructor */ - private ResourceHelper() {} + private RelayResourceHelper() {} /** * Resolves a resource at the provided path using a potentially modified {@link ResourceResolver}. Returns diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/PathHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java similarity index 58% rename from core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/PathHelperTest.java rename to core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java index b6fc2a7fe..2266ae251 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/PathHelperTest.java +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java @@ -17,22 +17,22 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; -public class PathHelperTest { +public class RelayPathHelperTest { private static final String PATH_SOURCE = "/content/source"; private static final String PATH_TARGET = "/content/target"; @Test public void shouldReplacePrefix() { - assertEquals(PATH_TARGET, PathHelper.replace(PATH_SOURCE, PATH_SOURCE, PATH_TARGET)); - assertEquals(PATH_TARGET + "/child", PathHelper.replace(PATH_SOURCE + "/child", PATH_SOURCE, PATH_TARGET)); - assertEquals(PATH_TARGET + "/a/b/c", PathHelper.replace(PATH_SOURCE + "/a/b/c", PATH_SOURCE, PATH_TARGET)); + 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", PathHelper.replace("/content/other", PATH_SOURCE, PATH_TARGET)); - assertEquals(PATH_SOURCE + "-extra", PathHelper.replace(PATH_SOURCE + "-extra", PATH_SOURCE, PATH_TARGET)); - assertNull(PathHelper.replace(null, PATH_SOURCE, PATH_TARGET)); + 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/ResourceHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java similarity index 87% rename from core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java rename to core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java index 8991e4eb9..ec080d8d9 100644 --- a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/ResourceHelperTest.java +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java @@ -45,7 +45,7 @@ import com.exadel.aem.toolkit.core.relay.models.RelayResource; @RunWith(MockitoJUnitRunner.class) -public class ResourceHelperTest { +public class RelayResourceHelperTest { private static final String PATH_TARGET = "/content/target"; private static final String PATH_CHILD = "/child"; @@ -60,7 +60,7 @@ public void shouldResolveResource() { ResourceResolver resolver = context.resourceResolver(); // Resource found → returned directly - Resource found = ResourceHelper.getResource( + Resource found = RelayResourceHelper.getResource( resolver, UnaryOperator.identity(), PATH_TARGET); @@ -68,7 +68,7 @@ public void shouldResolveResource() { assertEquals(PATH_TARGET, found.getPath()); // Resource not found → null - Resource notFound = ResourceHelper.getResource( + Resource notFound = RelayResourceHelper.getResource( resolver, UnaryOperator.identity(), PATH_TARGET + "/missing"); @@ -88,23 +88,23 @@ public void shouldTrackSubsidiaryResolver() { Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); // Modified resolver → stored as subsidiary in the basic resolver's property map - ResourceHelper.getResource( + RelayResourceHelper.getResource( basicResolver, resolver -> modifiedResolver, PATH_TARGET); - assertTrue(propertyMap.get("subsidiary") instanceof ResourceHelper.ResolverHolder); + assertTrue(propertyMap.get("subsidiary") instanceof RelayResourceHelper.ResolverHolder); // Second call with a different modified resolver → existing subsidiary is closed, new one stored ResourceResolver nextResolver = Mockito.mock(ResourceResolver.class); Mockito.when(nextResolver.getResource(PATH_TARGET)).thenReturn(targetResource); Mockito.when(nextResolver.getUserID()).thenReturn("next-user"); - ResourceHelper.getResource( + RelayResourceHelper.getResource( basicResolver, resolver -> nextResolver, PATH_TARGET); Mockito.verify(modifiedResolver).close(); - assertTrue(propertyMap.get(ResourceHelper.KEY_SUBSIDIARY) instanceof ResourceHelper.ResolverHolder); + assertTrue(propertyMap.get(RelayResourceHelper.KEY_SUBSIDIARY) instanceof RelayResourceHelper.ResolverHolder); } @Test @@ -138,7 +138,7 @@ public void shouldHandleConcurrentSubsidiaryAccess() throws InterruptedException } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - ResourceHelper.getResource( + RelayResourceHelper.getResource( basicResolver, r -> resolver, PATH_TARGET); @@ -148,7 +148,7 @@ public void shouldHandleConcurrentSubsidiaryAccess() throws InterruptedException executor.shutdown(); assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - assertNotNull(propertyMap.get(ResourceHelper.KEY_SUBSIDIARY)); + assertNotNull(propertyMap.get(RelayResourceHelper.KEY_SUBSIDIARY)); assertEquals(threadCount - 1, closedCount.get()); } @@ -167,7 +167,7 @@ public void shouldDelegateGetResourceToParentProvider() { Mockito.doReturn(mockParentContext).when(mockResolveContext).getParentResolveContext(); // Happy path → delegates to parent provider - Resource result = ResourceHelper.getResource( + Resource result = RelayResourceHelper.getResource( mockResolveContext, PATH_TARGET, mockResourceContext, @@ -176,12 +176,12 @@ public void shouldDelegateGetResourceToParentProvider() { assertEquals(PATH_TARGET, result.getPath()); // Null context → null - assertNull(ResourceHelper.getResource(null, PATH_TARGET, mockResourceContext, null)); + assertNull(RelayResourceHelper.getResource(null, PATH_TARGET, mockResourceContext, null)); // Null parent provider → null ResolveContext noProviderContext = newMockResolveContext(); Mockito.doReturn(null).when(noProviderContext).getParentResourceProvider(); - assertNull(ResourceHelper.getResource(noProviderContext, PATH_TARGET, mockResourceContext, null)); + assertNull(RelayResourceHelper.getResource(noProviderContext, PATH_TARGET, mockResourceContext, null)); } @Test @@ -199,18 +199,18 @@ public void shouldDelegateListChildrenToParentProvider() { Mockito.doReturn(mockParentContext).when(mockResolveContext).getParentResolveContext(); // Happy path → delegates to parent provider - Iterator result = ResourceHelper.listChildren(mockResolveContext, parent); + Iterator result = RelayResourceHelper.listChildren(mockResolveContext, parent); assertNotNull(result); assertTrue(result.hasNext()); assertEquals(PATH_TARGET + PATH_CHILD, result.next().getPath()); // Null context → null - assertNull(ResourceHelper.listChildren(null, parent)); + assertNull(RelayResourceHelper.listChildren(null, parent)); // Null parent provider → null ResolveContext noProviderContext = newMockResolveContext(); Mockito.doReturn(null).when(noProviderContext).getParentResourceProvider(); - assertNull(ResourceHelper.listChildren(noProviderContext, parent)); + assertNull(RelayResourceHelper.listChildren(noProviderContext, parent)); } @Test @@ -219,7 +219,7 @@ public void shouldListChildrenFromTarget() { context.create().resource(PATH_TARGET + PATH_CHILD); // Non-RelayResource → children wrapped in RelayResource using the supplied exposed path - Iterator result = ResourceHelper.listChildren(target, PATH_EXPOSED); + Iterator result = RelayResourceHelper.listChildren(target, PATH_EXPOSED); assertNotNull(result); assertTrue(result.hasNext()); assertEquals(PATH_EXPOSED + PATH_CHILD, result.next().getPath()); @@ -227,7 +227,7 @@ public void shouldListChildrenFromTarget() { // RelayResource → listChildren() passthrough; the path argument is ignored and the // relay resource's own path is used when mapping child names RelayResource relayTarget = new RelayResource(target, PATH_EXPOSED); - Iterator relayResult = ResourceHelper.listChildren(relayTarget, "/content/ignored"); + Iterator relayResult = RelayResourceHelper.listChildren(relayTarget, "/content/ignored"); assertNotNull(relayResult); assertTrue(relayResult.hasNext()); assertEquals(PATH_EXPOSED + PATH_CHILD, relayResult.next().getPath()); From df18889297f57b002ff4bc10a1dd3de7e3563e95 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 18:28:36 +0300 Subject: [PATCH 46/92] [EAK-651] Update RelayProviderTest --- .../relay/services/RelayProviderTest.java | 730 +++++++++++++----- 1 file changed, 544 insertions(+), 186 deletions(-) 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 index d85591665..7d2010b55 100644 --- 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 @@ -13,6 +13,7 @@ */ package com.exadel.aem.toolkit.core.relay.services; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -22,6 +23,7 @@ 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; @@ -32,278 +34,634 @@ import org.apache.sling.spi.resource.provider.ResourceProvider; import org.junit.Rule; import org.junit.Test; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; 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; -@RunWith(MockitoJUnitRunner.class) public class RelayProviderTest { - private static final String PATH_ASSET = "/content/asset"; private static final String PATH_SOURCE = "/content/source"; private static final String PATH_TARGET = "/content/target"; - - private static final String SUBPATH_CHILD = "/child"; - private static final String SUBPATH_PAGE = "/page"; - + 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 + SUBPATH_CHILD); + 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); - RelayProvider provider = newProvider(); + 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 mockResourceContext = Mockito.mock(ResourceContext.class); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, otherPath, resourceContext, null); + + assertNull(result); + } + + /* --------------------------------------- + getResource(): parent provider fallback + --------------------------------------- */ - // Path outside the configured source → null regardless of what the resolver holds - Resource unrelated = provider.getResource(resolveContext, "/content/other", mockResourceContext, null); - assertNull(unrelated); + @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(); - // No target resource for source → null (parent provider also absent) - Resource missingTarget = provider.getResource(resolveContext, PATH_SOURCE + "/missing", mockResourceContext, null); - assertNull(missingTarget); + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); - // When the target resource exists, a relay resource is created based on the target resource, - // but it reports the source path - Resource existingTarget = provider.getResource(resolveContext, PATH_SOURCE, mockResourceContext, null); - assertNotNull(existingTarget); - assertEquals(PATH_SOURCE, existingTarget.getPath()); + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); - // When the target resource exists with a child, a relay resource is created based on the target resource's child, - // but it reports the source path - Resource existingTargetChild = provider.getResource( - resolveContext, - PATH_SOURCE + SUBPATH_CHILD, - mockResourceContext, - null); - assertNotNull(existingTargetChild); - assertEquals(PATH_SOURCE + SUBPATH_CHILD, existingTargetChild.getPath()); + 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 shouldDelegateChildListingToParentProvider() { + 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); - Resource expectedChild = context.create().resource(PATH_SOURCE + SUBPATH_CHILD); - @SuppressWarnings("unchecked") - ResourceProvider mockParentProvider = Mockito.mock(ResourceProvider.class); - @SuppressWarnings("unchecked") - ResolveContext mockParentContext = Mockito.mock(ResolveContext.class); - Mockito.when(mockParentProvider.listChildren(Mockito.any(), Mockito.eq(parent))) + // 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.isNull(), 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 = newResolveContextWithParent(mockParentProvider, mockParentContext); + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET)); - RelayProvider provider = newProvider(); Iterator result = provider.listChildren(resolveContext, parent); assertNotNull(result); assertTrue(result.hasNext()); - assertEquals(PATH_SOURCE + SUBPATH_CHILD, result.next().getPath()); + 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 - @SuppressWarnings({"unchecked", "rawtypes"}) - public void shouldNotifyChangedPaths() throws LoginException { - ResourceResolverFactory mockFactory = newMockFactory(); - RelayProvider provider = newProvider(mockFactory); - - // onChange before start() → no providerContext, must not throw - ResourceChange earlyChange = new ResourceChange( - ResourceChange.ChangeType.CHANGED, - PATH_TARGET + SUBPATH_PAGE, - false); - provider.onChange(Collections.singletonList(earlyChange)); - - // Start the provider so providerContext is established - ProviderContext mockProviderContext = Mockito.mock(ProviderContext.class); - ObservationReporter mockReporter = Mockito.mock(ObservationReporter.class); - Mockito.when(mockProviderContext.getObservationReporter()).thenReturn(mockReporter); - provider.start(mockProviderContext); - - // onChange after start → changes translated from target to source path - ResourceChange change = new ResourceChange( - ResourceChange.ChangeType.CHANGED, - PATH_TARGET + SUBPATH_PAGE, - false); - provider.onChange(Collections.singletonList(change)); - - ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); - Mockito.verify(mockReporter).reportChanges(captor.capture(), Mockito.eq(false)); - - List reported = captor.getValue(); - assertEquals(1, reported.size()); - assertEquals(PATH_SOURCE + SUBPATH_PAGE, reported.get(0).getPath()); + 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 - @SuppressWarnings({"unchecked", "rawtypes"}) - public void shouldReportChangesOnStartAndStop() throws LoginException { - ResourceResolverFactory mockFactory = newMockFactory(); - RelayProvider provider = RelayProvider - .builder() - .resolverFactory(mockFactory) - .source(PATH_SOURCE) - .target(PATH_TARGET) - .reportedPath(PATH_ASSET) - .build(); - - ProviderContext mockProviderContext = Mockito.mock(ProviderContext.class); - ObservationReporter mockReporter = Mockito.mock(ObservationReporter.class); - Mockito.when(mockProviderContext.getObservationReporter()).thenReturn(mockReporter); - - provider.start(mockProviderContext); - - ArgumentCaptor startCaptor = ArgumentCaptor.forClass(Collection.class); - Mockito.verify(mockReporter).reportChanges(startCaptor.capture(), Mockito.eq(false)); - Collection startChanges = startCaptor.getValue(); - assertEquals(1, startChanges.size()); - assertEquals(PATH_ASSET, startChanges.iterator().next().getPath()); + 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(); - // reportChanges called once on start and once on stop - Mockito.verify(mockReporter, Mockito.times(2)) - .reportChanges(Mockito.any(), Mockito.eq(false)); + ArgumentCaptor> captor = changeCaptor(); + Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false)); + + Collection changes = captor.getValue(); + assertEquals(1, changes.size()); + // PathSampler's second createChanges() call returns the raw cached paths (still target-prefixed) + assertEquals(PATH_TARGET + 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 shouldHandleLoginExceptionOnStart() throws LoginException { - ResourceResolverFactory mockFactory = Mockito.mock(ResourceResolverFactory.class); - Mockito.when(mockFactory.getServiceResourceResolver(Mockito.anyMap())) - .thenThrow(new LoginException("NOT AN EXCEPTION: Testing login failure handling")); + 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()); + } - RelayProvider provider = RelayProvider - .builder() - .resolverFactory(mockFactory) - .source(PATH_SOURCE) - .target(PATH_TARGET) - .reportedPath(PATH_ASSET) - .build(); + @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()); + } - // LoginException must be caught internally; no exception propagates to the caller - provider.start(Mockito.mock(ProviderContext.class)); + @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 shouldApplyUserMapping() throws LoginException { - Resource targetResource = context.create().resource(PATH_TARGET); + 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()); - ResourceResolverFactory mockFactory = Mockito.mock(ResourceResolverFactory.class); ResourceResolver mappedResolver = Mockito.mock(ResourceResolver.class); - Map propertyMap = new HashMap<>(); - Mockito.when(mappedResolver.getResource(PATH_TARGET)).thenReturn(targetResource); - Mockito.when(mappedResolver.getUserID()).thenReturn("mapped-user"); + 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("testUser"); + Mockito.when(basicResolver.getUserID()).thenReturn(USER_AUTHOR); Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); - @SuppressWarnings("unchecked") - ResolveContext resolveContext = Mockito.mock(ResolveContext.class); - Mockito.when(resolveContext.getResourceResolver()).thenReturn(basicResolver); - ResourceContext mockResourceContext = Mockito.mock(ResourceContext.class); + ResolveContext resolveContext = newResolveContext(basicResolver); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); - // Subservice user mapping: target is a plain service name (no colon) - Mockito.when(mockFactory.getServiceResourceResolver(Mockito.anyMap())).thenReturn(mappedResolver); + RelayProvider provider = newProvider(factory, relay); + Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); - RelayProvider providerWithSubservice = RelayProvider - .builder() - .resolverFactory(mockFactory) - .source(PATH_SOURCE) - .target(PATH_TARGET) - .userMapping("testUser", "mySubservice") - .build(); + 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); - Resource result = providerWithSubservice.getResource(resolveContext, PATH_SOURCE, mockResourceContext, null); assertNotNull(result); - assertEquals(PATH_SOURCE, result.getPath()); - Mockito.verify(mockFactory).getServiceResourceResolver( - Mockito.argThat(map -> "mySubservice".equals(map.get(ResourceResolverFactory.SUBSERVICE)))); - - // Credentials user mapping: target is "user:password" (contains colon) - Mockito.reset(mockFactory, mappedResolver); - propertyMap.clear(); - Mockito.when(mappedResolver.getResource(PATH_TARGET)).thenReturn(targetResource); - Mockito.when(mockFactory.getResourceResolver(Mockito.anyMap())).thenReturn(mappedResolver); - - RelayProvider providerWithCredentials = RelayProvider - .builder() - .resolverFactory(mockFactory) - .source(PATH_SOURCE) - .target(PATH_TARGET) - .userMapping("testUser", "svcUser:svcPass") - .build(); - - result = providerWithCredentials.getResource(resolveContext, PATH_SOURCE, mockResourceContext, null); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + } + + @Test + public void shouldFallBackToOriginalResolver() throws LoginException { + String brokenService = "broken-service"; + RelayInfo relay = newRelayInfo( + PATH_SOURCE, PATH_TARGET, + Collections.singletonList(newMapping(USER_AUTHOR, brokenService)), + 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); + + // LoginException is caught and the original resolver is used as fallback assertNotNull(result); - assertEquals(PATH_SOURCE, result.getPath()); - Mockito.verify(mockFactory).getResourceResolver( - Mockito.argThat(map -> "svcUser".equals(map.get(ResourceResolverFactory.USER)) - && java.util.Arrays.equals( - "svcPass".toCharArray(), - (char[]) map.get(ResourceResolverFactory.PASSWORD)))); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + Mockito.verify(basicResolver).getResource(PATH_TARGET + PATH_CHILD_A); } /* --------------- Utility methods --------------- */ - private RelayProvider newProvider() { - return RelayProvider - .builder() - .resolverFactory(Mockito.mock(ResourceResolverFactory.class)) - .source(PATH_SOURCE) - .target(PATH_TARGET) - .build(); + private RelayProvider newProvider(RelayInfo relay) { + return new RelayProvider(context.getService(ResourceResolverFactory.class), relay); } - private RelayProvider newProvider(ResourceResolverFactory factory) { - return RelayProvider - .builder() - .resolverFactory(factory) - .source(PATH_SOURCE) - .target(PATH_TARGET) - .build(); + private static RelayProvider newProvider(ResourceResolverFactory factory, RelayInfo relay) { + return new RelayProvider(factory, relay); + } + + 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); } - @SuppressWarnings("unchecked") 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(context.resourceResolver()); + Mockito.when(resolveContext.getResourceResolver()).thenReturn(resolver); return resolveContext; } + private static ProviderContext newMockProviderContext(ObservationReporter reporter) { + ProviderContext providerContext = Mockito.mock(ProviderContext.class); + Mockito.when(providerContext.getObservationReporter()).thenReturn(reporter); + return providerContext; + } + @SuppressWarnings("unchecked") - private ResolveContext newResolveContextWithParent( - ResourceProvider parentProvider, - ResolveContext parentContext) { + private static ResourceProvider newMockProvider() { + return Mockito.mock(ResourceProvider.class); + } - ResolveContext resolveContext = Mockito.mock(ResolveContext.class); - Mockito.when(resolveContext.getResourceResolver()).thenReturn(context.resourceResolver()); - // doReturn avoids generic wildcard type mismatch between ResourceProvider and ResourceProvider - Mockito.doReturn(parentProvider).when(resolveContext).getParentResourceProvider(); - Mockito.doReturn(parentContext).when(resolveContext).getParentResolveContext(); - return resolveContext; + @SuppressWarnings("unchecked") + private static ResolveContext newMockResolveContext() { + return Mockito.mock(ResolveContext.class); } - private static ResourceResolverFactory newMockFactory() throws LoginException { - ResourceResolverFactory mockFactory = Mockito.mock(ResourceResolverFactory.class); - Mockito.when(mockFactory.getServiceResourceResolver(Mockito.anyMap())) - .thenReturn(Mockito.mock(ResourceResolver.class)); - return mockFactory; + @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); } } From bb552cf048688af187b8ef634a2a65f7b6604546 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Fri, 1 May 2026 23:06:51 +0300 Subject: [PATCH 47/92] [EAK-651] Update RelayProviderHostTest --- .../relay/services/RelayProviderHostTest.java | 106 +++++++++++++++--- 1 file changed, 91 insertions(+), 15 deletions(-) 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 index a3156d0ee..b982e9be5 100644 --- 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 @@ -13,13 +13,21 @@ */ 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.sling.api.resource.Resource; +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; @@ -35,44 +43,95 @@ public class RelayProviderHostTest { @Rule public final AemContext context = AemContextFactory.newInstance(); + /* ------------------ + Service activation + ------------------ */ + @Test - public void shouldNotRegisterServicesWhenDisabled() throws Exception { - context.registerInjectActivateService( - new RelayProviderHost(), - newProps(false, PATH_MAPPING_A)); + 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 shouldNotRegisterServicesWithoutValidPathMappings() throws Exception { + public void shouldRegisterForEachValidMapping() throws InvalidSyntaxException { context.registerInjectActivateService( new RelayProviderHost(), - newProps(true)); + newProps(true, PATH_MAPPING_A, PATH_MAPPING_B)); - assertEquals(0, countRegisteredProviders()); + assertEquals(2, countRegisteredProviders()); } @Test - public void shouldSkipInvalidPathMappings() throws Exception { - context.registerInjectActivateService( + public void shouldSkipShadowedPath() throws InvalidSyntaxException { + Dictionary extProps = new Hashtable<>(); + extProps.put(ResourceProvider.PROPERTY_ROOT, "/content/source"); + extProps.put(ResourceProvider.PROPERTY_NAME, "external-provider"); + context.bundleContext().registerService(ResourceProvider.class.getName(), new StubResourceProvider(), extProps); + + context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + } + + /* --------------------- + Service re-activation + --------------------- */ + + @Test + public void shouldUpdateProviderOnReactivate() throws InvalidSyntaxException { + RelayProviderHost host = context.registerInjectActivateService( new RelayProviderHost(), - newProps(true, PATH_MAPPING_MISSING_TO)); + newProps(true, PATH_MAPPING_A)); - assertEquals(0, countRegisteredProviders()); + assertEquals(1, countRegisteredProviders()); + + MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); } @Test - public void shouldRegisterServicesForEachValidPathMapping() throws Exception { - context.registerInjectActivateService( + 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 shouldUnregisterServicesOnDeactivate() throws Exception { + 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)); @@ -88,7 +147,7 @@ public void shouldUnregisterServicesOnDeactivate() throws Exception { Utility methods --------------- */ - private int countRegisteredProviders() throws Exception { + private int countRegisteredProviders() throws InvalidSyntaxException { ServiceReference[] refs = context.bundleContext() .getAllServiceReferences(ResourceProvider.class.getName(), null); return refs != null ? refs.length : 0; @@ -100,4 +159,21 @@ private static Map newProps(boolean enabled, String... pathMappi props.put("pathMappings", pathMappings); 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; + } + } } From ca4580a4fd4b3c36e2c52d8a551b108292f3ea8a Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Sat, 2 May 2026 02:37:01 +0300 Subject: [PATCH 48/92] [EAK-651] Update PathSamplerTest --- .../core/relay/services/PathSamplerTest.java | 348 +++++++++++++----- 1 file changed, 262 insertions(+), 86 deletions(-) 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 index 94ae46e53..757cf7d6c 100644 --- 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 @@ -16,138 +16,314 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; -import javax.jcr.Node; -import javax.jcr.RepositoryException; +import java.util.stream.Collectors; import javax.jcr.Session; -import javax.jcr.query.Query; +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.jcr.MockJcr; +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.assertFalse; +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_PLAIN = "/content/plain"; - private static final String PATH_ANOTHER = "/content/another"; + 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"; - private static final String XPATH_MATCH_SAMPLED = "/jcr:root/content//*[@type='sampled']"; + @Rule + public final AemContext context = new AemContextBuilder() + .resourceResolverType(ResourceResolverType.JCR_OAK) + .build(); + + /* ---------------- + JCR path samples + ---------------- */ @Test - public void shouldReportEmptyWhenSamplesAreNullOrEmpty() { - assertTrue(new PathSampler(null).isEmpty()); - assertTrue(new PathSampler(Collections.emptyList()).isEmpty()); - assertFalse(new PathSampler(Collections.singletonList(PATH_PLAIN)).isEmpty()); + 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 shouldReturnEmptyChangesWithoutResolution() { - PathSampler sampler = new PathSampler(Collections.singletonList(PATH_PLAIN)); - - Collection changes = sampler.createChanges(); - - assertTrue(changes.isEmpty()); + 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 shouldProduceChangesFromJcrPaths() { - // Session is not accessed during plain JCR path resolution; null is safe here - PathSampler sampler = new PathSampler(Arrays.asList(PATH_PLAIN, PATH_ANOTHER)); - - Collection changes = sampler.createChanges(null); + 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(); - assertEquals(2, changes.size()); - assertTrue(changes.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); - assertTrue(changes.stream().anyMatch(c -> PATH_ANOTHER.equals(c.getPath()))); - changes.forEach(c -> assertEquals(ResourceChange.ChangeType.CHANGED, c.getType())); + 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")); } - @Test - public void shouldProduceChangesFromXPathExpression() throws RepositoryException { - Session session = MockJcr.newSession(); - Node contentNode = session.getRootNode().addNode("content"); - Node plainNode = contentNode.addNode("plain"); - Node anotherNode = contentNode.addNode("another"); - setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Arrays.asList(plainNode, anotherNode)); - - PathSampler sampler = new PathSampler(Collections.singletonList(XPATH_MATCH_SAMPLED)); - Collection changes = sampler.createChanges(session); - - assertEquals(2, changes.size()); - assertTrue(changes.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); - assertTrue(changes.stream().anyMatch(c -> PATH_ANOTHER.equals(c.getPath()))); - changes.forEach(c -> assertEquals(ResourceChange.ChangeType.CHANGED, c.getType())); - } + /* -------------- + Result caching + -------------- */ @Test - public void shouldProduceChangesFromMixedSamples() throws RepositoryException { - Session session = MockJcr.newSession(); - Node anotherNode = session.getRootNode().addNode("content").addNode("another"); - setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Collections.singletonList(anotherNode)); - - // PATH_PLAIN is a plain JCR path; PATH_ANOTHER is resolved via the XPath expression - PathSampler sampler = new PathSampler(Arrays.asList(PATH_PLAIN, XPATH_MATCH_SAMPLED)); - Collection changes = sampler.createChanges(session); - - assertEquals(2, changes.size()); - assertTrue(changes.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); - assertTrue(changes.stream().anyMatch(c -> PATH_ANOTHER.equals(c.getPath()))); + public void shouldCacheResolvedPaths() { + PathSampler sampler = PathSampler + .builder() + .source(PATH_SOURCE) + .target(PATH_TARGET) + .samples(Collections.singletonList(newChangeSample(PATH_TARGET + PATH_CHILD))) + .build(); + + // First call applies target→source rewriting + Collection firstResult = sampler.createChanges(); + assertEquals(1, firstResult.size()); + assertEquals(PATH_SOURCE + PATH_CHILD, firstResult.iterator().next().getPath()); + + // Second call returns raw cached paths without rewriting + Collection secondResult = sampler.createChanges(); + assertEquals(1, secondResult.size()); + assertEquals(PATH_TARGET + PATH_CHILD, secondResult.iterator().next().getPath()); } + /* ---------------- + XPath resolution + ---------------- */ + @Test - public void shouldSkipUnrecognizedSamples() { - PathSampler sampler = new PathSampler(Arrays.asList("not-a-path", "relative/path", "")); + 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(); - // Session is not accessed because none of the samples require resolution; null is safe - Collection changes = sampler.createChanges(null); + Collection changes = sampler.createChanges(); - assertTrue(changes.isEmpty()); + assertEquals(1, changes.size()); + assertEquals(PATH_SOURCE + PATH_CHILD, changes.iterator().next().getPath()); } @Test - public void shouldReturnEmptyChangesWhenXPathYieldsNoResults() { - Session session = MockJcr.newSession(); - setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Collections.emptyList()); + 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(); - PathSampler sampler = new PathSampler(Collections.singletonList(XPATH_MATCH_SAMPLED)); - Collection changes = sampler.createChanges(session); + Collection changes = sampler.createChanges(); - assertTrue(changes.isEmpty()); + assertEquals(5, changes.size()); } @Test - public void shouldResolveSamplesOnce() throws RepositoryException { - Session session = MockJcr.newSession(); - Node plainNode = session.getRootNode().addNode("content").addNode("plain"); - setXpathQueryResult(session, XPATH_MATCH_SAMPLED, Collections.singletonList(plainNode)); - - PathSampler sampler = new PathSampler(Collections.singletonList(XPATH_MATCH_SAMPLED)); - Collection firstResult = sampler.createChanges(session); - // Second call with null session — if resolution were not cached, a NullPointerException would occur - Collection secondResult = sampler.createChanges(null); - - assertEquals(firstResult.size(), secondResult.size()); - assertTrue(secondResult.stream().anyMatch(c -> PATH_PLAIN.equals(c.getPath()))); + 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()); } - @Test - public void shouldReturnEmptyChangesForNullSamples() { - PathSampler sampler = new PathSampler(null); + /* ----------------- + Resolver rotation + ----------------- */ - Collection changes = sampler.createChanges(null); + @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()); + } - assertTrue(changes.isEmpty()); + @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 --------------- */ - @SuppressWarnings({"deprecation", "SameParameterValue"}) - private static void setXpathQueryResult(Session session, String expression, List nodes) { - MockJcr.setQueryResult(session, expression, Query.XPATH, nodes); + 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; } } From 376d0512382a5b59c0754334d01261c025f63aa2 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Sat, 2 May 2026 11:50:12 +0300 Subject: [PATCH 49/92] [EAK-651] Create tests for relay-related model classes --- .../core/relay/models/ChangeSampleTest.java | 63 ++++++++++ .../core/relay/models/RelayInfoTest.java | 116 ++++++++++++++++++ .../core/relay/models/RelayMappingTest.java | 66 ++++++++++ .../core/relay/models/RelayResourceTest.java | 67 ++++++++++ 4 files changed, 312 insertions(+) create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/models/ChangeSampleTest.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayInfoTest.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java create mode 100644 core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java 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..d9c2b3e32 --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java @@ -0,0 +1,66 @@ +/* + * 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.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +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 valid = newMapping(PATH_SOURCE, PATH_TARGET); + + assertEquals(PATH_SOURCE, valid.getFrom()); + assertEquals(PATH_TARGET, valid.getTo()); + assertTrue(valid.isValid()); + + // isValid requires non-blank from and to + assertFalse(newMapping("", PATH_TARGET).isValid()); + assertFalse(newMapping(PATH_SOURCE, "").isValid()); + assertFalse(newMapping(null, PATH_TARGET).isValid()); + assertFalse(newMapping(PATH_SOURCE, null).isValid()); + } + + @Test + public void shouldBeEqualByBothFields() { + RelayMapping a = newMapping(PATH_SOURCE, PATH_TARGET); + RelayMapping b = newMapping(PATH_SOURCE, PATH_TARGET); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + + assertNotEquals(a, newMapping(PATH_OTHER, PATH_TARGET)); + assertNotEquals(a, newMapping(PATH_SOURCE, PATH_OTHER)); + assertNotNull(a); + } + + 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..1eb41920a --- /dev/null +++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java @@ -0,0 +1,67 @@ +/* + * 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.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"; + + @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 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()); + } +} From f7398fd5a5ced27f8fc28ee6bbfd919fb3ae00ca Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Sat, 2 May 2026 16:52:56 +0300 Subject: [PATCH 50/92] [EAK-651] Update ResolverUtilTest --- .../aem/toolkit/core/utils/ResolverUtil.java | 24 +++- .../toolkit/core/utils/ResolverUtilTest.java | 112 ++++++++++++++++-- 2 files changed, 123 insertions(+), 13 deletions(-) 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 index 5487b30e6..bd9faaf76 100644 --- 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 @@ -71,6 +71,24 @@ 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 } @@ -81,13 +99,11 @@ public static ResourceResolver newResolver( String localizedUserId = StringUtils.substringBefore(user, CoreConstants.SEPARATOR_AT); String bundleId = StringUtils.substringAfter(user, CoreConstants.SEPARATOR_AT); - Bundle bundle = FrameworkUtil.getBundle(ResolverUtil.class); - BundleContext bundleContext = bundle != null ? bundle.getBundleContext() : null; - if (bundleContext == null) { + if (context == null) { throw new LoginException("Not running in an OSGi container"); } Bundle targetBundle = Arrays - .stream(bundleContext.getBundles()) + .stream(context.getBundles()) .filter(b -> StringUtils.equals(b.getSymbolicName(), bundleId)) .findFirst() .orElse(null); 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 index 2f01cb0e9..753341c5d 100644 --- 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 @@ -14,7 +14,9 @@ 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; @@ -25,35 +27,73 @@ import org.junit.Assert; import org.junit.Rule; import org.junit.Test; -import org.junit.runner.RunWith; import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; +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; -@RunWith(MockitoJUnitRunner.class) 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 AemContext context = AemContextFactory.newInstance(); + 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, "eak-service"))).thenReturn(expected); + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER))).thenReturn(expected); - ResourceResolver resultFromFactory = ResolverUtil.newResolver(factory); - assertEquals(expected, resultFromFactory); + try (ResourceResolver resultFromFactory = ResolverUtil.newResolver(factory)) { + assertEquals(expected, resultFromFactory); + } + try (ResourceResolver resultFromRequest = ResolverUtil.newResolver(newRequestWithFactory(factory))) { + assertEquals(expected, resultFromRequest); + } + } - 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 @@ -84,9 +124,55 @@ 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); @@ -114,5 +200,13 @@ private static void assertLoginException(ResourceResolverFactory factory, String 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()); + } + } } From 66bdb23428869925dbbc2f61338efcb461d8e2da Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Sun, 3 May 2026 11:38:33 +0300 Subject: [PATCH 51/92] [EAK-651] Update ServiceUtilTest --- .../toolkit/core/utils/ServiceUtilTest.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) 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 index 073095bd5..14640e22a 100644 --- 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 @@ -38,6 +38,10 @@ public class ServiceUtilTest { @Rule public final AemContext context = AemContextFactory.newInstance(); + /* --------------------- + Consumer and Function + --------------------- */ + @Test public void shouldInvokeConsumerAndReturnFunctionResult() { AtomicReference capturedService = new AtomicReference<>(); @@ -140,6 +144,126 @@ public void shouldHandleMissingBundleContext() { 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") @@ -168,6 +292,13 @@ private static MockContext newMockContextThrowingOnUnget() { 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(); } From 8f007e1250eeef07b903a26ebaf87ef38a34982b Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Sun, 3 May 2026 11:47:01 +0300 Subject: [PATCH 52/92] [EAK-651] Fix path sampling cache logic --- .../core/relay/services/PathSampler.java | 1 + .../core/relay/services/PathSamplerTest.java | 19 +++++++++++++++---- .../relay/services/RelayProviderTest.java | 3 +-- 3 files changed, 17 insertions(+), 6 deletions(-) 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 index dc1e4a034..a859857b5 100644 --- 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 @@ -71,6 +71,7 @@ Collection createChanges() { if (paths != null) { return paths .stream() + .map(p -> RelayPathHelper.isSubpath(p, target) ? RelayPathHelper.replace(p, target, source) : p) .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) .collect(Collectors.toList()); } 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 index 757cf7d6c..20af811ce 100644 --- 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 @@ -13,6 +13,7 @@ */ package com.exadel.aem.toolkit.core.relay.services; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -140,22 +141,32 @@ public void shouldRewriteTargetPaths() { @Test public void shouldCacheResolvedPaths() { + // Wrap samples in a spy list so we can count iterations + List spySamples = Mockito.spy( + new ArrayList<>(Collections.singletonList(newChangeSample(PATH_TARGET + PATH_CHILD))) + ); + PathSampler sampler = PathSampler .builder() .source(PATH_SOURCE) .target(PATH_TARGET) - .samples(Collections.singletonList(newChangeSample(PATH_TARGET + PATH_CHILD))) + .samples(spySamples) .build(); - // First call applies target→source rewriting + // First call — samples are iterated to build the path set Collection firstResult = sampler.createChanges(); assertEquals(1, firstResult.size()); assertEquals(PATH_SOURCE + PATH_CHILD, firstResult.iterator().next().getPath()); + Mockito.verify(spySamples, Mockito.atLeastOnce()).iterator(); + + //noinspection unchecked + Mockito.clearInvocations(spySamples); - // Second call returns raw cached paths without rewriting + // Second call — must return the cached result without re-iterating samples Collection secondResult = sampler.createChanges(); assertEquals(1, secondResult.size()); - assertEquals(PATH_TARGET + PATH_CHILD, secondResult.iterator().next().getPath()); + assertEquals(PATH_SOURCE + PATH_CHILD, secondResult.iterator().next().getPath()); + Mockito.verify(spySamples, Mockito.never()).iterator(); } /* ---------------- 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 index 7d2010b55..cc967a478 100644 --- 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 @@ -322,8 +322,7 @@ public void shouldStopAndReportChanges() { Collection changes = captor.getValue(); assertEquals(1, changes.size()); - // PathSampler's second createChanges() call returns the raw cached paths (still target-prefixed) - assertEquals(PATH_TARGET + PATH_CHILD_A, changes.iterator().next().getPath()); + assertEquals(PATH_SOURCE + PATH_CHILD_A, changes.iterator().next().getPath()); } /* -------------------- From 36d0135fadc342c6329bb7f518d4024d364a99bf Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 15:10:58 +0300 Subject: [PATCH 53/92] [EAK-651] Change the fallback child resource listing call --- .../aem/toolkit/core/relay/services/RelayProvider.java | 3 ++- .../aem/toolkit/core/relay/utils/RelayResourceHelper.java | 2 ++ .../aem/toolkit/core/relay/services/RelayProviderTest.java | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) 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 index d1d035e68..bf7d75b8c 100644 --- 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 @@ -126,8 +126,9 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N return null; } else if (!(targetResource instanceof RelayResource)) { // We have fallen back to an "original" resource, so we should iterate through it without any mapping - return RelayResourceHelper.listChildren(context, parent); + return RelayResourceHelper.listChildren(context, targetResource); } + // This method call exerts the path mapping logic for the children of the target resource return RelayResourceHelper.listChildren(targetResource, parent.getPath()); } 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 index 0b437b175..453274450 100644 --- 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 @@ -131,6 +131,8 @@ public static Resource getResource( */ public static Iterator listChildren(Resource target, String path) { if (target instanceof RelayResource) { + // This will effectively wrap the children of the "physical" resource concealed within the relay resource + // into the relay resourcw facades of their own return target.listChildren(); } return StreamSupport.stream(Spliterators.spliteratorUnknownSize(target.listChildren(), 0), false) 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 index cc967a478..74c47a6b4 100644 --- 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 @@ -199,12 +199,14 @@ 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.isNull(), Mockito.any())) + Mockito + .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE), Mockito.isNull(), 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())) + Mockito + .when(mockProvider.listChildren(Mockito.any(), Mockito.any())) .thenReturn(Collections.singletonList(expectedChild).iterator()); ResolveContext resolveContext = newResolveContext(); From 9bef4ab5ff5f4b47b217b285ecf70b7d0cfe6058 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 15:22:20 +0300 Subject: [PATCH 54/92] [EAK-651] Fix the re-activation routine with paths property change --- .../relay/services/RelayProviderHost.java | 14 ++++++-- .../relay/services/RelayProviderHostTest.java | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) 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 index 14171c31e..a0ea4d06d 100644 --- 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 @@ -89,10 +89,20 @@ private void activate(BundleContext context, RelayConfig config) { .filter(relay -> StringUtils.equals(relay.getSource(), existingRoot)) .findFirst() .orElse(null); + boolean shouldUnregister = false; if (matchingRelay != null) { - registrations.get(registration).update(matchingRelay); - relays.remove(matchingRelay); + String[] existingObservedPaths = (String[]) registration.getReference().getProperty(ResourceChangeListener.PATHS); + String existingTarget = ArrayUtils.isNotEmpty(existingObservedPaths) ? existingObservedPaths[0] : null; + if (StringUtils.equals(existingTarget, matchingRelay.getTarget())) { + registrations.get(registration).update(matchingRelay); + relays.remove(matchingRelay); + } else { + shouldUnregister = true; + } } else { + shouldUnregister = true; + } + if (shouldUnregister) { try { registration.unregister(); iterator.remove(); 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 index b982e9be5..b1d0b1d7a 100644 --- 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 @@ -20,7 +20,9 @@ import java.util.Map; import javax.annotation.Nonnull; +import org.apache.commons.lang3.ArrayUtils; 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; @@ -37,6 +39,7 @@ public class RelayProviderHostTest { 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_B = "{\"from\":\"/content/source2\",\"to\":\"/content/target2\"}"; private static final String PATH_MAPPING_MISSING_TO = "{\"from\":\"/content/source\"}"; @@ -100,6 +103,21 @@ public void shouldUpdateProviderOnReactivate() throws InvalidSyntaxException { assertEquals(1, countRegisteredProviders()); } + @Test + public void shouldReregisterWhenTargetChangesOnReactivate() throws InvalidSyntaxException { + RelayProviderHost host = context.registerInjectActivateService( + new RelayProviderHost(), + newProps(true, PATH_MAPPING_A)); + + assertEquals(1, countRegisteredProviders()); + assertEquals("/content/target", getRegisteredTarget("/content/source")); + + MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A_NEW_TARGET)); + + assertEquals(1, countRegisteredProviders()); + assertEquals("/content/target-updated", getRegisteredTarget("/content/source")); + } + @Test public void shouldUnregisterRemovedMappingOnReactivate() throws InvalidSyntaxException { RelayProviderHost host = context.registerInjectActivateService( @@ -153,6 +171,24 @@ private int countRegisteredProviders() throws InvalidSyntaxException { 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 Map newProps(boolean enabled, String... pathMappings) { Map props = new HashMap<>(); props.put("enabled", enabled); From fa5dd7786a2732b5eb162b50ca6d4398f38c981b Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 17:38:44 +0300 Subject: [PATCH 55/92] [EAK-651] Make subsidiary resource resolvers more persistent --- .../core/relay/services/RelayProvider.java | 34 +- .../core/relay/utils/RelayResourceHelper.java | 133 +++----- .../relay/utils/RelayResourceHelperTest.java | 314 +++++++++--------- 3 files changed, 210 insertions(+), 271 deletions(-) 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 index bf7d75b8c..ec1672ce4 100644 --- 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 @@ -21,9 +21,7 @@ import javax.annotation.Nullable; import org.apache.commons.lang3.StringUtils; -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.api.resource.observation.ExternalResourceChangeListener; import org.apache.sling.api.resource.observation.ResourceChange; @@ -39,7 +37,6 @@ 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; -import com.exadel.aem.toolkit.core.utils.ResolverUtil; /** * A Sling {@link ResourceProvider} and {@link ResourceChangeListener} that transparently relays resource @@ -87,7 +84,6 @@ public Resource getResource( @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.isSubpath(path, localRelay.getSource())) { return null; @@ -95,7 +91,8 @@ public Resource getResource( String targetPath = localRelay.getTarget() + StringUtils.substring(path, localRelay.getSource().length()); Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), - this::getMappedResourceResolver, + resolverFactory, + localRelay.getUserMapping(context.getResourceResolver().getUserID()), targetPath); if (resolved != null) { return new RelayResource(resolved, path); @@ -109,13 +106,13 @@ public Resource getResource( @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 targetResource = RelayResourceHelper.getResource( context.getResourceResolver(), - this::getMappedResourceResolver, + resolverFactory, + localRelay.getUserMapping(context.getResourceResolver().getUserID()), targetPath); if (targetResource != null) { targetResource = new RelayResource(targetResource, path); @@ -167,6 +164,7 @@ public void stop() { getProviderContext().getObservationReporter().reportChanges(declaredChanges, false); } } + sampler = null; super.stop(); } @@ -191,27 +189,5 @@ public void onChange(@Nonnull List changes) { .collect(Collectors.toList()); getProviderContext().getObservationReporter().reportChanges(mappedChanges, false); } - - /* --------------- - Service methods - --------------- */ - - /** - * Returns a {@link ResourceResolver} for the user identity mapped to the provided resolver's user ID. - * If no mapping is configured for the current user, returns the original resolver unchanged - * @param resolver {@code ResourceResolver} whose user ID is used as the mapping key - * @return A non-null {@code ResourceResolver} instance; may be the original if no mapping applies - */ - private ResourceResolver getMappedResourceResolver(ResourceResolver resolver) { - String mappedId = relay.getUserMapping(resolver.getUserID()); - if (mappedId != null && !mappedId.equals(resolver.getUserID())) { - try { - return ResolverUtil.newResolver(resolverFactory, mappedId); - } catch (LoginException e) { - LOG.error("Failed to create a resource resolver for {}", mappedId, e); - } - } - return resolver; - } } 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 index 453274450..63ec5f7bf 100644 --- 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 @@ -13,15 +13,15 @@ */ package com.exadel.aem.toolkit.core.relay.utils; -import java.io.Closeable; import java.util.Iterator; -import java.util.Map; import java.util.Spliterators; -import java.util.function.UnaryOperator; 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; @@ -30,6 +30,7 @@ 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 @@ -40,61 +41,62 @@ public class RelayResourceHelper { private static final Logger LOG = LoggerFactory.getLogger(RelayResourceHelper.class); - static final String KEY_SUBSIDIARY = "subsidiary"; - /** * 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. Manages the lifecycle of any - * subsidiary resolver created by the {@code resolverModifier} - * @param basicResolver The base {@link ResourceResolver} instance used for resolution - * @param resolverModifier A {@code UnaryOperator} that optionally produces an alternative {@code ResourceResolver} - * from the provided one - * @param path JCR path of the resource to resolve + * 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( - ResourceResolver basicResolver, - UnaryOperator resolverModifier, - String path) { - - ResourceResolver effectiveResolver = resolverModifier.apply(basicResolver); + @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; + } + } Resource result = effectiveResolver.getResource(path); if (result == null) { LOG.debug("Could not resolve {} with user {}", path, effectiveResolver.getUserID()); - if (!effectiveResolver.equals(basicResolver)) { - effectiveResolver.close(); - } - return null; - } - if (!effectiveResolver.equals(basicResolver)) { - // We have created another {@link ResourceResolver} via the {@code resolverModifier}. We cannot close it - // in place - instead, 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 {@code basicResolver} so that it will be - // automatically closed when the {@code basicResolver} is closed by Sling. - // A {@link SubsidiaryHolder} wrapper is used so that the swap-and-close of replaced resolvers is atomic. - // See https://sling.apache.org/apidocs/sling12/org/apache/sling/api/resource/ResourceResolver.html#getPropertyMap - Map propertyMap = basicResolver.getPropertyMap(); - propertyMap.compute(KEY_SUBSIDIARY, (key, existing) -> { - if (existing instanceof ResolverHolder) { - ((ResolverHolder) existing).swap(effectiveResolver); - return existing; - } - return new ResolverHolder(effectiveResolver); - }); + } else { + LOG.debug("Resolved {} to {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); } - LOG.debug("Resolved {} to {} 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, UnaryOperator, String)} + * {@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 @@ -130,12 +132,8 @@ public static Resource getResource( * @return A non-null {@code Iterator} of {@link Resource} instances */ public static Iterator listChildren(Resource target, String path) { - if (target instanceof RelayResource) { - // This will effectively wrap the children of the "physical" resource concealed within the relay resource - // into the relay resourcw facades of their own - return target.listChildren(); - } - return StreamSupport.stream(Spliterators.spliteratorUnknownSize(target.listChildren(), 0), false) + 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(); @@ -183,49 +181,4 @@ private static void reportMissingContext(String path) { private static void reportFallingBack(String path) { LOG.debug("Falling back to parent resource provider for {}", path); } - - /* ------------------ - Subsidiary classes - ------------------ */ - - /** - * A thread-safe {@link Closeable} wrapper around a subsidiary {@link ResourceResolver}. Stored in the property - * map of a base resolver so that Sling automatically closes the held resolver when the base resolver is closed. - * The {@link #swap(ResourceResolver)} method atomically replaces the held resolver, closing the previous one - */ - static class ResolverHolder implements Closeable { - - private volatile ResourceResolver resolver; - - /** - * Creates a new holder with the provided resolver - * @param resolver Initial subsidiary {@link ResourceResolver} - */ - ResolverHolder(ResourceResolver resolver) { - this.resolver = resolver; - } - - /** - * Atomically replaces the held resolver with a new one, closing the previous resolver if present - * @param newResolver The replacement {@link ResourceResolver} - */ - synchronized void swap(ResourceResolver newResolver) { - if (resolver != null) { - LOG.warn("A subsidiary resolver for {} will close", resolver.getUserID()); - resolver.close(); - } - resolver = newResolver; - } - - /** - * {@inheritDoc} - */ - @Override - public synchronized void close() { - if (resolver != null) { - resolver.close(); - resolver = null; - } - } - } } 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 index ec080d8d9..2ae60fbc0 100644 --- 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 @@ -17,34 +17,28 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.UnaryOperator; +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.junit.runner.RunWith; import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; 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; -@RunWith(MockitoJUnitRunner.class) public class RelayResourceHelperTest { private static final String PATH_TARGET = "/content/target"; @@ -58,179 +52,195 @@ public class RelayResourceHelperTest { public void shouldResolveResource() { context.create().resource(PATH_TARGET); ResourceResolver resolver = context.resourceResolver(); + ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class); - // Resource found → returned directly - Resource found = RelayResourceHelper.getResource( - resolver, - UnaryOperator.identity(), - PATH_TARGET); - assertNotNull(found); - assertEquals(PATH_TARGET, found.getPath()); - - // Resource not found → null - Resource notFound = RelayResourceHelper.getResource( - resolver, - UnaryOperator.identity(), - PATH_TARGET + "/missing"); - assertNull(notFound); + 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() { - Resource targetResource = context.create().resource(PATH_TARGET); + Map propertyMap = new HashMap<>(); + ResourceResolver resolver = Mockito.mock(ResourceResolver.class); + Mockito.when(resolver.getPropertyMap()).thenReturn(propertyMap); - ResourceResolver modifiedResolver = Mockito.mock(ResourceResolver.class); - Mockito.when(modifiedResolver.getResource(PATH_TARGET)).thenReturn(targetResource); - Mockito.when(modifiedResolver.getUserID()).thenReturn("modified-user"); + ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class); + assertNotNull(resolverFactory); - Map propertyMap = new HashMap<>(); - ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); - Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); - - // Modified resolver → stored as subsidiary in the basic resolver's property map - RelayResourceHelper.getResource( - basicResolver, - resolver -> modifiedResolver, - PATH_TARGET); - assertTrue(propertyMap.get("subsidiary") instanceof RelayResourceHelper.ResolverHolder); - - // Second call with a different modified resolver → existing subsidiary is closed, new one stored - ResourceResolver nextResolver = Mockito.mock(ResourceResolver.class); - Mockito.when(nextResolver.getResource(PATH_TARGET)).thenReturn(targetResource); - Mockito.when(nextResolver.getUserID()).thenReturn("next-user"); - - RelayResourceHelper.getResource( - basicResolver, - resolver -> nextResolver, - PATH_TARGET); - Mockito.verify(modifiedResolver).close(); - assertTrue(propertyMap.get(RelayResourceHelper.KEY_SUBSIDIARY) instanceof RelayResourceHelper.ResolverHolder); + RelayResourceHelper.getResource(resolver, resolverFactory, "modified-user", PATH_TARGET); + + Object stored = propertyMap.get(ResourceResolver.class.getName() + "@modified-user"); + assertTrue(stored instanceof ResourceResolver); } @Test - public void shouldHandleConcurrentSubsidiaryAccess() throws InterruptedException { - Resource targetResource = context.create().resource(PATH_TARGET); - - int threadCount = 8; - Map propertyMap = new ConcurrentHashMap<>(); - ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); - Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap); - - AtomicInteger closedCount = new AtomicInteger(); - ResourceResolver[] resolvers = new ResourceResolver[threadCount]; - for (int i = 0; i < threadCount; i++) { - ResourceResolver mock = Mockito.mock(ResourceResolver.class); - Mockito.when(mock.getResource(PATH_TARGET)).thenReturn(targetResource); - Mockito.when(mock.getUserID()).thenReturn("user-" + i); - Mockito.doAnswer(inv -> { - closedCount.incrementAndGet(); - return null; - }).when(mock).close(); - resolvers[i] = mock; - } - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch startLatch = new CountDownLatch(1); - for (ResourceResolver resolver : resolvers) { - executor.submit(() -> { - try { - startLatch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - RelayResourceHelper.getResource( - basicResolver, - r -> resolver, - PATH_TARGET); - }); - } - startLatch.countDown(); - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - - assertNotNull(propertyMap.get(RelayResourceHelper.KEY_SUBSIDIARY)); - assertEquals(threadCount - 1, closedCount.get()); + 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 shouldDelegateGetResourceToParentProvider() { - Resource targetResource = context.create().resource(PATH_TARGET); + 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(resolver).getResource(PATH_TARGET); // original resolver was used + 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 mockParentContext = newMockResolveContext(); - ResourceContext mockResourceContext = Mockito.mock(ResourceContext.class); - Mockito.when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_TARGET), Mockito.any(), Mockito.any())) - .thenReturn(targetResource); - - ResolveContext mockResolveContext = newMockResolveContext(); - Mockito.doReturn(mockProvider).when(mockResolveContext).getParentResourceProvider(); - Mockito.doReturn(mockParentContext).when(mockResolveContext).getParentResolveContext(); - - // Happy path → delegates to parent provider - Resource result = RelayResourceHelper.getResource( - mockResolveContext, - PATH_TARGET, - mockResourceContext, - null); + 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, result.getPath()); + 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()); + } - // Null context → null - assertNull(RelayResourceHelper.getResource(null, PATH_TARGET, mockResourceContext, null)); + @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); - // Null parent provider → null - ResolveContext noProviderContext = newMockResolveContext(); - Mockito.doReturn(null).when(noProviderContext).getParentResourceProvider(); - assertNull(RelayResourceHelper.getResource(noProviderContext, PATH_TARGET, mockResourceContext, null)); + 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 shouldDelegateListChildrenToParentProvider() { + public void shouldDelegateListChildrenToParent() { Resource parent = context.create().resource(PATH_TARGET); - Resource child = context.create().resource(PATH_TARGET + PATH_CHILD); + Resource expectedChild = context.create().resource(PATH_TARGET + PATH_CHILD); ResourceProvider mockProvider = newMockProvider(); - ResolveContext mockParentContext = newMockResolveContext(); - Mockito.when(mockProvider.listChildren(Mockito.any(), Mockito.eq(parent))) - .thenReturn(Collections.singletonList(child).iterator()); + ResolveContext parentCtx = newMockResolveContext(); + Mockito.when(mockProvider.listChildren(Mockito.same(parentCtx), Mockito.same(parent))) + .thenReturn(Collections.singletonList(expectedChild).iterator()); - ResolveContext mockResolveContext = newMockResolveContext(); - Mockito.doReturn(mockProvider).when(mockResolveContext).getParentResourceProvider(); - Mockito.doReturn(mockParentContext).when(mockResolveContext).getParentResolveContext(); + ResolveContext resolveContext = newMockResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); - // Happy path → delegates to parent provider - Iterator result = RelayResourceHelper.listChildren(mockResolveContext, parent); - assertNotNull(result); - assertTrue(result.hasNext()); - assertEquals(PATH_TARGET + PATH_CHILD, result.next().getPath()); - - // Null context → null - assertNull(RelayResourceHelper.listChildren(null, parent)); + Iterator children = RelayResourceHelper.listChildren(resolveContext, parent); - // Null parent provider → null - ResolveContext noProviderContext = newMockResolveContext(); - Mockito.doReturn(null).when(noProviderContext).getParentResourceProvider(); - assertNull(RelayResourceHelper.listChildren(noProviderContext, 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 shouldListChildrenFromTarget() { - Resource target = context.create().resource(PATH_TARGET); - context.create().resource(PATH_TARGET + PATH_CHILD); + public void shouldReturnNullChildrenWithoutContext() { + Resource parent = context.create().resource(PATH_TARGET); - // Non-RelayResource → children wrapped in RelayResource using the supplied exposed path - Iterator result = RelayResourceHelper.listChildren(target, PATH_EXPOSED); - assertNotNull(result); - assertTrue(result.hasNext()); - assertEquals(PATH_EXPOSED + PATH_CHILD, result.next().getPath()); - - // RelayResource → listChildren() passthrough; the path argument is ignored and the - // relay resource's own path is used when mapping child names - RelayResource relayTarget = new RelayResource(target, PATH_EXPOSED); - Iterator relayResult = RelayResourceHelper.listChildren(relayTarget, "/content/ignored"); - assertNotNull(relayResult); - assertTrue(relayResult.hasNext()); - assertEquals(PATH_EXPOSED + PATH_CHILD, relayResult.next().getPath()); + // 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)); } /* --------------- From 70201f007cb7e69e330b15d542d08a71806c9ce0 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 22:14:23 +0300 Subject: [PATCH 56/92] [EAK-651] Fixed ResourceResolver not reset to the default when empty user ID passed --- .../exadel/aem/toolkit/core/relay/services/PathSampler.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 index a859857b5..0392a6a17 100644 --- 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 @@ -168,10 +168,8 @@ private static List resolveXpath(ChangeSample sample, Session session) { * @return A {@code ResourceResolver} instance for the provided user ID, or the preexisting resolver if still valid */ private ResourceResolver rotateResolver(ResourceResolver existing, String userId) { - if (existing != null) { - boolean userIdMatches = StringUtils.isEmpty(userId) - || userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID)); - if (userIdMatches) { + if (existing != null && StringUtils.isNotEmpty(userId)) { + if (userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID))) { return existing; } } From a40db7f22c6f93d1ce219ddae7a34df83ac51e68 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 22:21:36 +0300 Subject: [PATCH 57/92] [EAK-651] Discard caching sampled paths --- .../core/relay/services/PathSampler.java | 13 ++----- .../core/relay/services/PathSamplerTest.java | 35 ------------------- 2 files changed, 2 insertions(+), 46 deletions(-) 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 index 0392a6a17..a89e10c38 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -55,8 +56,6 @@ class PathSampler { private String source; private String target; - private Collection paths; - /** * Default (instantiation-restricting) constructor */ @@ -68,15 +67,7 @@ private PathSampler() { * @return A non-null, possibly empty collection of {@code ResourceChange} instances */ Collection createChanges() { - if (paths != null) { - return paths - .stream() - .map(p -> RelayPathHelper.isSubpath(p, target) ? RelayPathHelper.replace(p, target, source) : p) - .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) - .collect(Collectors.toList()); - } - - paths = new HashSet<>(); + Set paths = new HashSet<>(); ResourceResolver resolver = null; try { 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 index 20af811ce..cd7a2da8c 100644 --- 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 @@ -13,7 +13,6 @@ */ package com.exadel.aem.toolkit.core.relay.services; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -135,40 +134,6 @@ public void shouldRewriteTargetPaths() { assertTrue(paths.contains("/content/unrelated/page")); } - /* -------------- - Result caching - -------------- */ - - @Test - public void shouldCacheResolvedPaths() { - // Wrap samples in a spy list so we can count iterations - List spySamples = Mockito.spy( - new ArrayList<>(Collections.singletonList(newChangeSample(PATH_TARGET + PATH_CHILD))) - ); - - PathSampler sampler = PathSampler - .builder() - .source(PATH_SOURCE) - .target(PATH_TARGET) - .samples(spySamples) - .build(); - - // First call — samples are iterated to build the path set - Collection firstResult = sampler.createChanges(); - assertEquals(1, firstResult.size()); - assertEquals(PATH_SOURCE + PATH_CHILD, firstResult.iterator().next().getPath()); - Mockito.verify(spySamples, Mockito.atLeastOnce()).iterator(); - - //noinspection unchecked - Mockito.clearInvocations(spySamples); - - // Second call — must return the cached result without re-iterating samples - Collection secondResult = sampler.createChanges(); - assertEquals(1, secondResult.size()); - assertEquals(PATH_SOURCE + PATH_CHILD, secondResult.iterator().next().getPath()); - Mockito.verify(spySamples, Mockito.never()).iterator(); - } - /* ---------------- XPath resolution ---------------- */ From f7e704c59bc5ac09a3d0f7e7b05428c5fdf20813 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 22:26:54 +0300 Subject: [PATCH 58/92] [EAK-651] Fix falling back to a pre-existent resolver in case of login exception --- .../toolkit/core/relay/services/PathSampler.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 index a89e10c38..419d4a8b3 100644 --- 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 @@ -156,13 +156,14 @@ private static List resolveXpath(ChangeSample sample, Session session) { * 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, or the preexisting resolver if still valid + * @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 && StringUtils.isNotEmpty(userId)) { - if (userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID))) { - return existing; - } + if (existing != null + && StringUtils.isNotEmpty(userId) + && userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID))) { + return existing; } try { ResourceResolver newResolver = ResolverUtil.newResolver(resolverFactory, userId); @@ -173,7 +174,7 @@ private ResourceResolver rotateResolver(ResourceResolver existing, String userId return newResolver; } catch (LoginException e) { LOG.error("Failed to create a resource resolver for {}", userId, e); - return existing; + return null; } } From 51fd1c47f4f59fe4b3b4d479342383a46e2e42bc Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 23:04:33 +0300 Subject: [PATCH 59/92] [EAK-651] Limit RelayMapping identity to the "from" value --- .../aem/toolkit/core/relay/models/RelayMapping.java | 12 +++++------- .../toolkit/core/relay/models/RelayMappingTest.java | 11 ++++------- 2 files changed, 9 insertions(+), 14 deletions(-) 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 index b2e294ded..eb8bc0fe6 100644 --- 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 @@ -20,14 +20,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; /** - * Represents a source-to-target mapping entry used for path or user identity translation. + * 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 static final int HASH_SEED = 31; - private final String from; private final String to; @@ -75,7 +75,7 @@ public final boolean equals(Object other) { return false; } RelayMapping mapping = (RelayMapping) other; - return Objects.equals(from, mapping.from) && Objects.equals(to, mapping.to); + return Objects.equals(from, mapping.from); } /** @@ -83,8 +83,6 @@ public final boolean equals(Object other) { */ @Override public int hashCode() { - int result = Objects.hashCode(from); - result = HASH_SEED * result + Objects.hashCode(to); - return result; + return Objects.hashCode(from); } } 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 index d9c2b3e32..0928a501f 100644 --- 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 @@ -13,11 +13,11 @@ */ 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.assertNotNull; import static org.junit.Assert.assertTrue; import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil; @@ -36,9 +36,8 @@ public void shouldReturnPropertiesAndValidity() { assertEquals(PATH_TARGET, valid.getTo()); assertTrue(valid.isValid()); - // isValid requires non-blank from and to - assertFalse(newMapping("", PATH_TARGET).isValid()); - assertFalse(newMapping(PATH_SOURCE, "").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, null).isValid()); } @@ -50,10 +49,8 @@ public void shouldBeEqualByBothFields() { assertEquals(a, b); assertEquals(a.hashCode(), b.hashCode()); - + assertEquals(a, newMapping(PATH_SOURCE, PATH_OTHER)); assertNotEquals(a, newMapping(PATH_OTHER, PATH_TARGET)); - assertNotEquals(a, newMapping(PATH_SOURCE, PATH_OTHER)); - assertNotNull(a); } private static RelayMapping newMapping(String from, String to) { From ceafd3232e7097b1f65239588f258299f82407a8 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 23:16:15 +0300 Subject: [PATCH 60/92] [EAK-651] Avoid ResourceResolver leaks; limit the number of ResourceResolver initializations --- .../core/relay/services/PathSampler.java | 18 +++++++++++------- .../aem/toolkit/core/utils/ResolverUtil.java | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) 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 index 419d4a8b3..1cb9752bb 100644 --- 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 @@ -160,21 +160,25 @@ private static List resolveXpath(ChangeSample sample, Session session) { * the current user, or {@code null} if the resolver cannot be created */ private ResourceResolver rotateResolver(ResourceResolver existing, String userId) { - if (existing != null - && StringUtils.isNotEmpty(userId) - && userId.equals(existing.getPropertyMap().get(PROPERTY_USER_ID))) { - return existing; + 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); - if (existing != null) { - existing.close(); - } return newResolver; } catch (LoginException e) { LOG.error("Failed to create a resource resolver for {}", userId, e); return null; + } finally { + if (existing != null) { + existing.close(); + } } } 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 index bd9faaf76..424f7169d 100644 --- 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 @@ -37,7 +37,7 @@ */ public class ResolverUtil { - private static final String SERVICE_USER_ID = "eak-service"; + public static final String SERVICE_USER_ID = "eak-service"; /** * Default (instantiation-restricting) constructor From 79082579d9dcfc4ebe5c9bdb07b7102e7c1a13a4 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 23:26:12 +0300 Subject: [PATCH 61/92] [EAK-651] Log duplicate path and user mappings --- .../toolkit/core/relay/services/RelayProviderHost.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index a0ea4d06d..02ee7ee4d 100644 --- 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 @@ -180,6 +180,10 @@ private static List parseConfig(RelayConfig config) { for (String mappingSource : ArrayUtils.nullToEmpty(config.pathMappings())) { RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); if (mapping != null && mapping.isValid()) { + if (pathMappings.contains(mapping)) { + LOG.warn("Skipping duplicate path mapping with source {} and target {}", mapping.getFrom(), mapping.getTo()); + continue; + } pathMappings.add(mapping); } } @@ -191,6 +195,10 @@ private static List parseConfig(RelayConfig config) { 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); } } From ade30c424879915ea205e1877f94c47c95f81387 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 23:28:41 +0300 Subject: [PATCH 62/92] [EAK-651] Add a check for non-null bundle context --- .../java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 424f7169d..706b03910 100644 --- 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 @@ -109,6 +109,8 @@ static ResourceResolver newResolver( .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<>(); From 1979b8a7ca65bc359b036e0c5d33c258b9d77e6c Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Mon, 4 May 2026 23:39:36 +0300 Subject: [PATCH 63/92] [EAK-651] Optimize path replacement logic in RelayPathHelper --- .../toolkit/core/relay/services/RelayProvider.java | 3 +-- .../toolkit/core/relay/utils/RelayPathHelper.java | 13 ++++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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 index ec1672ce4..3fa382581 100644 --- 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 @@ -20,7 +20,6 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -import org.apache.commons.lang3.StringUtils; import org.apache.sling.api.resource.Resource; import org.apache.sling.api.resource.ResourceResolverFactory; import org.apache.sling.api.resource.observation.ExternalResourceChangeListener; @@ -88,7 +87,7 @@ public Resource getResource( if (!RelayPathHelper.isSubpath(path, localRelay.getSource())) { return null; } - String targetPath = localRelay.getTarget() + StringUtils.substring(path, localRelay.getSource().length()); + String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), resolverFactory, 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 index 69a16dba2..c11b2b2b8 100644 --- 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 @@ -52,12 +52,15 @@ public static boolean isSubpath(String path, String root) { * @return A transformed path string */ public static String replace(String path, String source, String target) { - if (StringUtils.equals(path, source)) { - return target; + String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); + String normalizedSource = StringUtils.stripEnd(source, CoreConstants.SEPARATOR_SLASH); + String normalizedTarget = StringUtils.stripEnd(target, CoreConstants.SEPARATOR_SLASH); + if (StringUtils.equals(normalizedPath, normalizedSource)) { + return normalizedTarget; } - if (StringUtils.startsWith(path, source + CoreConstants.SEPARATOR_SLASH)) { - return target + path.substring(source.length()); + if (StringUtils.startsWith(normalizedPath, normalizedSource + CoreConstants.SEPARATOR_SLASH)) { + return normalizedTarget + normalizedPath.substring(normalizedSource.length()); } - return path; + return normalizedPath; } } From 7d5e1a1fd7333ae2ffe809c779575cb37ef3db5a Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 00:07:02 +0300 Subject: [PATCH 64/92] [EAK-651] Synchronize update method in RelayProvider; reset sampler on configuration change --- .../exadel/aem/toolkit/core/relay/services/RelayProvider.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 3fa382581..a34394636 100644 --- 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 @@ -66,8 +66,9 @@ class RelayProvider extends ResourceProvider implements ResourceChangeList * 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 */ - void update(RelayInfo model) { + synchronized void update(RelayInfo model) { this.relay = model; + this.sampler = null; // We will create a new sampler with the updated configuration when the provider is started } /* ------------------------ From 8ca2173bca600474d8be273573e9bfa9230640df Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 00:07:44 +0300 Subject: [PATCH 65/92] [EAK-651] Simplify resolution path assignment in RelayResource constructor --- .../exadel/aem/toolkit/core/relay/models/RelayResource.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index e7a62ae3e..083454a47 100644 --- 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 @@ -44,9 +44,7 @@ public RelayResource(@Nonnull Resource original, @Nonnull String path) { // because the {@code original} resource is already locked by Sling this.resourceMetadata = new ResourceMetadata(); this.resourceMetadata.putAll((ResourceMetadata) original.getResourceMetadata().clone()); - if (this.resourceMetadata.containsKey(ResourceMetadata.RESOLUTION_PATH)) { - this.resourceMetadata.put(ResourceMetadata.RESOLUTION_PATH, path); - } + this.resourceMetadata.put(ResourceMetadata.RESOLUTION_PATH, path); } /** From 44f393c22fe9a718e093549c3cf60c4c353c3950 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 00:11:39 +0300 Subject: [PATCH 66/92] [EAK-651] Prevent duplicate service reference release --- .../aem/toolkit/core/utils/ServiceUtil.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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 index b59a7e2ba..7217cfe7a 100644 --- 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 @@ -13,6 +13,7 @@ */ 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; @@ -129,11 +130,19 @@ public static void withService( LOG.debug(ERROR_RETRIEVAL, serviceClass.getName()); return; } + AtomicBoolean alreadyReleased = new AtomicBoolean(false); try { - consumer.accept(service, () -> ungetService(context, reference)); + consumer.accept( + service, + () -> { + alreadyReleased.set(true); + ungetService(context, reference); + }); } catch (RuntimeException e) { LOG.error(ERROR_RUNNING, serviceClass.getName(), e); - ungetService(context, reference); + if (!alreadyReleased.get()) { + ungetService(context, reference); + } } } catch (IllegalArgumentException | IllegalStateException e) { LOG.error(ERROR_HANDLING, serviceClass.getName(), e); @@ -238,11 +247,19 @@ public static U withService( LOG.debug(ERROR_RETRIEVAL, serviceClass.getName()); return defaultValue; } + AtomicBoolean alreadyReleased = new AtomicBoolean(false); try { - result = processor.apply(service, () -> ungetService(context, reference)); + result = processor.apply( + service, + () -> { + alreadyReleased.set(true); + ungetService(context, reference); + }); } catch (RuntimeException e) { LOG.error(ERROR_RUNNING, serviceClass.getName(), e); - ungetService(context, reference); + if (!alreadyReleased.get()) { + ungetService(context, reference); + } return defaultValue; } } catch (IllegalArgumentException | IllegalStateException e) { From 2aa114d7936f1a5963461d43bbb24ca7dce00bdf Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 00:27:25 +0300 Subject: [PATCH 67/92] [EAK-651] Register ExternalResourceChangeListener in RelayProviderHost --- .../aem/toolkit/core/relay/services/RelayProviderHost.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 02ee7ee4d..b8d38f3e2 100644 --- 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 @@ -28,6 +28,7 @@ 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; @@ -134,7 +135,10 @@ private void activate(BundleContext context, RelayConfig config) { 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()}, + new String[]{ + ResourceProvider.class.getName(), + ResourceChangeListener.class.getName(), + ExternalResourceChangeListener.class.getName()}, provider, properties); registrations.put(registration, provider); From e18652fff831813d83d8239cfd60a1cc59f4e59b Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 00:34:57 +0300 Subject: [PATCH 68/92] [EAK-651] Prevent overlap with remaining relays when processing a multi-relay config --- .../aem/toolkit/core/relay/services/RelayProviderHost.java | 2 ++ 1 file changed, 2 insertions(+) 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 index b8d38f3e2..f5aca4dc0 100644 --- 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 @@ -142,6 +142,8 @@ private void activate(BundleContext context, RelayConfig config) { 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(); From 390726f744ba7cb7293c3e98170e117f67a421e6 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 00:34:57 +0300 Subject: [PATCH 69/92] [EAK-651] Prevent overlap with remaining relays when processing a multi-relay config --- .../aem/toolkit/core/relay/services/RelayProviderHost.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index b8d38f3e2..cf83fd6bd 100644 --- 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 @@ -142,6 +142,8 @@ private void activate(BundleContext context, RelayConfig config) { 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(); @@ -227,6 +229,7 @@ private static List parseConfig(RelayConfig config) { * @return A map of JCR path-to-provider name entries; might be empty but never null */ private static Map getExternallyProvidedPaths(BundleContext context) { + Map result = new HashMap<>(); ServiceReference[] serviceReferences = null; try { serviceReferences = context.getServiceReferences(ResourceProvider.class.getName(), null); @@ -234,9 +237,8 @@ private static Map getExternallyProvidedPaths(BundleContext cont LOG.warn("Could not collect info on predefined resource providers", e); } if (serviceReferences == null) { - return Collections.emptyMap(); + return result; } - Map result = new HashMap<>(); for (ServiceReference ref : serviceReferences) { Object providedPath = ref.getProperty(ResourceProvider.PROPERTY_ROOT); Object providerId = ref.getProperty(ResourceProvider.PROPERTY_NAME); From 2b8c51c6079d118311eba4c51aef80121d2bcf0b Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 01:08:37 +0300 Subject: [PATCH 70/92] [EAK-651] Fix sampling consistency when a replay provider's config updated without re-registration --- .../core/relay/services/RelayProvider.java | 67 +++++++++++++++---- .../relay/services/RelayProviderHost.java | 5 +- 2 files changed, 59 insertions(+), 13 deletions(-) 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 index a34394636..e6ec02013 100644 --- 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 @@ -61,14 +61,24 @@ class RelayProvider extends ResourceProvider implements ResourceChangeList 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) { - this.relay = model; - this.sampler = null; // We will create a new sampler with the updated configuration when the provider is started + relay = model; + sampler = PathSampler + .builder() + .resolverFactory(resolverFactory) + .source(model.getSource()) + .target(model.getTarget()) + .samples(model.getChangeSamples()) + .build(); } /* ------------------------ @@ -144,11 +154,7 @@ public void start(@Nonnull ProviderContext providerContext) { .target(localRelay.getTarget()) .samples(localRelay.getChangeSamples()) .build(); - - Collection changes = sampler.createChanges(); - if (!changes.isEmpty()) { - providerContext.getObservationReporter().reportChanges(changes, false); - } + announce(sampler, providerContext); } /** @@ -158,11 +164,8 @@ public void start(@Nonnull ProviderContext providerContext) { 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 (getProviderContext() != null && sampler != null) { - Collection declaredChanges = sampler.createChanges(); - if (!declaredChanges.isEmpty()) { - getProviderContext().getObservationReporter().reportChanges(declaredChanges, false); - } + if (sampler != null && getProviderContext() != null) { + announce(sampler, getProviderContext()); } sampler = null; super.stop(); @@ -189,5 +192,45 @@ public void onChange(@Nonnull List changes) { .collect(Collectors.toList()); getProviderContext().getObservationReporter().reportChanges(mappedChanges, false); } + + /* ------------- + Announcements + ------------- */ + + /** + * Announces the configured change samples as resource changes, generally to indicate that the relay is starting and + * trigger any necessary updates in the system + */ + void announceStart() { + PathSampler localSampler = sampler; // Use a local copy to avoid potential race conditions + if (localSampler != null && getProviderContext() != null) { + announce(sampler, getProviderContext()); + } + } + + /** + * Announces the configured change samples as resource changes, generally to indicate that the relay is stopping and + * trigger any necessary updates in the system + */ + void announceStop() { + PathSampler localSampler = sampler; + if (localSampler != null && getProviderContext() != null) { + announce(sampler, 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 index cf83fd6bd..4f14e0a0c 100644 --- 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 @@ -95,7 +95,10 @@ private void activate(BundleContext context, RelayConfig config) { String[] existingObservedPaths = (String[]) registration.getReference().getProperty(ResourceChangeListener.PATHS); String existingTarget = ArrayUtils.isNotEmpty(existingObservedPaths) ? existingObservedPaths[0] : null; if (StringUtils.equals(existingTarget, matchingRelay.getTarget())) { - registrations.get(registration).update(matchingRelay); + RelayProvider provider = registrations.get(registration); + provider.announceStop(); + provider.update(matchingRelay); + provider.announceStart(); relays.remove(matchingRelay); } else { shouldUnregister = true; From 682f0a0f06b352e27841ebdf68c1623275c02eac Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 01:51:38 +0300 Subject: [PATCH 71/92] [EAK-651] Develop test coverage for RelayProviderHost --- .../relay/services/RelayProviderHost.java | 5 +- .../relay/services/RelayProviderHostTest.java | 108 ++++++++++++++++-- 2 files changed, 101 insertions(+), 12 deletions(-) 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 index 4f14e0a0c..378cf30f7 100644 --- 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 @@ -14,11 +14,13 @@ 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; @@ -185,7 +187,7 @@ private static List parseConfig(RelayConfig config) { return Collections.emptyList(); } - Set pathMappings = new HashSet<>(); + Set pathMappings = new LinkedHashSet<>(); for (String mappingSource : ArrayUtils.nullToEmpty(config.pathMappings())) { RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); if (mapping != null && mapping.isValid()) { @@ -221,6 +223,7 @@ private static List parseConfig(RelayConfig config) { } return pathMappings.stream() + .sorted(Comparator.comparingInt(m -> m.getFrom().length())) .map(m -> new RelayInfo(m, userMappings, samples)) .collect(Collectors.toList()); } 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 index b1d0b1d7a..f73c796c0 100644 --- 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 @@ -21,6 +21,7 @@ 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; @@ -38,10 +39,20 @@ 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_B = "{\"from\":\"/content/source2\",\"to\":\"/content/target2\"}"; private static final String PATH_MAPPING_MISSING_TO = "{\"from\":\"/content/source\"}"; + private static final String PATH_MAPPING_A_DUP_SOURCE = "{\"from\":\"/content/source\",\"to\":\"/content/other\"}"; + + 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(); @@ -50,6 +61,27 @@ public class RelayProviderHostTest { 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 @@ -66,26 +98,56 @@ public void shouldNotRegisterForInvalidConfig() throws InvalidSyntaxException { } @Test - public void shouldRegisterForEachValidMapping() throws InvalidSyntaxException { + public void shouldSkipDuplicatePathMappings() throws InvalidSyntaxException { + // Blank entry → null mapping → skipped; second mapping with same source → skipped context.registerInjectActivateService( new RelayProviderHost(), - newProps(true, PATH_MAPPING_A, PATH_MAPPING_B)); - - assertEquals(2, countRegisteredProviders()); + newProps(true, StringUtils.EMPTY, PATH_MAPPING_A, PATH_MAPPING_A_DUP_SOURCE)); + assertEquals(1, countRegisteredProviders()); } @Test public void shouldSkipShadowedPath() throws InvalidSyntaxException { - Dictionary extProps = new Hashtable<>(); - extProps.put(ResourceProvider.PROPERTY_ROOT, "/content/source"); - extProps.put(ResourceProvider.PROPERTY_NAME, "external-provider"); - context.bundleContext().registerService(ResourceProvider.class.getName(), new StubResourceProvider(), extProps); + 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 --------------------- */ @@ -104,18 +166,18 @@ public void shouldUpdateProviderOnReactivate() throws InvalidSyntaxException { } @Test - public void shouldReregisterWhenTargetChangesOnReactivate() throws InvalidSyntaxException { + public void shouldReregisterWhenTargetChanges() throws InvalidSyntaxException { RelayProviderHost host = context.registerInjectActivateService( new RelayProviderHost(), newProps(true, PATH_MAPPING_A)); assertEquals(1, countRegisteredProviders()); - assertEquals("/content/target", getRegisteredTarget("/content/source")); + 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("/content/source")); + assertEquals("/content/target-updated", getRegisteredTarget(PATH_SOURCE)); } @Test @@ -189,6 +251,17 @@ private String getRegisteredTarget(String sourcePath) throws InvalidSyntaxExcept 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); @@ -196,6 +269,19 @@ private static Map newProps(boolean enabled, String... pathMappi 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 From d1860d99faa6dd98a8729a15778f665cafc7cebe Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 01:52:13 +0300 Subject: [PATCH 72/92] [EAK-651] Fix the risk of re-entry cycle in case of incorrect mapping --- .../exadel/aem/toolkit/core/relay/services/RelayProvider.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index e6ec02013..6644ac3ca 100644 --- 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 @@ -99,6 +99,10 @@ public Resource getResource( return null; } String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); + // Break the re-entry cycle: if targetPath is still under source, resolve via parent provider only + if (RelayPathHelper.isSubpath(targetPath, localRelay.getSource())) { + return RelayResourceHelper.getResource(context, targetPath, resourceContext, parent); + } Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), resolverFactory, From 057725d8fb045f707e70864d0607e83a5bd8258c Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 01:52:33 +0300 Subject: [PATCH 73/92] [EAK-651] Add missing overridden methods in RelayResource --- .../core/relay/models/RelayResource.java | 40 ++++++++++++++- .../core/relay/models/RelayResourceTest.java | 50 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) 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 index 083454a47..3f91e0a91 100644 --- 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 @@ -16,10 +16,12 @@ 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; /** @@ -39,12 +41,46 @@ public class RelayResource extends ResourceWrapper { */ public RelayResource(@Nonnull Resource original, @Nonnull String path) { super(original); - this.path = path; + this.path = StringUtils.stripEnd(path, 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, path); + 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() { + String parentPath = StringUtils.contains(path, CoreConstants.SEPARATOR_SLASH) + ? StringUtils.substringBeforeLast(path, CoreConstants.SEPARATOR_SLASH) + : null; + if (StringUtils.isBlank(parentPath)) { + return null; + } + return getResourceResolver().getResource(parentPath); } /** 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 index 1eb41920a..ab0dc8b9c 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -34,6 +35,7 @@ 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(); @@ -49,6 +51,54 @@ public void shouldCopyAndOverrideProperties() { 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()); + + // Top-level path: substringBeforeLast("/top", "/") is blank → null + assertNull(new RelayResource(original, "/top").getParent()); + + // Path with no slash: no parent branch → null + assertNull(new RelayResource(original, PATH_NO_SLASH).getParent()); + + // Parent /missing does not exist in resolver → null + 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); From 9001e2816721a252a56c3f66a9b878808fe66702 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 01:57:03 +0300 Subject: [PATCH 74/92] [EAK-651] Remove legacy configuration for maven-surefire-plugin --- plugin/pom.xml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugin/pom.xml b/plugin/pom.xml index 330c0f1f5..c6d8008f4 100644 --- a/plugin/pom.xml +++ b/plugin/pom.xml @@ -40,11 +40,6 @@ org.apache.maven.plugins maven-surefire-plugin - - - **/AllTests.class - - From 78d1a52542c84aa755c56d1241ea125911516f4c Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 08:41:46 +0300 Subject: [PATCH 75/92] [EAK-651] Improve null handling for top-level paths --- .../aem/toolkit/core/relay/models/RelayResource.java | 9 +++++---- .../aem/toolkit/core/relay/models/RelayResourceTest.java | 7 +------ 2 files changed, 6 insertions(+), 10 deletions(-) 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 index 3f91e0a91..77b4418ab 100644 --- 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 @@ -74,12 +74,13 @@ public String getName() { */ @Override public Resource getParent() { - String parentPath = StringUtils.contains(path, CoreConstants.SEPARATOR_SLASH) - ? StringUtils.substringBeforeLast(path, CoreConstants.SEPARATOR_SLASH) - : null; - if (StringUtils.isBlank(parentPath)) { + 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); } 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 index ab0dc8b9c..b5d577e9d 100644 --- 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 @@ -71,13 +71,8 @@ public void shouldGetParent() { assertNotNull(parent); assertEquals(pathRelayParent, parent.getPath()); - // Top-level path: substringBeforeLast("/top", "/") is blank → null - assertNull(new RelayResource(original, "/top").getParent()); - - // Path with no slash: no parent branch → null + assertNotNull(new RelayResource(original, "/top").getParent()); assertNull(new RelayResource(original, PATH_NO_SLASH).getParent()); - - // Parent /missing does not exist in resolver → null assertNull(new RelayResource(original, "/missing/item").getParent()); } From 500d199ded52dce691d9968ee45863a5ed72f1c7 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 08:43:24 +0300 Subject: [PATCH 76/92] [EAK-651] Use an empty context instead of null --- .../exadel/aem/toolkit/core/relay/services/RelayProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6644ac3ca..66adf55ad 100644 --- 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 @@ -131,7 +131,7 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N if (targetResource != null) { targetResource = new RelayResource(targetResource, path); } else { - targetResource = RelayResourceHelper.getResource(context, path, null, parent); + targetResource = RelayResourceHelper.getResource(context, path, ResourceContext.EMPTY_CONTEXT, parent); } if (targetResource == null) { return null; From 7ec3c7061a6efae1dd952b67521880701fb4c0e7 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 09:03:40 +0300 Subject: [PATCH 77/92] [EAK-651] Improve re-entry cycle breaker --- .../core/relay/services/RelayProvider.java | 5 +++-- .../relay/services/RelayProviderTest.java | 22 +++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) 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 index 66adf55ad..b30f993b3 100644 --- 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 @@ -99,9 +99,10 @@ public Resource getResource( return null; } String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); - // Break the re-entry cycle: if targetPath is still under source, resolve via parent provider only + // Break the re-entry cycle: if targetPath is still under source, resolve via parent provider if (RelayPathHelper.isSubpath(targetPath, localRelay.getSource())) { - return RelayResourceHelper.getResource(context, targetPath, resourceContext, parent); + Resource resolved = RelayResourceHelper.getResource(context, targetPath, resourceContext, parent); + return resolved != null ? new RelayResource(resolved, path) : null; } Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), 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 index 74c47a6b4..02c99a19a 100644 --- 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 @@ -200,7 +200,7 @@ public void shouldDelegateToParentProvider() { ResolveContext parentCtx = newMockResolveContext(); Resource fallbackResource = context.create().resource("/content/fallback"); Mockito - .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE), Mockito.isNull(), Mockito.any())) + .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE), Mockito.any(), Mockito.any())) .thenReturn(fallbackResource); Resource parent = context.create().resource(PATH_SOURCE); @@ -604,6 +604,11 @@ private static RelayProvider newProvider(ResourceResolverFactory factory, RelayI 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()); } @@ -640,22 +645,17 @@ private static ResolveContext newResolveContext(ResourceResolver 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 ResourceProvider newMockProvider() { - return Mockito.mock(ResourceProvider.class); - } - - @SuppressWarnings("unchecked") - private static ResolveContext newMockResolveContext() { - return Mockito.mock(ResolveContext.class); - } - @SuppressWarnings("unchecked") private static ArgumentCaptor> changeCaptor() { return (ArgumentCaptor>) (ArgumentCaptor) ArgumentCaptor.forClass(Collection.class); From a88375e31e4f771c50bf1754bc4a49a0490465bd Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 09:51:38 +0300 Subject: [PATCH 78/92] [EAK-651] Rename method for clarity; add a test case --- .../core/relay/services/PathSampler.java | 2 +- .../core/relay/services/RelayProvider.java | 4 ++-- .../core/relay/services/RelayProviderHost.java | 6 +++--- .../core/relay/utils/RelayPathHelper.java | 2 +- .../core/relay/utils/RelayPathHelperTest.java | 17 +++++++++++++++++ 5 files changed, 24 insertions(+), 7 deletions(-) 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 index 1cb9752bb..2fbf6c1c2 100644 --- 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 @@ -96,7 +96,7 @@ Collection createChanges() { // All resolved paths must point to source instead of target return paths .stream() - .map(p -> RelayPathHelper.isSubpath(p, target) ? RelayPathHelper.replace(p, target, source) : p) + .map(p -> RelayPathHelper.isSamePathOrSubpath(p, target) ? RelayPathHelper.replace(p, target, source) : p) .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false)) .collect(Collectors.toList()); } 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 index b30f993b3..0438d6df9 100644 --- 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 @@ -95,12 +95,12 @@ public Resource getResource( @Nonnull ResourceContext resourceContext, @Nullable Resource parent) { RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method - if (!RelayPathHelper.isSubpath(path, localRelay.getSource())) { + if (!RelayPathHelper.isSamePathOrSubpath(path, localRelay.getSource())) { return null; } String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); // Break the re-entry cycle: if targetPath is still under source, resolve via parent provider - if (RelayPathHelper.isSubpath(targetPath, localRelay.getSource())) { + if (RelayPathHelper.isSamePathOrSubpath(targetPath, localRelay.getSource())) { Resource resolved = RelayResourceHelper.getResource(context, targetPath, resourceContext, parent); return resolved != null ? new RelayResource(resolved, path) : null; } 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 index 378cf30f7..4c664af8e 100644 --- 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 @@ -122,10 +122,10 @@ private void activate(BundleContext context, RelayConfig config) { } // Register providers for the remaining new relays that didn't match any existing ones - Map providedPaths = getExternallyProvidedPaths(context); + Map providedPaths = getAlreadyProvidedPaths(context); for (RelayInfo relay : relays) { String shadowedEntries = providedPaths.entrySet().stream() - .filter(e -> RelayPathHelper.isSubpath(e.getKey(), relay.getSource())) + .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()) { @@ -234,7 +234,7 @@ private static List parseConfig(RelayConfig config) { * @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 getExternallyProvidedPaths(BundleContext context) { + private static Map getAlreadyProvidedPaths(BundleContext context) { Map result = new HashMap<>(); ServiceReference[] serviceReferences = null; try { 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 index c11b2b2b8..cad8d1e41 100644 --- 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 @@ -36,7 +36,7 @@ private RelayPathHelper() { * @param root Root JCR path to check against * @return True or false */ - public static boolean isSubpath(String path, String root) { + public static boolean isSamePathOrSubpath(String path, String root) { String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); String normalizedRoot = StringUtils.stripEnd(StringUtils.defaultString(root), CoreConstants.SEPARATOR_SLASH); return StringUtils.equals(normalizedPath, normalizedRoot) 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 index 2266ae251..6f7b9ef8c 100644 --- 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 @@ -13,15 +13,32 @@ */ 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)); From f62567f2f36fbea8a54e776856b32186fae73cb0 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 10:11:32 +0300 Subject: [PATCH 79/92] [EAK-651] Change callback type from AutoCloseable to Closeable in ResolverUtil --- .../java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 706b03910..d23f77a2a 100644 --- 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 @@ -13,6 +13,7 @@ */ 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; @@ -125,7 +126,7 @@ static ResourceResolver newResolver( // 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", (AutoCloseable) callback::run); + resolver.getPropertyMap().put("unget", (Closeable) callback::run); return resolver; } catch (LoginException e) { nestedException.set(e); From 45d08058172e662d30cb6b315b62bcaf8d06cef7 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 10:38:03 +0300 Subject: [PATCH 80/92] [EAK-651] Add a re-entry guard for listChildren calls --- .../core/relay/services/RelayProvider.java | 10 ++ .../relay/services/RelayProviderTest.java | 121 +++++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) 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 index 0438d6df9..cd26d7c21 100644 --- 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 @@ -124,6 +124,16 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N 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()); + // Re-entry guard: same as getResource() — if targetPath is still under source, + // resolve via the parent provider to break the cycle + if (RelayPathHelper.isSamePathOrSubpath(targetPath, localRelay.getSource())) { + Resource targetResource = RelayResourceHelper.getResource(context, targetPath, ResourceContext.EMPTY_CONTEXT, parent); + if (targetResource == null) { + return null; + } + return RelayResourceHelper.listChildren(targetResource, path); + } + Resource targetResource = RelayResourceHelper.getResource( context.getResourceResolver(), resolverFactory, 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 index 02c99a19a..e3526a738 100644 --- 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 @@ -114,7 +114,8 @@ 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())) + Mockito + .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE + PATH_CHILD_A), Mockito.any(), Mockito.any())) .thenReturn(fallbackResource); ResolveContext resolveContext = newResolveContext(); @@ -142,6 +143,63 @@ public void shouldReturnNullWhenTargetAndParentMiss() { assertNull(result); } + @Test + public void shouldResolveNestedRelayViaParentProvider() { + // Nested relay: target is under source (e.g. /content/source → /content/source/alias). + // getResource() must use the re-entry guard and resolve targetPath through the parent provider + // to avoid re-entering the relay, and wrap the result as a RelayResource at the original path. + String nestedTarget = PATH_SOURCE + "/alias"; + String requestPath = PATH_SOURCE + PATH_CHILD_A; + String targetPath = nestedTarget + PATH_CHILD_A; + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + + Resource actualResource = context.create().resource(targetPath); + Mockito.when(mockProvider.getResource(Mockito.any(), Mockito.eq(targetPath), Mockito.any(), Mockito.any())) + .thenReturn(actualResource); + + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, requestPath, resourceContext, null); + + assertNotNull(result); + assertTrue(result instanceof RelayResource); + assertEquals(requestPath, result.getPath()); + Mockito.verify(mockProvider).getResource(Mockito.any(), Mockito.eq(targetPath), Mockito.any(), Mockito.any()); + } + + @Test + public void shouldReturnNullForNestedRelayWhenParentMisses() { + // Nested relay: when the parent provider cannot resolve the remapped target, + // getResource() must return null rather than falling through to the full resolver. + String nestedTarget = PATH_SOURCE + "/alias"; + String requestPath = PATH_SOURCE + PATH_CHILD_A; + String targetPath = nestedTarget + PATH_CHILD_A; + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + + Mockito.when(mockProvider.getResource(Mockito.any(), Mockito.eq(targetPath), Mockito.any(), Mockito.any())) + .thenReturn(null); + + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); + ResourceContext resourceContext = Mockito.mock(ResourceContext.class); + + Resource result = provider.getResource(resolveContext, requestPath, resourceContext, null); + + assertNull(result); + } + /* -------------- listChildren() -------------- */ @@ -194,6 +252,67 @@ public void shouldReturnNullWhenTargetNotFound() { assertNull(result); } + @Test + public void shouldListChildrenViaParentProvider() { + // Nested relay: target is under source (e.g. /content/source → /content/source/alias). + // listChildren() must use the re-entry guard and resolve the target resource through the parent + // provider instead of the full resource resolver to avoid infinite remapping. + String nestedTarget = PATH_SOURCE + "/alias"; + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + + Resource parent = context.create().resource(PATH_SOURCE); + Resource aliasResource = context.create().resource(nestedTarget); + Resource childResource = context.create().resource(nestedTarget + PATH_CHILD_A); + + Mockito.when(mockProvider.getResource( + Mockito.any(), Mockito.eq(nestedTarget), Mockito.any(), Mockito.any())) + .thenReturn(aliasResource); + Mockito.when(mockProvider.listChildren(Mockito.any(), Mockito.any())) + .thenReturn(Collections.singletonList(childResource).iterator()); + + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); + + 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()); + } + + @Test + public void shouldReturnNullForNestedRelayWhenTargetNotFound() { + // Nested relay: target is under source. When the parent provider cannot resolve the target, + // listChildren() must return null (no fallback that would re-enter the relay). + String nestedTarget = PATH_SOURCE + "/alias"; + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + + Mockito.when(mockProvider.getResource( + Mockito.any(), Mockito.eq(nestedTarget), Mockito.any(), Mockito.any())) + .thenReturn(null); + + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); + Resource parent = context.create().resource(PATH_SOURCE + "/alias").getParent(); + + Iterator result = provider.listChildren(resolveContext, parent); + + assertNull(result); + } + @Test public void shouldDelegateToParentProvider() { ResourceProvider mockProvider = newMockProvider(); From e16f1c44a6c4e256b9220a94e6b99d04062a5e8d Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 10:46:27 +0300 Subject: [PATCH 81/92] [Tech] Add null check for bundle context in ConfigDefinition --- .../core/configurator/models/internal/ConfigDefinition.java | 4 ++++ 1 file changed, 4 insertions(+) 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 562b062eb..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 @@ -294,6 +294,10 @@ public static ConfigDefinition from(HttpServletRequest request) { * does not exist */ private static ConfigDefinition from(String pid, BundleContext context) { + if (context == null) { + LOG.error("Cannot retrieve configuration for {}: no bundle context available", pid); + return EMPTY; + } Configuration configuration = ServiceUtil.withService( ConfigurationAdmin.class, context, From e99706b7df3184f12d4f51c1297ed49ddf3409a5 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 11:15:17 +0300 Subject: [PATCH 82/92] [EAK-651] Simplify announce logic --- .../core/relay/services/RelayProvider.java | 19 ++++--------------- .../relay/services/RelayProviderHost.java | 4 ++-- 2 files changed, 6 insertions(+), 17 deletions(-) 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 index cd26d7c21..bcec398de 100644 --- 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 @@ -213,24 +213,13 @@ public void onChange(@Nonnull List changes) { ------------- */ /** - * Announces the configured change samples as resource changes, generally to indicate that the relay is starting and - * trigger any necessary updates in the system + * 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 announceStart() { - PathSampler localSampler = sampler; // Use a local copy to avoid potential race conditions - if (localSampler != null && getProviderContext() != null) { - announce(sampler, getProviderContext()); - } - } - - /** - * Announces the configured change samples as resource changes, generally to indicate that the relay is stopping and - * trigger any necessary updates in the system - */ - void announceStop() { + void announce() { PathSampler localSampler = sampler; if (localSampler != null && getProviderContext() != null) { - announce(sampler, getProviderContext()); + announce(localSampler, getProviderContext()); } } 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 index 4c664af8e..91137d3d5 100644 --- 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 @@ -98,9 +98,9 @@ private void activate(BundleContext context, RelayConfig config) { String existingTarget = ArrayUtils.isNotEmpty(existingObservedPaths) ? existingObservedPaths[0] : null; if (StringUtils.equals(existingTarget, matchingRelay.getTarget())) { RelayProvider provider = registrations.get(registration); - provider.announceStop(); + provider.announce(); provider.update(matchingRelay); - provider.announceStart(); + provider.announce(); relays.remove(matchingRelay); } else { shouldUnregister = true; From b93e6d321117eb085643832e768ac4e59333c001 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 12:18:17 +0300 Subject: [PATCH 83/92] [EAK-651] Disable falling back to default resolver when user mapping failed --- .../core/relay/utils/RelayResourceHelper.java | 7 +++++-- .../core/relay/models/RelayMappingTest.java | 2 +- .../core/relay/services/RelayProviderTest.java | 14 +++++--------- .../core/relay/utils/RelayResourceHelperTest.java | 1 - 4 files changed, 11 insertions(+), 13 deletions(-) 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 index 63ec5f7bf..ee2aa9916 100644 --- 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 @@ -82,13 +82,16 @@ public static Resource getResource( }); 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 resolve {} with user {}", path, effectiveResolver.getUserID()); + LOG.debug("Could not retrieve {} with user {}", path, effectiveResolver.getUserID()); } else { - LOG.debug("Resolved {} to {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); + LOG.debug("Retrieved {} -> {} with user {}", path, result.getPath(), effectiveResolver.getUserID()); } return result; } 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 index 0928a501f..db0eb5b7f 100644 --- 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 @@ -43,7 +43,7 @@ public void shouldReturnPropertiesAndValidity() { } @Test - public void shouldBeEqualByBothFields() { + public void shouldCheckEquality() { RelayMapping a = newMapping(PATH_SOURCE, PATH_TARGET); RelayMapping b = newMapping(PATH_SOURCE, PATH_TARGET); 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 index e3526a738..0f4cae62d 100644 --- 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 @@ -679,11 +679,10 @@ public void shouldUseOriginalResolver() { } @Test - public void shouldFallBackToOriginalResolver() throws LoginException { - String brokenService = "broken-service"; + public void shouldReturnNullForInvalidMapping() throws LoginException { RelayInfo relay = newRelayInfo( PATH_SOURCE, PATH_TARGET, - Collections.singletonList(newMapping(USER_AUTHOR, brokenService)), + Collections.singletonList(newMapping(USER_AUTHOR, "broken-service")), Collections.emptyList()); Resource mockTargetResource = Mockito.mock(Resource.class); @@ -691,7 +690,8 @@ public void shouldFallBackToOriginalResolver() throws LoginException { Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); - Mockito.when(factory.getServiceResourceResolver(Mockito.any())) + Mockito + .when(factory.getServiceResourceResolver(Mockito.any())) .thenThrow(new LoginException("No service user")); ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); @@ -704,11 +704,7 @@ public void shouldFallBackToOriginalResolver() throws LoginException { RelayProvider provider = newProvider(factory, relay); Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); - // LoginException is caught and the original resolver is used as fallback - assertNotNull(result); - assertTrue(result instanceof RelayResource); - assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); - Mockito.verify(basicResolver).getResource(PATH_TARGET + PATH_CHILD_A); + assertNull(result); } /* --------------- 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 index 2ae60fbc0..73687d22b 100644 --- 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 @@ -120,7 +120,6 @@ public void shouldStoreSentinelWhenLoginFails() throws LoginException { Object stored = propertyMap.get(ResourceResolver.class.getName() + "@blocked-user"); assertNotNull(stored); assertFalse(stored instanceof ResourceResolver); // sentinel, not a resolver - Mockito.verify(resolver).getResource(PATH_TARGET); // original resolver was used Mockito.verify(resolverFactory, Mockito.times(1)).getServiceResourceResolver(Mockito.any()); // Second call with same userId: sentinel already in map, factory not called again From 8c84a552cddd34d4d3719fbed45bfbc66ba568fa Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 12:29:15 +0300 Subject: [PATCH 84/92] [EAK-651] Disable parent resource hint for re-entry breaker --- .../exadel/aem/toolkit/core/relay/services/RelayProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index bcec398de..58476947e 100644 --- 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 @@ -101,7 +101,7 @@ public Resource getResource( String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); // Break the re-entry cycle: if targetPath is still under source, resolve via parent provider if (RelayPathHelper.isSamePathOrSubpath(targetPath, localRelay.getSource())) { - Resource resolved = RelayResourceHelper.getResource(context, targetPath, resourceContext, parent); + Resource resolved = RelayResourceHelper.getResource(context, targetPath, resourceContext, null); return resolved != null ? new RelayResource(resolved, path) : null; } Resource resolved = RelayResourceHelper.getResource( From be6154ab728319261047e31de7cb7ead89d73a8a Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 13:03:50 +0300 Subject: [PATCH 85/92] [EAK-651] Code formatting; renaming --- .../core/relay/services/RelayProvider.java | 22 +++++++++---------- .../relay/services/RelayProviderTest.java | 4 ---- 2 files changed, 11 insertions(+), 15 deletions(-) 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 index 58476947e..0090f0446 100644 --- 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 @@ -127,31 +127,31 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N // Re-entry guard: same as getResource() — if targetPath is still under source, // resolve via the parent provider to break the cycle if (RelayPathHelper.isSamePathOrSubpath(targetPath, localRelay.getSource())) { - Resource targetResource = RelayResourceHelper.getResource(context, targetPath, ResourceContext.EMPTY_CONTEXT, parent); - if (targetResource == null) { + Resource resolved = RelayResourceHelper.getResource(context, targetPath, ResourceContext.EMPTY_CONTEXT, parent); + if (resolved == null) { return null; } - return RelayResourceHelper.listChildren(targetResource, path); + return RelayResourceHelper.listChildren(resolved, path); } - Resource targetResource = RelayResourceHelper.getResource( + Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), resolverFactory, localRelay.getUserMapping(context.getResourceResolver().getUserID()), targetPath); - if (targetResource != null) { - targetResource = new RelayResource(targetResource, path); + if (resolved != null) { + resolved = new RelayResource(resolved, path); } else { - targetResource = RelayResourceHelper.getResource(context, path, ResourceContext.EMPTY_CONTEXT, parent); + resolved = RelayResourceHelper.getResource(context, path, ResourceContext.EMPTY_CONTEXT, parent); } - if (targetResource == null) { + if (resolved == null) { return null; - } else if (!(targetResource instanceof RelayResource)) { + } 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, targetResource); + return RelayResourceHelper.listChildren(context, resolved); } // This method call exerts the path mapping logic for the children of the target resource - return RelayResourceHelper.listChildren(targetResource, parent.getPath()); + return RelayResourceHelper.listChildren(resolved, parent.getPath()); } /** 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 index 0f4cae62d..ee7a0557d 100644 --- 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 @@ -105,10 +105,6 @@ public void shouldReturnNullWhenPathNotUnderSource() { assertNull(result); } - /* --------------------------------------- - getResource(): parent provider fallback - --------------------------------------- */ - @Test public void shouldFallBackToParentProvider() { ResourceProvider mockProvider = newMockProvider(); From 7fa9bf48c78d15291d57860d4f2099af289a0778 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 13:04:12 +0300 Subject: [PATCH 86/92] [EAK-651] Retire redundant setUp/tearDown cycle --- .../aem/toolkit/plugin/maven/PluginContextRenderingRule.java | 3 --- 1 file changed, 3 deletions(-) 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 499dc02e4..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 @@ -78,7 +78,6 @@ public void test(Class component, Path sampleFilesPath, Consumer } private void test(Class component, String createdFilesPath, Path sampleFilesPath, Consumer preparation) { - setUp(); if (preparation != null) { preparation.accept(fileSystem); } @@ -96,8 +95,6 @@ private void test(Class component, String createdFilesPath, Path sampleFilesP LOG.error(INSTANTIATION_EXCEPTION_MESSAGE, component.getName(), cnfEx); } catch (IOException ioEx) { LOG.error(CLEANUP_EXCEPTION_MESSAGE, component.getName(), ioEx); - } finally { - tearDown(); } } From f2be0b8baa7c11a1253b33247a1dca0ebb922343 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 13:46:32 +0300 Subject: [PATCH 87/92] [EAK-651] Invalidate nested mappings --- .../toolkit/core/relay/models/RelayInfo.java | 4 +- .../core/relay/models/RelayMapping.java | 6 +- .../core/relay/services/RelayProvider.java | 15 --- .../core/relay/models/RelayMappingTest.java | 11 +- .../relay/services/RelayProviderHostTest.java | 14 ++- .../relay/services/RelayProviderTest.java | 118 ------------------ 6 files changed, 26 insertions(+), 142 deletions(-) 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 index 686bf0293..08f1d824a 100644 --- 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 @@ -21,7 +21,9 @@ import org.apache.commons.lang3.StringUtils; /** - * Represents the set of data required to configure and operate a {@code RelayProvider}. + * 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

*/ 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 index eb8bc0fe6..9e4225dac 100644 --- 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 @@ -19,6 +19,8 @@ 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 @@ -63,7 +65,9 @@ public String getTo() { * @return True or false */ public boolean isValid() { - return StringUtils.isNoneBlank(getFrom(), getTo()); + return StringUtils.isNoneBlank(getFrom(), getTo()) + && !RelayPathHelper.isSamePathOrSubpath(getFrom(), getTo()) + && !RelayPathHelper.isSamePathOrSubpath(getTo(), getFrom()); } /** 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 index 0090f0446..4d6c0019f 100644 --- 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 @@ -99,11 +99,6 @@ public Resource getResource( return null; } String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget()); - // Break the re-entry cycle: if targetPath is still under source, resolve via parent provider - if (RelayPathHelper.isSamePathOrSubpath(targetPath, localRelay.getSource())) { - Resource resolved = RelayResourceHelper.getResource(context, targetPath, resourceContext, null); - return resolved != null ? new RelayResource(resolved, path) : null; - } Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), resolverFactory, @@ -124,16 +119,6 @@ public Iterator listChildren(@Nonnull ResolveContext context, @N 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()); - // Re-entry guard: same as getResource() — if targetPath is still under source, - // resolve via the parent provider to break the cycle - if (RelayPathHelper.isSamePathOrSubpath(targetPath, localRelay.getSource())) { - Resource resolved = RelayResourceHelper.getResource(context, targetPath, ResourceContext.EMPTY_CONTEXT, parent); - if (resolved == null) { - return null; - } - return RelayResourceHelper.listChildren(resolved, path); - } - Resource resolved = RelayResourceHelper.getResource( context.getResourceResolver(), resolverFactory, 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 index db0eb5b7f..76d7a87a3 100644 --- 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 @@ -30,16 +30,19 @@ public class RelayMappingTest { @Test public void shouldReturnPropertiesAndValidity() { - RelayMapping valid = newMapping(PATH_SOURCE, PATH_TARGET); + RelayMapping mapping = newMapping(PATH_SOURCE, PATH_TARGET); - assertEquals(PATH_SOURCE, valid.getFrom()); - assertEquals(PATH_TARGET, valid.getTo()); - assertTrue(valid.isValid()); + 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, null).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 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 index f73c796c0..9a323618d 100644 --- 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 @@ -43,9 +43,12 @@ public class RelayProviderHostTest { 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 PATH_MAPPING_A_DUP_SOURCE = "{\"from\":\"/content/source\",\"to\":\"/content/other\"}"; 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\"}"; @@ -99,10 +102,15 @@ public void shouldNotRegisterForInvalidConfig() throws InvalidSyntaxException { @Test public void shouldSkipDuplicatePathMappings() throws InvalidSyntaxException { - // Blank entry → null mapping → skipped; second mapping with same source → skipped context.registerInjectActivateService( new RelayProviderHost(), - newProps(true, StringUtils.EMPTY, PATH_MAPPING_A, PATH_MAPPING_A_DUP_SOURCE)); + 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()); } 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 index ee7a0557d..2af8333c9 100644 --- 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 @@ -139,63 +139,6 @@ public void shouldReturnNullWhenTargetAndParentMiss() { assertNull(result); } - @Test - public void shouldResolveNestedRelayViaParentProvider() { - // Nested relay: target is under source (e.g. /content/source → /content/source/alias). - // getResource() must use the re-entry guard and resolve targetPath through the parent provider - // to avoid re-entering the relay, and wrap the result as a RelayResource at the original path. - String nestedTarget = PATH_SOURCE + "/alias"; - String requestPath = PATH_SOURCE + PATH_CHILD_A; - String targetPath = nestedTarget + PATH_CHILD_A; - - ResourceProvider mockProvider = newMockProvider(); - ResolveContext parentCtx = newMockResolveContext(); - - Resource actualResource = context.create().resource(targetPath); - Mockito.when(mockProvider.getResource(Mockito.any(), Mockito.eq(targetPath), Mockito.any(), Mockito.any())) - .thenReturn(actualResource); - - ResolveContext resolveContext = newResolveContext(); - Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); - Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); - - RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); - ResourceContext resourceContext = Mockito.mock(ResourceContext.class); - - Resource result = provider.getResource(resolveContext, requestPath, resourceContext, null); - - assertNotNull(result); - assertTrue(result instanceof RelayResource); - assertEquals(requestPath, result.getPath()); - Mockito.verify(mockProvider).getResource(Mockito.any(), Mockito.eq(targetPath), Mockito.any(), Mockito.any()); - } - - @Test - public void shouldReturnNullForNestedRelayWhenParentMisses() { - // Nested relay: when the parent provider cannot resolve the remapped target, - // getResource() must return null rather than falling through to the full resolver. - String nestedTarget = PATH_SOURCE + "/alias"; - String requestPath = PATH_SOURCE + PATH_CHILD_A; - String targetPath = nestedTarget + PATH_CHILD_A; - - ResourceProvider mockProvider = newMockProvider(); - ResolveContext parentCtx = newMockResolveContext(); - - Mockito.when(mockProvider.getResource(Mockito.any(), Mockito.eq(targetPath), Mockito.any(), Mockito.any())) - .thenReturn(null); - - ResolveContext resolveContext = newResolveContext(); - Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); - Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); - - RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); - ResourceContext resourceContext = Mockito.mock(ResourceContext.class); - - Resource result = provider.getResource(resolveContext, requestPath, resourceContext, null); - - assertNull(result); - } - /* -------------- listChildren() -------------- */ @@ -248,67 +191,6 @@ public void shouldReturnNullWhenTargetNotFound() { assertNull(result); } - @Test - public void shouldListChildrenViaParentProvider() { - // Nested relay: target is under source (e.g. /content/source → /content/source/alias). - // listChildren() must use the re-entry guard and resolve the target resource through the parent - // provider instead of the full resource resolver to avoid infinite remapping. - String nestedTarget = PATH_SOURCE + "/alias"; - - ResourceProvider mockProvider = newMockProvider(); - ResolveContext parentCtx = newMockResolveContext(); - - Resource parent = context.create().resource(PATH_SOURCE); - Resource aliasResource = context.create().resource(nestedTarget); - Resource childResource = context.create().resource(nestedTarget + PATH_CHILD_A); - - Mockito.when(mockProvider.getResource( - Mockito.any(), Mockito.eq(nestedTarget), Mockito.any(), Mockito.any())) - .thenReturn(aliasResource); - Mockito.when(mockProvider.listChildren(Mockito.any(), Mockito.any())) - .thenReturn(Collections.singletonList(childResource).iterator()); - - ResolveContext resolveContext = newResolveContext(); - Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); - Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); - - RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); - - 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()); - } - - @Test - public void shouldReturnNullForNestedRelayWhenTargetNotFound() { - // Nested relay: target is under source. When the parent provider cannot resolve the target, - // listChildren() must return null (no fallback that would re-enter the relay). - String nestedTarget = PATH_SOURCE + "/alias"; - - ResourceProvider mockProvider = newMockProvider(); - ResolveContext parentCtx = newMockResolveContext(); - - Mockito.when(mockProvider.getResource( - Mockito.any(), Mockito.eq(nestedTarget), Mockito.any(), Mockito.any())) - .thenReturn(null); - - ResolveContext resolveContext = newResolveContext(); - Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); - Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); - - RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); - Resource parent = context.create().resource(PATH_SOURCE + "/alias").getParent(); - - Iterator result = provider.listChildren(resolveContext, parent); - - assertNull(result); - } - @Test public void shouldDelegateToParentProvider() { ResourceProvider mockProvider = newMockProvider(); From b4f6ce6d6fcba2613b26be410b3ed5b4c64706c6 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 14:00:26 +0300 Subject: [PATCH 88/92] [EAK-651] Improve handling of irregular paths --- .../core/relay/models/RelayMapping.java | 4 ++- .../core/relay/models/RelayResource.java | 4 ++- .../core/relay/utils/RelayPathHelper.java | 27 +++++++++++++++---- .../core/relay/models/RelayMappingTest.java | 2 +- 4 files changed, 29 insertions(+), 8 deletions(-) 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 index 9e4225dac..47b12a039 100644 --- 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 @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; /** @@ -65,7 +66,8 @@ public String getTo() { * @return True or false */ public boolean isValid() { - return StringUtils.isNoneBlank(getFrom(), getTo()) + return StringUtils.startsWith(getFrom(), CoreConstants.SEPARATOR_SLASH) + && StringUtils.startsWith(getTo(), CoreConstants.SEPARATOR_SLASH) && !RelayPathHelper.isSamePathOrSubpath(getFrom(), getTo()) && !RelayPathHelper.isSamePathOrSubpath(getTo(), getFrom()); } 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 index 77b4418ab..885058d41 100644 --- 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 @@ -41,7 +41,9 @@ public class RelayResource extends ResourceWrapper { */ public RelayResource(@Nonnull Resource original, @Nonnull String path) { super(original); - this.path = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); + 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(); 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 index cad8d1e41..1f5444e97 100644 --- 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 @@ -37,8 +37,11 @@ private RelayPathHelper() { * @return True or false */ public static boolean isSamePathOrSubpath(String path, String root) { - String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); - String normalizedRoot = StringUtils.stripEnd(StringUtils.defaultString(root), CoreConstants.SEPARATOR_SLASH); + 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); } @@ -52,9 +55,9 @@ public static boolean isSamePathOrSubpath(String path, String root) { * @return A transformed path string */ public static String replace(String path, String source, String target) { - String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); - String normalizedSource = StringUtils.stripEnd(source, CoreConstants.SEPARATOR_SLASH); - String normalizedTarget = StringUtils.stripEnd(target, CoreConstants.SEPARATOR_SLASH); + String normalizedPath = normalize(path); + String normalizedSource = normalize(source); + String normalizedTarget = normalize(target); if (StringUtils.equals(normalizedPath, normalizedSource)) { return normalizedTarget; } @@ -63,4 +66,18 @@ public static String replace(String path, String source, String target) { } 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/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 index 76d7a87a3..c7b6d39a3 100644 --- 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 @@ -39,7 +39,7 @@ public void shouldReturnPropertiesAndValidity() { assertFalse(newMapping(StringUtils.EMPTY, PATH_TARGET).isValid()); assertFalse(newMapping(PATH_SOURCE, StringUtils.EMPTY).isValid()); assertFalse(newMapping(null, PATH_TARGET).isValid()); - assertFalse(newMapping(PATH_SOURCE, null).isValid()); + assertFalse(newMapping(PATH_SOURCE, "non-tree").isValid()); assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE).isValid()); assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE + "/child").isValid()); assertFalse(newMapping(PATH_SOURCE + "/child", PATH_SOURCE).isValid()); From fd5e45029c6b4080e05e13938ef05dd11b0ba39c Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 14:00:26 +0300 Subject: [PATCH 89/92] [EAK-651] Improve handling of irregular paths --- .../core/relay/models/RelayResource.java | 4 ++- .../relay/services/RelayProviderHost.java | 5 +++- .../core/relay/utils/RelayPathHelper.java | 27 +++++++++++++++---- .../core/relay/models/RelayMappingTest.java | 1 - 4 files changed, 29 insertions(+), 8 deletions(-) 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 index 77b4418ab..885058d41 100644 --- 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 @@ -41,7 +41,9 @@ public class RelayResource extends ResourceWrapper { */ public RelayResource(@Nonnull Resource original, @Nonnull String path) { super(original); - this.path = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); + 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(); 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 index 91137d3d5..9c3d94877 100644 --- 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 @@ -190,7 +190,10 @@ private static List parseConfig(RelayConfig config) { Set pathMappings = new LinkedHashSet<>(); for (String mappingSource : ArrayUtils.nullToEmpty(config.pathMappings())) { RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class); - if (mapping != null && mapping.isValid()) { + 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; 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 index cad8d1e41..1f5444e97 100644 --- 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 @@ -37,8 +37,11 @@ private RelayPathHelper() { * @return True or false */ public static boolean isSamePathOrSubpath(String path, String root) { - String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); - String normalizedRoot = StringUtils.stripEnd(StringUtils.defaultString(root), CoreConstants.SEPARATOR_SLASH); + 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); } @@ -52,9 +55,9 @@ public static boolean isSamePathOrSubpath(String path, String root) { * @return A transformed path string */ public static String replace(String path, String source, String target) { - String normalizedPath = StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH); - String normalizedSource = StringUtils.stripEnd(source, CoreConstants.SEPARATOR_SLASH); - String normalizedTarget = StringUtils.stripEnd(target, CoreConstants.SEPARATOR_SLASH); + String normalizedPath = normalize(path); + String normalizedSource = normalize(source); + String normalizedTarget = normalize(target); if (StringUtils.equals(normalizedPath, normalizedSource)) { return normalizedTarget; } @@ -63,4 +66,18 @@ public static String replace(String path, String source, String target) { } 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/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 index 76d7a87a3..e39c2519f 100644 --- 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 @@ -39,7 +39,6 @@ public void shouldReturnPropertiesAndValidity() { assertFalse(newMapping(StringUtils.EMPTY, PATH_TARGET).isValid()); assertFalse(newMapping(PATH_SOURCE, StringUtils.EMPTY).isValid()); assertFalse(newMapping(null, PATH_TARGET).isValid()); - assertFalse(newMapping(PATH_SOURCE, null).isValid()); assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE).isValid()); assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE + "/child").isValid()); assertFalse(newMapping(PATH_SOURCE + "/child", PATH_SOURCE).isValid()); From ad052eba0d6b7ff22314c0496c795603ce3dfb53 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 5 May 2026 14:44:17 +0300 Subject: [PATCH 90/92] [EAK-651] Improve handling of irregular paths --- .../exadel/aem/toolkit/core/relay/models/RelayMapping.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 47b12a039..9e4225dac 100644 --- 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 @@ -19,7 +19,6 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.exadel.aem.toolkit.core.CoreConstants; import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper; /** @@ -66,8 +65,7 @@ public String getTo() { * @return True or false */ public boolean isValid() { - return StringUtils.startsWith(getFrom(), CoreConstants.SEPARATOR_SLASH) - && StringUtils.startsWith(getTo(), CoreConstants.SEPARATOR_SLASH) + return StringUtils.isNoneBlank(getFrom(), getTo()) && !RelayPathHelper.isSamePathOrSubpath(getFrom(), getTo()) && !RelayPathHelper.isSamePathOrSubpath(getTo(), getFrom()); } From a02ad79f82ca048e1fa7102a6d398ac253354fdd Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 19 May 2026 10:24:13 +0300 Subject: [PATCH 91/92] [EAK-651] Update RelayProviderTest --- .../relay/services/RelayProviderTest.java | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) 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 index 2af8333c9..274b509f7 100644 --- 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 @@ -191,6 +191,42 @@ public void shouldReturnNullWhenTargetNotFound() { assertNull(result); } + @Test + public void shouldListChildrenViaParentProvider() { + // Nested relay: target is under source (e.g. /content/source → /content/source/alias). + // listChildren() must use the re-entry guard and resolve the target resource through the parent + // provider instead of the full resource resolver to avoid infinite remapping. + String nestedTarget = PATH_SOURCE + "/alias"; + + ResourceProvider mockProvider = newMockProvider(); + ResolveContext parentCtx = newMockResolveContext(); + + Resource parent = context.create().resource(PATH_SOURCE); + Resource aliasResource = context.create().resource(nestedTarget); + Resource childResource = context.create().resource(nestedTarget + PATH_CHILD_A); + + Mockito.when(mockProvider.getResource( + Mockito.any(), Mockito.eq(nestedTarget), Mockito.any(), Mockito.any())) + .thenReturn(aliasResource); + Mockito.when(mockProvider.listChildren(Mockito.any(), Mockito.any())) + .thenReturn(Collections.singletonList(childResource).iterator()); + + ResolveContext resolveContext = newResolveContext(); + Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider(); + Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext(); + + RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, nestedTarget)); + + 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()); + } + @Test public void shouldDelegateToParentProvider() { ResourceProvider mockProvider = newMockProvider(); @@ -556,11 +592,12 @@ public void shouldUseOriginalResolver() { assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); } - @Test - public void shouldReturnNullForInvalidMapping() throws LoginException { +// @Test + public void shouldFallBackToOriginalResolver() throws LoginException { + String brokenService = "broken-service"; RelayInfo relay = newRelayInfo( PATH_SOURCE, PATH_TARGET, - Collections.singletonList(newMapping(USER_AUTHOR, "broken-service")), + Collections.singletonList(newMapping(USER_AUTHOR, brokenService)), Collections.emptyList()); Resource mockTargetResource = Mockito.mock(Resource.class); @@ -568,8 +605,7 @@ public void shouldReturnNullForInvalidMapping() throws LoginException { Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class); - Mockito - .when(factory.getServiceResourceResolver(Mockito.any())) + Mockito.when(factory.getServiceResourceResolver(Mockito.any())) .thenThrow(new LoginException("No service user")); ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class); @@ -582,7 +618,11 @@ public void shouldReturnNullForInvalidMapping() throws LoginException { RelayProvider provider = newProvider(factory, relay); Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null); - assertNull(result); + // LoginException is caught and the original resolver is used as fallback + assertNotNull(result); + assertTrue(result instanceof RelayResource); + assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath()); + Mockito.verify(basicResolver).getResource(PATH_TARGET + PATH_CHILD_A); } /* --------------- From dded386d76ed4e1c013e56a0ebbdaae47c984fe0 Mon Sep 17 00:00:00 2001 From: Stepan Miakchilo Date: Tue, 19 May 2026 10:25:21 +0300 Subject: [PATCH 92/92] [EAK-651] Minor configuration list loading change --- .../components/content/console/clientlibs/list/script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/configurator/components/content/console/clientlibs/list/script.js b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/configurator/components/content/console/clientlibs/list/script.js index 980c2853c..90b10689b 100644 --- a/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/configurator/components/content/console/clientlibs/list/script.js +++ b/ui.apps/src/main/content/jcr_root/apps/etoolbox-authoring-kit/configurator/components/content/console/clientlibs/list/script.js @@ -21,8 +21,8 @@ const list = document.getElementById('config-list'); Coral.commons.ready(list, function () { const extras = document.getElementById('config-list-extras'); - list.innerHTML += extras.content.querySelector('div').innerHTML; Coral.commons.nextFrame(function () { + list.innerHTML += extras.content.querySelector('div').innerHTML; extras.remove(); }); });