From 77dcc691b37b28e23e86747021ce2e11c0338069 Mon Sep 17 00:00:00 2001 From: Marcus Da Coregio Date: Tue, 4 Oct 2022 14:38:28 -0300 Subject: [PATCH] Add modified classpath test support Closes gh-11951 --- config/spring-security-config.gradle | 12 + .../test/support/ClassPathExclusions.java | 49 ++++ .../test/support/ClassPathOverrides.java | 47 +++ .../test/support/ForkedClassPath.java | 41 +++ .../support/ModifiedClassPathClassLoader.java | 267 ++++++++++++++++++ .../support/ModifiedClassPathExtension.java | 145 ++++++++++ .../spring-security-dependencies.gradle | 4 + 7 files changed, 565 insertions(+) create mode 100644 config/src/test/java/org/springframework/security/test/support/ClassPathExclusions.java create mode 100644 config/src/test/java/org/springframework/security/test/support/ClassPathOverrides.java create mode 100644 config/src/test/java/org/springframework/security/test/support/ForkedClassPath.java create mode 100644 config/src/test/java/org/springframework/security/test/support/ModifiedClassPathClassLoader.java create mode 100644 config/src/test/java/org/springframework/security/test/support/ModifiedClassPathExtension.java diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index 2388f51ba1..61b6e3a319 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -97,6 +97,18 @@ dependencies { testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor' testImplementation 'io.mockk:mockk' + testImplementation 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.apache.maven.resolver:maven-resolver-connector-basic' + testImplementation ('org.apache.maven.resolver:maven-resolver-impl') { + exclude(group: "javax.annotation", module: "javax.annotation-api") + } + testImplementation ('org.apache.maven:maven-resolver-provider') { + exclude(group: "javax.inject", module: "javax.inject") + exclude(group: "javax.annotation", module: "javax.annotation-api") + } + testImplementation ('org.apache.maven.resolver:maven-resolver-transport-http') { + exclude group: "org.slf4j", module: "jcl-over-slf4j" + } testRuntimeOnly 'org.hsqldb:hsqldb' } diff --git a/config/src/test/java/org/springframework/security/test/support/ClassPathExclusions.java b/config/src/test/java/org/springframework/security/test/support/ClassPathExclusions.java new file mode 100644 index 0000000000..f116e78d70 --- /dev/null +++ b/config/src/test/java/org/springframework/security/test/support/ClassPathExclusions.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.security.test.support; + +import java.io.File; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Annotation used to exclude entries from the classpath. + * + * @author Andy Wilkinson + * @since 1.5.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@ExtendWith(ModifiedClassPathExtension.class) +public @interface ClassPathExclusions { + + /** + * One or more Ant-style patterns that identify entries to be excluded from the class + * path. Matching is performed against an entry's {@link File#getName() file name}. + * For example, to exclude Hibernate Validator from the classpath, + * {@code "hibernate-validator-*.jar"} can be used. + * @return the exclusion patterns + */ + String[] value(); + +} diff --git a/config/src/test/java/org/springframework/security/test/support/ClassPathOverrides.java b/config/src/test/java/org/springframework/security/test/support/ClassPathOverrides.java new file mode 100644 index 0000000000..dd3b48f474 --- /dev/null +++ b/config/src/test/java/org/springframework/security/test/support/ClassPathOverrides.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.security.test.support; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Annotation used to override entries on the classpath. + * + * @author Andy Wilkinson + * @since 1.5.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@ExtendWith(ModifiedClassPathExtension.class) +public @interface ClassPathOverrides { + + /** + * One or more sets of Maven coordinates ({@code groupId:artifactId:version}) to be + * added to the classpath. The additions will take precedence over any existing + * classes on the classpath. + * @return the coordinates + */ + String[] value(); + +} diff --git a/config/src/test/java/org/springframework/security/test/support/ForkedClassPath.java b/config/src/test/java/org/springframework/security/test/support/ForkedClassPath.java new file mode 100644 index 0000000000..89b7c7cfab --- /dev/null +++ b/config/src/test/java/org/springframework/security/test/support/ForkedClassPath.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.security.test.support; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Annotation used to fork the classpath. This can be helpful where neither + * {@link ClassPathExclusions} or {@link ClassPathOverrides} are needed, but just a copy + * of the classpath. + * + * @author Christoph Dreis + * @since 2.4.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@ExtendWith(ModifiedClassPathExtension.class) +public @interface ForkedClassPath { + +} diff --git a/config/src/test/java/org/springframework/security/test/support/ModifiedClassPathClassLoader.java b/config/src/test/java/org/springframework/security/test/support/ModifiedClassPathClassLoader.java new file mode 100644 index 0000000000..f6431b81ab --- /dev/null +++ b/config/src/test/java/org/springframework/security/test/support/ModifiedClassPathClassLoader.java @@ -0,0 +1,267 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.security.test.support; + +import java.io.File; +import java.lang.management.ManagementFactory; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.apache.maven.repository.internal.MavenRepositorySystemUtils; +import org.eclipse.aether.DefaultRepositorySystemSession; +import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.collection.CollectRequest; +import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory; +import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.impl.DefaultServiceLocator; +import org.eclipse.aether.repository.LocalRepository; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.resolution.ArtifactResult; +import org.eclipse.aether.resolution.DependencyRequest; +import org.eclipse.aether.resolution.DependencyResult; +import org.eclipse.aether.spi.connector.RepositoryConnectorFactory; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transport.http.HttpTransporterFactory; + +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.StringUtils; + +/** + * Custom {@link URLClassLoader} that modifies the class path. + * + * @author Andy Wilkinson + * @author Christoph Dreis + */ +final class ModifiedClassPathClassLoader extends URLClassLoader { + + private static final Map, ModifiedClassPathClassLoader> cache = new ConcurrentReferenceHashMap<>(); + + private static final Pattern INTELLIJ_CLASSPATH_JAR_PATTERN = Pattern.compile(".*classpath(\\d+)?\\.jar"); + + private static final int MAX_RESOLUTION_ATTEMPTS = 5; + + private final ClassLoader junitLoader; + + ModifiedClassPathClassLoader(URL[] urls, ClassLoader parent, ClassLoader junitLoader) { + super(urls, parent); + this.junitLoader = junitLoader; + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + if (name.startsWith("org.junit") || name.startsWith("org.hamcrest") + || name.startsWith("io.netty.internal.tcnative")) { + return Class.forName(name, false, this.junitLoader); + } + return super.loadClass(name); + } + + static ModifiedClassPathClassLoader get(Class testClass) { + return cache.computeIfAbsent(testClass, ModifiedClassPathClassLoader::compute); + } + + private static ModifiedClassPathClassLoader compute(Class testClass) { + ClassLoader classLoader = testClass.getClassLoader(); + MergedAnnotations annotations = MergedAnnotations.from(testClass, + MergedAnnotations.SearchStrategy.TYPE_HIERARCHY); + if (annotations.isPresent(ForkedClassPath.class) && (annotations.isPresent(ClassPathOverrides.class) + || annotations.isPresent(ClassPathExclusions.class))) { + throw new IllegalStateException("@ForkedClassPath is redundant in combination with either " + + "@ClassPathOverrides or @ClassPathExclusions"); + } + return new ModifiedClassPathClassLoader(processUrls(extractUrls(classLoader), annotations), + classLoader.getParent(), classLoader); + } + + private static URL[] extractUrls(ClassLoader classLoader) { + List extractedUrls = new ArrayList<>(); + doExtractUrls(classLoader).forEach((URL url) -> { + if (isManifestOnlyJar(url)) { + extractedUrls.addAll(extractUrlsFromManifestClassPath(url)); + } + else { + extractedUrls.add(url); + } + }); + return extractedUrls.toArray(new URL[0]); + } + + private static Stream doExtractUrls(ClassLoader classLoader) { + if (classLoader instanceof URLClassLoader urlClassLoader) { + return Stream.of(urlClassLoader.getURLs()); + } + return Stream.of(ManagementFactory.getRuntimeMXBean().getClassPath().split(File.pathSeparator)) + .map(ModifiedClassPathClassLoader::toURL); + } + + private static URL toURL(String entry) { + try { + return new File(entry).toURI().toURL(); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + private static boolean isManifestOnlyJar(URL url) { + return isShortenedIntelliJJar(url); + } + + private static boolean isShortenedIntelliJJar(URL url) { + String urlPath = url.getPath(); + boolean isCandidate = INTELLIJ_CLASSPATH_JAR_PATTERN.matcher(urlPath).matches(); + if (isCandidate) { + try { + Attributes attributes = getManifestMainAttributesFromUrl(url); + String createdBy = attributes.getValue("Created-By"); + return createdBy != null && createdBy.contains("IntelliJ"); + } + catch (Exception ex) { + } + } + return false; + } + + private static List extractUrlsFromManifestClassPath(URL booterJar) { + List urls = new ArrayList<>(); + try { + for (String entry : getClassPath(booterJar)) { + urls.add(new URL(entry)); + } + } + catch (Exception ex) { + throw new RuntimeException(ex); + } + return urls; + } + + private static String[] getClassPath(URL booterJar) throws Exception { + Attributes attributes = getManifestMainAttributesFromUrl(booterJar); + return StringUtils.delimitedListToStringArray(attributes.getValue(Attributes.Name.CLASS_PATH), " "); + } + + private static Attributes getManifestMainAttributesFromUrl(URL url) throws Exception { + try (JarFile jarFile = new JarFile(new File(url.toURI()))) { + return jarFile.getManifest().getMainAttributes(); + } + } + + private static URL[] processUrls(URL[] urls, MergedAnnotations annotations) { + ClassPathEntryFilter filter = new ClassPathEntryFilter(annotations.get(ClassPathExclusions.class)); + List additionalUrls = getAdditionalUrls(annotations.get(ClassPathOverrides.class)); + List processedUrls = new ArrayList<>(additionalUrls); + for (URL url : urls) { + if (!filter.isExcluded(url)) { + processedUrls.add(url); + } + } + return processedUrls.toArray(new URL[0]); + } + + private static List getAdditionalUrls(MergedAnnotation annotation) { + if (!annotation.isPresent()) { + return Collections.emptyList(); + } + return resolveCoordinates(annotation.getStringArray(MergedAnnotation.VALUE)); + } + + private static List resolveCoordinates(String[] coordinates) { + Exception latestFailure = null; + DefaultServiceLocator serviceLocator = MavenRepositorySystemUtils.newServiceLocator(); + serviceLocator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class); + serviceLocator.addService(TransporterFactory.class, HttpTransporterFactory.class); + RepositorySystem repositorySystem = serviceLocator.getService(RepositorySystem.class); + DefaultRepositorySystemSession session = MavenRepositorySystemUtils.newSession(); + LocalRepository localRepository = new LocalRepository(System.getProperty("user.home") + "/.m2/repository"); + RemoteRepository remoteRepository = new RemoteRepository.Builder("central", "default", + "https://repo.maven.apache.org/maven2").build(); + session.setLocalRepositoryManager(repositorySystem.newLocalRepositoryManager(session, localRepository)); + for (int i = 0; i < MAX_RESOLUTION_ATTEMPTS; i++) { + CollectRequest collectRequest = new CollectRequest(null, Arrays.asList(remoteRepository)); + collectRequest.setDependencies(createDependencies(coordinates)); + DependencyRequest dependencyRequest = new DependencyRequest(collectRequest, null); + try { + DependencyResult result = repositorySystem.resolveDependencies(session, dependencyRequest); + List resolvedArtifacts = new ArrayList<>(); + for (ArtifactResult artifact : result.getArtifactResults()) { + resolvedArtifacts.add(artifact.getArtifact().getFile().toURI().toURL()); + } + return resolvedArtifacts; + } + catch (Exception ex) { + latestFailure = ex; + } + } + throw new IllegalStateException("Resolution failed after " + MAX_RESOLUTION_ATTEMPTS + " attempts", + latestFailure); + } + + private static List createDependencies(String[] allCoordinates) { + List dependencies = new ArrayList<>(); + for (String coordinate : allCoordinates) { + dependencies.add(new Dependency(new DefaultArtifact(coordinate), null)); + } + return dependencies; + } + + /** + * Filter for class path entries. + */ + private static final class ClassPathEntryFilter { + + private final List exclusions; + + private final AntPathMatcher matcher = new AntPathMatcher(); + + private ClassPathEntryFilter(MergedAnnotation annotation) { + this.exclusions = annotation.getValue(MergedAnnotation.VALUE, String[].class).map(Arrays::asList) + .orElse(Collections.emptyList()); + } + + private boolean isExcluded(URL url) { + if ("file".equals(url.getProtocol())) { + try { + String name = new File(url.toURI()).getName(); + for (String exclusion : this.exclusions) { + if (this.matcher.match(exclusion, name)) { + return true; + } + } + } + catch (URISyntaxException ex) { + } + } + return false; + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/test/support/ModifiedClassPathExtension.java b/config/src/test/java/org/springframework/security/test/support/ModifiedClassPathExtension.java new file mode 100644 index 0000000000..945142056b --- /dev/null +++ b/config/src/test/java/org/springframework/security/test/support/ModifiedClassPathExtension.java @@ -0,0 +1,145 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.security.test.support; + +import java.lang.reflect.Method; +import java.net.URLClassLoader; + +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.TestPlan; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.launcher.listeners.TestExecutionSummary; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ReflectionUtils; + +/** + * A custom {@link Extension} that runs tests using a modified class path. Entries are + * excluded from the class path using {@link ClassPathExclusions @ClassPathExclusions} and + * overridden using {@link ClassPathOverrides @ClassPathOverrides} on the test class. For + * an unchanged copy of the class path {@link ForkedClassPath @ForkedClassPath} can be + * used. A class loader is created with the customized class path and is used both to load + * the test class and as the thread context class loader while the test is being run. + * + * @author Christoph Dreis + */ +class ModifiedClassPathExtension implements InvocationInterceptor { + + @Override + public void interceptBeforeAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptBeforeEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptAfterEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptAfterAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + return; + } + invocation.skip(); + runTestWithModifiedClassPath(invocationContext, extensionContext); + } + + private void runTestWithModifiedClassPath(ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + Class testClass = extensionContext.getRequiredTestClass(); + Method testMethod = invocationContext.getExecutable(); + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + URLClassLoader modifiedClassLoader = ModifiedClassPathClassLoader.get(testClass); + Thread.currentThread().setContextClassLoader(modifiedClassLoader); + try { + runTest(modifiedClassLoader, testClass.getName(), testMethod.getName()); + } + finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + private void runTest(ClassLoader classLoader, String testClassName, String testMethodName) throws Throwable { + Class testClass = classLoader.loadClass(testClassName); + Method testMethod = findMethod(testClass, testMethodName); + LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectMethod(testClass, testMethod)).build(); + Launcher launcher = LauncherFactory.create(); + TestPlan testPlan = launcher.discover(request); + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + launcher.registerTestExecutionListeners(listener); + launcher.execute(testPlan); + TestExecutionSummary summary = listener.getSummary(); + if (!CollectionUtils.isEmpty(summary.getFailures())) { + throw summary.getFailures().get(0).getException(); + } + } + + private Method findMethod(Class testClass, String testMethodName) { + Method method = ReflectionUtils.findMethod(testClass, testMethodName); + if (method == null) { + Method[] methods = ReflectionUtils.getUniqueDeclaredMethods(testClass); + for (Method candidate : methods) { + if (candidate.getName().equals(testMethodName)) { + return candidate; + } + } + } + Assert.state(method != null, () -> "Unable to find " + testClass + "." + testMethodName); + return method; + } + + private void intercept(Invocation invocation, ExtensionContext extensionContext) throws Throwable { + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + return; + } + invocation.skip(); + } + + private boolean isModifiedClassPathClassLoader(ExtensionContext extensionContext) { + Class testClass = extensionContext.getRequiredTestClass(); + ClassLoader classLoader = testClass.getClassLoader(); + return classLoader.getClass().getName().equals(ModifiedClassPathClassLoader.class.getName()); + } + +} diff --git a/dependencies/spring-security-dependencies.gradle b/dependencies/spring-security-dependencies.gradle index 49312973f0..91a6c7ac9a 100644 --- a/dependencies/spring-security-dependencies.gradle +++ b/dependencies/spring-security-dependencies.gradle @@ -67,6 +67,10 @@ dependencies { api "org.slf4j:slf4j-api:1.7.36" api "org.springframework.ldap:spring-ldap-core:3.0.0-M3" api "org.synchronoss.cloud:nio-multipart-parser:1.1.0" + api 'org.apache.maven.resolver:maven-resolver-connector-basic:1.8.2' + api 'org.apache.maven.resolver:maven-resolver-impl:1.8.2' + api 'org.apache.maven.resolver:maven-resolver-transport-http:1.8.2' + api 'org.apache.maven:maven-resolver-provider:3.8.6' } }