Преглед изворни кода

Add modified classpath test support

Closes gh-11951
Marcus Da Coregio пре 2 година
родитељ
комит
77dcc691b3

+ 12 - 0
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'
 }

+ 49 - 0
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();
+
+}

+ 47 - 0
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();
+
+}

+ 41 - 0
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 {
+
+}

+ 267 - 0
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<Class<?>, 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<URL> 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<URL> 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<URL> extractUrlsFromManifestClassPath(URL booterJar) {
+		List<URL> 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<URL> additionalUrls = getAdditionalUrls(annotations.get(ClassPathOverrides.class));
+		List<URL> processedUrls = new ArrayList<>(additionalUrls);
+		for (URL url : urls) {
+			if (!filter.isExcluded(url)) {
+				processedUrls.add(url);
+			}
+		}
+		return processedUrls.toArray(new URL[0]);
+	}
+
+	private static List<URL> getAdditionalUrls(MergedAnnotation<ClassPathOverrides> annotation) {
+		if (!annotation.isPresent()) {
+			return Collections.emptyList();
+		}
+		return resolveCoordinates(annotation.getStringArray(MergedAnnotation.VALUE));
+	}
+
+	private static List<URL> 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<URL> 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<Dependency> createDependencies(String[] allCoordinates) {
+		List<Dependency> 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<String> exclusions;
+
+		private final AntPathMatcher matcher = new AntPathMatcher();
+
+		private ClassPathEntryFilter(MergedAnnotation<ClassPathExclusions> 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;
+		}
+
+	}
+
+}

+ 145 - 0
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<Void> invocation,
+			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
+		intercept(invocation, extensionContext);
+	}
+
+	@Override
+	public void interceptBeforeEachMethod(Invocation<Void> invocation,
+			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
+		intercept(invocation, extensionContext);
+	}
+
+	@Override
+	public void interceptAfterEachMethod(Invocation<Void> invocation,
+			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
+		intercept(invocation, extensionContext);
+	}
+
+	@Override
+	public void interceptAfterAllMethod(Invocation<Void> invocation,
+			ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
+		intercept(invocation, extensionContext);
+	}
+
+	@Override
+	public void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
+			ExtensionContext extensionContext) throws Throwable {
+		if (isModifiedClassPathClassLoader(extensionContext)) {
+			invocation.proceed();
+			return;
+		}
+		invocation.skip();
+		runTestWithModifiedClassPath(invocationContext, extensionContext);
+	}
+
+	private void runTestWithModifiedClassPath(ReflectiveInvocationContext<Method> 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<Void> 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());
+	}
+
+}

+ 4 - 0
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'
 	}
 }