From c946c130ebb81428070d878745df81aad30cd58a Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 5 Aug 2020 15:28:39 +0200 Subject: [PATCH] adds verification of dependency versions on plugin installation --- .../java/sonia/scm/plugin/NameAndVersion.java | 7 + .../main/java/sonia/scm/version/Version.java | 55 +++++- .../java/sonia/scm/version/VersionTest.java | 14 +- .../scm/plugin/DefaultPluginManager.java | 12 +- .../plugin/DependencyNotFoundException.java | 62 +++++++ .../DependencyVersionMismatchException.java | 60 ++++++ .../scm/plugin/PluginInstallationContext.java | 61 ++++++ .../plugin/PluginInstallationVerifier.java | 95 ++++++++++ .../sonia/scm/plugin/PluginInstaller.java | 26 ++- .../scm/plugin/DefaultPluginManagerTest.java | 53 +++--- .../plugin/PluginInstallationContextTest.java | 98 ++++++++++ .../PluginInstallationVerifierTest.java | 174 ++++++++++++++++++ .../sonia/scm/plugin/PluginInstallerTest.java | 12 +- 13 files changed, 673 insertions(+), 56 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/DependencyNotFoundException.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationVerifier.java create mode 100644 scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationVerifierTest.java diff --git a/scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java b/scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java index f867c14c17..414d3fcf98 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java +++ b/scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java @@ -71,6 +71,13 @@ public class NameAndVersion { return Optional.ofNullable(version); } + public Version mustGetVersion() { + if (version == null) { + throw new IllegalStateException("version is not set"); + } + return version; + } + @Override public String toString() { return name + (version != null ? ":" + version.getParsedVersion() : ""); diff --git a/scm-core/src/main/java/sonia/scm/version/Version.java b/scm-core/src/main/java/sonia/scm/version/Version.java index c0190e6bd2..c6edb05daa 100644 --- a/scm-core/src/main/java/sonia/scm/version/Version.java +++ b/scm-core/src/main/java/sonia/scm/version/Version.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.version; //~--- non-JDK imports -------------------------------------------------------- @@ -268,11 +268,28 @@ public final class Version implements Comparable * * @return true if newer */ - public boolean isNewer(String versionString) - { - Version o = Version.parse(versionString); + public boolean isNewer(String versionString) { + return isNewer(Version.parse(versionString)); + } - return (o != null) && isNewer(o); + /** + * Returns true if the given version is newer or equal. + * @param versionString other version + * @return true if newer + * @since 2.4.0 + */ + public boolean isNewerOrEqual(String versionString) { + return isNewerOrEqual(Version.parse(versionString)); + } + + /** + * Returns true if the given version is newer or equal. + * @param o other version + * @return {@code true} if newer or equal + * @since 2.4.0 + */ + public boolean isNewerOrEqual(Version o) { + return compareTo(o) <= 0; } /** @@ -296,13 +313,31 @@ public final class Version implements Comparable * * @return true if older */ - public boolean isOlder(String versionString) - { - Version o = Version.parse(versionString); - - return (o != null) && isOlder(o); + public boolean isOlder(String versionString) { + return isOlder(Version.parse(versionString)); } + /** + * Returns true if the given version is older or equal. + * @param versionString other version + * @return {@code true} if older or equal + * @since 2.4.0 + */ + public boolean isOlderOrEqual(String versionString) { + return isOlderOrEqual(Version.parse(versionString)); + } + + /** + * Returns true if the given version is older or equal. + * @param o other version + * @return {@code true} if older or equal + * @since 2.4.0 + */ + public boolean isOlderOrEqual(Version o) { + return compareTo(o) >= 0; + } + + /** * Returns true if the version is a snapshot. * diff --git a/scm-core/src/test/java/sonia/scm/version/VersionTest.java b/scm-core/src/test/java/sonia/scm/version/VersionTest.java index cd222cf57a..77a26975aa 100644 --- a/scm-core/src/test/java/sonia/scm/version/VersionTest.java +++ b/scm-core/src/test/java/sonia/scm/version/VersionTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.version; //~--- non-JDK imports -------------------------------------------------------- @@ -158,6 +158,18 @@ public class VersionTest assertTrue(Version.parse("1.1-RC5").isOlder("1.1")); } + @Test + public void testIsOlderOrEqual() { + assertTrue(Version.parse("1.0.0").isOlderOrEqual("1.0.1")); + assertTrue(Version.parse("1.0.1").isOlderOrEqual("1.0.1")); + } + + @Test + public void testINewerOrEqual() { + assertTrue(Version.parse("1.0.1").isNewerOrEqual("1.0.0")); + assertTrue(Version.parse("1.0.1").isOlderOrEqual("1.0.1")); + } + /** * Method description * diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 69fc876704..ac7bf67108 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -42,6 +42,7 @@ import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -68,6 +69,8 @@ public class DefaultPluginManager implements PluginManager { private final Collection pendingUninstallQueue = new ArrayList<>(); private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker(); + private Function, PluginInstallationContext> contextFactory; + @Inject public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus) { this.loader = loader; @@ -77,6 +80,12 @@ public class DefaultPluginManager implements PluginManager { this.eventBus = eventBus; this.computeInstallationDependencies(); + this.contextFactory = (availablePlugins -> PluginInstallationContext.of(getInstalled(), availablePlugins)); + } + + @VisibleForTesting + void setContextFactory(Function, PluginInstallationContext> contextFactory) { + this.contextFactory = contextFactory; } @VisibleForTesting @@ -167,9 +176,10 @@ public class DefaultPluginManager implements PluginManager { List plugins = collectPluginsToInstall(name); List pendingInstallations = new ArrayList<>(); + for (AvailablePlugin plugin : plugins) { try { - PendingPluginInstallation pending = installer.install(plugin); + PendingPluginInstallation pending = installer.install(contextFactory.apply(plugins), plugin); dependencyTracker.addInstalled(plugin.getDescriptor()); pendingInstallations.add(pending); eventBus.post(new PluginEvent(PluginEvent.PluginEventType.INSTALLED, plugin)); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DependencyNotFoundException.java b/scm-webapp/src/main/java/sonia/scm/plugin/DependencyNotFoundException.java new file mode 100644 index 0000000000..360ddc372b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DependencyNotFoundException.java @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +@SuppressWarnings("java:S110") +public class DependencyNotFoundException extends PluginInstallException { + + private final String plugin; + private final String missingDependency; + + public DependencyNotFoundException(String plugin, String missingDependency) { + super( + entity("Dependency", missingDependency) + .in("Plugin", plugin) + .build(), + String.format( + "missing dependency %s of plugin %s", + missingDependency, + plugin + ) + ); + this.plugin = plugin; + this.missingDependency = missingDependency; + } + + public String getPlugin() { + return plugin; + } + + public String getMissingDependency() { + return missingDependency; + } + + @Override + public String getCode() { + return "5GS6lwvWF1"; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java b/scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java new file mode 100644 index 0000000000..285a2a84b5 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java @@ -0,0 +1,60 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import lombok.Getter; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +@Getter +@SuppressWarnings("java:S110") +public class DependencyVersionMismatchException extends PluginInstallException { + + private final String plugin; + private final String dependency; + private final String minVersion; + private final String currentVersion; + + public DependencyVersionMismatchException(String plugin, String dependency, String minVersion, String currentVersion) { + super( + entity("Dependency", dependency) + .in("Plugin", plugin) + .build(), + String.format( + "%s requires dependency %s at least in version %s, but it is installed in version %s", + plugin, dependency, minVersion, currentVersion + ) + ); + this.plugin = plugin; + this.dependency = dependency; + this.minVersion = minVersion; + this.currentVersion = currentVersion; + } + + @Override + public String getCode() { + return null; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java new file mode 100644 index 0000000000..31992d5cfb --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public final class PluginInstallationContext { + + private final Map dependencies; + + private PluginInstallationContext(Map dependencies) { + this.dependencies = dependencies; + } + + public static PluginInstallationContext empty() { + return new PluginInstallationContext(Collections.emptyMap()); + } + + public static PluginInstallationContext of(Iterable installed, Iterable pending) { + Map dependencies = new HashMap<>(); + append(dependencies, installed); + append(dependencies, pending); + return new PluginInstallationContext(dependencies); + } + + private static

void append(Map dependencies, Iterable

plugins) { + for (Plugin plugin : plugins) { + PluginInformation information = plugin.getDescriptor().getInformation(); + dependencies.put(information.getName(), new NameAndVersion(information.getName(), information.getVersion())); + } + } + + public Optional find(String name) { + return Optional.ofNullable(dependencies.get(name)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationVerifier.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationVerifier.java new file mode 100644 index 0000000000..918c649274 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationVerifier.java @@ -0,0 +1,95 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import sonia.scm.version.Version; + +import java.util.Optional; +import java.util.Set; + +public final class PluginInstallationVerifier { + + private PluginInstallationVerifier() { + } + + public static void verify(PluginInstallationContext context, InstalledPlugin plugin) { + verify(context, plugin.getDescriptor()); + } + + public static void verify(PluginInstallationContext context, InstalledPluginDescriptor descriptor) { + verifyConditions(descriptor); + verifyDependencies(context, descriptor); + verifyOptionalDependencies(context, descriptor); + } + + private static void verifyConditions(InstalledPluginDescriptor descriptor) { + // TODO we should provide more details here, which condition has failed + if (!descriptor.getCondition().isSupported()) { + throw new PluginConditionFailedException( + descriptor.getCondition(), + String.format( + "could not load plugin %s, the plugin condition does not match", + descriptor.getInformation().getName() + ) + ); + } + } + + private static void verifyDependencies(PluginInstallationContext context, InstalledPluginDescriptor descriptor) { + Set dependencies = descriptor.getDependenciesWithVersion(); + for (NameAndVersion dependency : dependencies) { + NameAndVersion installed = context.find(dependency.getName()) + .orElseThrow( + () -> new DependencyNotFoundException(descriptor.getInformation().getName(), dependency.getName()) + ); + + dependency.getVersion().ifPresent(requiredVersion -> verifyDependencyVersion(descriptor, dependency, installed)); + } + } + + private static void verifyOptionalDependencies(PluginInstallationContext context, InstalledPluginDescriptor descriptor) { + Set dependencies = descriptor.getOptionalDependenciesWithVersion(); + for (NameAndVersion dependency : dependencies) { + Optional version = dependency.getVersion(); + if (version.isPresent()) { + Optional installed = context.find(dependency.getName()); + installed.ifPresent(nameAndVersion -> verifyDependencyVersion(descriptor, dependency, nameAndVersion)); + } + } + } + + private static void verifyDependencyVersion(InstalledPluginDescriptor descriptor, NameAndVersion required, NameAndVersion installed) { + Version requiredVersion = required.mustGetVersion(); + Version installedVersion = installed.mustGetVersion(); + if (installedVersion.isOlder(requiredVersion)) { + throw new DependencyVersionMismatchException( + descriptor.getInformation().getName(), + required.getName(), + requiredVersion.getUnparsedVersion(), + installedVersion.getUnparsedVersion() + ); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index 1ca34c5166..e1115c9384 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -41,26 +41,26 @@ import java.util.Optional; @SuppressWarnings("UnstableApiUsage") // guava hash is marked as unstable class PluginInstaller { - private final SCMContextProvider context; + private final SCMContextProvider scmContext; private final AdvancedHttpClient client; private final SmpDescriptorExtractor smpDescriptorExtractor; @Inject - public PluginInstaller(SCMContextProvider context, AdvancedHttpClient client, SmpDescriptorExtractor smpDescriptorExtractor) { - this.context = context; + public PluginInstaller(SCMContextProvider scmContext, AdvancedHttpClient client, SmpDescriptorExtractor smpDescriptorExtractor) { + this.scmContext = scmContext; this.client = client; this.smpDescriptorExtractor = smpDescriptorExtractor; } @SuppressWarnings("squid:S4790") // hashing should be safe - public PendingPluginInstallation install(AvailablePlugin plugin) { + public PendingPluginInstallation install(PluginInstallationContext context, AvailablePlugin plugin) { Path file = null; try (HashingInputStream input = new HashingInputStream(Hashing.sha256(), download(plugin))) { file = createFile(plugin); Files.copy(input, file); verifyChecksum(plugin, input.hash(), file); - verifyConditions(plugin, file); + verifyConditions(context, file); return new PendingPluginInstallation(plugin.install(), file); } catch (IOException ex) { cleanup(file); @@ -89,17 +89,13 @@ class PluginInstaller { } } - private void verifyConditions(AvailablePlugin plugin, Path file) throws IOException { + private void verifyConditions(PluginInstallationContext context, Path file) throws IOException { InstalledPluginDescriptor pluginDescriptor = smpDescriptorExtractor.extractPluginDescriptor(file); - if (!pluginDescriptor.getCondition().isSupported()) { + try { + PluginInstallationVerifier.verify(context, pluginDescriptor); + } catch (PluginException ex) { cleanup(file); - throw new PluginConditionFailedException( - pluginDescriptor.getCondition(), - String.format( - "could not load plugin %s, the plugin condition does not match", - plugin.getDescriptor().getInformation().getName() - ) - ); + throw ex; } } @@ -108,7 +104,7 @@ class PluginInstaller { } private Path createFile(AvailablePlugin plugin) throws IOException { - Path directory = context.resolve(Paths.get("plugins")); + Path directory = scmContext.resolve(Paths.get("plugins")); Files.createDirectories(directory); return directory.resolve(plugin.getDescriptor().getInformation().getName() + ".smp"); } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index b16d010c5d..54bcf90c95 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -96,14 +96,21 @@ class DefaultPluginManagerTest { @Mock private Subject subject; + private final PluginInstallationContext context = PluginInstallationContext.empty(); + @BeforeEach void mockInstaller() { - lenient().when(installer.install(any())).then(ic -> { - AvailablePlugin plugin = ic.getArgument(0); + lenient().when(installer.install(any(), any())).then(ic -> { + AvailablePlugin plugin = ic.getArgument(1); return new PendingPluginInstallation(plugin.install(), null); }); } + @BeforeEach + void setUpContextFactory() { + manager.setContextFactory((List availablePlugins) -> context); + } + @Nested class WithAdminPermissions { @@ -209,7 +216,7 @@ class DefaultPluginManagerTest { manager.install("scm-git-plugin", false); - verify(installer).install(git); + verify(installer).install(context, git); verify(restarter, never()).restart(any(), any()); } @@ -222,8 +229,8 @@ class DefaultPluginManagerTest { manager.install("scm-review-plugin", false); - verify(installer).install(mail); - verify(installer).install(review); + verify(installer).install(context, mail); + verify(installer).install(context, review); } @Test @@ -239,7 +246,7 @@ class DefaultPluginManagerTest { manager.install("scm-review-plugin", false); ArgumentCaptor captor = ArgumentCaptor.forClass(AvailablePlugin.class); - verify(installer).install(captor.capture()); + verify(installer).install(any(), captor.capture()); assertThat(captor.getValue().getDescriptor().getInformation().getName()).isEqualTo("scm-review-plugin"); } @@ -256,8 +263,8 @@ class DefaultPluginManagerTest { manager.install("scm-review-plugin", false); - verify(installer).install(mail); - verify(installer).install(review); + verify(installer).install(context, mail); + verify(installer).install(context, review); } @Test @@ -272,8 +279,8 @@ class DefaultPluginManagerTest { manager.install("scm-review-plugin", false); - verify(installer).install(mail); - verify(installer).install(review); + verify(installer).install(context, mail); + verify(installer).install(context, review); } @Test @@ -285,8 +292,8 @@ class DefaultPluginManagerTest { manager.install("scm-review-plugin", false); - verify(installer, never()).install(mail); - verify(installer).install(review); + verify(installer, never()).install(context, mail); + verify(installer).install(context, review); } @Test @@ -299,12 +306,12 @@ class DefaultPluginManagerTest { when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail, notification)); PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class); - doReturn(pendingNotification).when(installer).install(notification); + doReturn(pendingNotification).when(installer).install(context, notification); PendingPluginInstallation pendingMail = mock(PendingPluginInstallation.class); - doReturn(pendingMail).when(installer).install(mail); + doReturn(pendingMail).when(installer).install(context, mail); - doThrow(new PluginChecksumMismatchException(mail, "1", "2")).when(installer).install(review); + doThrow(new PluginChecksumMismatchException(mail, "1", "2")).when(installer).install(context, review); assertThrows(PluginInstallException.class, () -> manager.install("scm-review-plugin", false)); @@ -322,7 +329,7 @@ class DefaultPluginManagerTest { assertThrows(NotFoundException.class, () -> manager.install("scm-review-plugin", false)); - verify(installer, never()).install(any()); + verify(installer, never()).install(any(), any()); } @Test @@ -332,7 +339,7 @@ class DefaultPluginManagerTest { manager.install("scm-git-plugin", true); - verify(installer).install(git); + verify(installer).install(context, git); verify(restarter).restart(any(), any()); } @@ -353,7 +360,7 @@ class DefaultPluginManagerTest { manager.install("scm-review-plugin", false); manager.install("scm-review-plugin", false); // only one interaction - verify(installer).install(any()); + verify(installer).install(any(), any()); } @Test @@ -538,7 +545,7 @@ class DefaultPluginManagerTest { AvailablePlugin git = createAvailable("scm-git-plugin"); when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); PendingPluginInstallation gitPendingPluginInformation = mock(PendingPluginInstallation.class); - when(installer.install(git)).thenReturn(gitPendingPluginInformation); + when(installer.install(context, git)).thenReturn(gitPendingPluginInformation); manager.install("scm-git-plugin", false); manager.uninstall("scm-ssh-plugin", false); @@ -571,8 +578,8 @@ class DefaultPluginManagerTest { manager.updateAll(); - verify(installer).install(newMailPlugin); - verify(installer).install(newReviewPlugin); + verify(installer).install(context, newMailPlugin); + verify(installer).install(context, newReviewPlugin); } @@ -587,7 +594,7 @@ class DefaultPluginManagerTest { manager.updateAll(); - verify(installer, never()).install(oldScriptPlugin); + verify(installer, never()).install(context, oldScriptPlugin); } @Test @@ -607,7 +614,7 @@ class DefaultPluginManagerTest { void shouldFirePluginEventOnFailedInstallation() { AvailablePlugin review = createAvailable("scm-review-plugin"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); - doThrow(new PluginDownloadException(review, new IOException())).when(installer).install(review); + doThrow(new PluginDownloadException(review, new IOException())).when(installer).install(context, review); assertThrows(PluginDownloadException.class, () -> manager.install("scm-review-plugin", false)); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java new file mode 100644 index 0000000000..35c2ece7ba --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java @@ -0,0 +1,98 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PluginInstallationContextTest { + + @Test + void shouldReturnInstalledPlugin() { + Set installed = installed("scm-git-plugin", "1.0.0"); + Set pending = Collections.emptySet(); + + PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + Optional plugin = context.find("scm-git-plugin"); + assertThat(plugin).contains(new NameAndVersion("scm-git-plugin", "1.0.0")); + } + + @Test + void shouldReturnPendingPlugin() { + Set installed = Collections.emptySet(); + Set pending = pending("scm-hg-plugin", "1.0.0"); + + PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + Optional plugin = context.find("scm-hg-plugin"); + assertThat(plugin).contains(new NameAndVersion("scm-hg-plugin", "1.0.0")); + } + + @Test + void shouldReturnPendingEvenWithInstalled() { + Set installed = installed("scm-svn-plugin", "1.1.0"); + Set pending = pending("scm-svn-plugin", "1.2.0"); + + PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + Optional plugin = context.find("scm-svn-plugin"); + assertThat(plugin).contains(new NameAndVersion("scm-svn-plugin", "1.2.0")); + } + + @Test + void shouldReturnEmpty() { + Set installed = Collections.emptySet(); + Set pending = Collections.emptySet(); + + PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + Optional plugin = context.find("scm-legacy-plugin"); + assertThat(plugin).isEmpty(); + } + + private Set installed(String name, String version) { + return mockSingleton(InstalledPlugin.class, name, version); + } + + private Set pending(String name, String version) { + return mockSingleton(AvailablePlugin.class, name, version); + } + + private

Set

mockSingleton(Class

pluginClass, String name, String version) { + P plugin = mock(pluginClass, Answers.RETURNS_DEEP_STUBS); + when(plugin.getDescriptor().getInformation().getName()).thenReturn(name); + when(plugin.getDescriptor().getInformation().getVersion()).thenReturn(version); + return Collections.singleton(plugin); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationVerifierTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationVerifierTest.java new file mode 100644 index 0000000000..37597c806f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationVerifierTest.java @@ -0,0 +1,174 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static sonia.scm.plugin.PluginInstallationContext.empty; + +@ExtendWith(MockitoExtension.class) +class PluginInstallationVerifierTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private InstalledPluginDescriptor descriptor; + + // hog stands for "Heart of Gold" + private static final String HOG_PLUGIN = "scm-hog-plugin"; + + // iid stands for "Infinite Improbability Drive" + private static final String IID_PLUGIN = "scm-iid-plugin"; + + @BeforeEach + void setUpDescriptor() { + PluginInformation information = new PluginInformation(); + information.setName(HOG_PLUGIN); + information.setVersion("1.0.0"); + when(descriptor.getInformation()).thenReturn(information); + } + + @Test + void shouldFailOnCondition() { + PluginInstallationContext context = empty(); + assertThrows(PluginConditionFailedException.class, () -> PluginInstallationVerifier.verify(context, descriptor)); + } + + @Test + void shouldFailOnMissingDependency() { + matchConditions(); + when(descriptor.getDependenciesWithVersion()).thenReturn(Collections.singleton(new NameAndVersion(IID_PLUGIN))); + PluginInstallationContext context = empty(); + + DependencyNotFoundException exception = assertThrows( + DependencyNotFoundException.class, () -> PluginInstallationVerifier.verify(context, descriptor) + ); + assertThat(exception.getPlugin()).isEqualTo(HOG_PLUGIN); + assertThat(exception.getMissingDependency()).isEqualTo(IID_PLUGIN); + } + + private void matchConditions() { + when(descriptor.getCondition().isSupported()).thenReturn(true); + } + + @Test + void shouldFailOnDependencyVersionMismatch() { + matchConditions(); + + // mock installation of iid 1.0.0 + PluginInstallationContext context = mockInstallationOf(IID_PLUGIN, "1.0.0"); + + // mock dependency of iid 1.1.0 + mockDependingOf(IID_PLUGIN, "1.1.0"); + + DependencyVersionMismatchException exception = assertThrows( + DependencyVersionMismatchException.class, () -> PluginInstallationVerifier.verify(context, descriptor) + ); + assertThat(exception.getPlugin()).isEqualTo(HOG_PLUGIN); + assertThat(exception.getDependency()).isEqualTo(IID_PLUGIN); + assertThat(exception.getMinVersion()).isEqualTo("1.1.0"); + assertThat(exception.getCurrentVersion()).isEqualTo("1.0.0"); + } + + @Test + void shouldFailOnOptionalDependencyVersionMismatch() { + matchConditions(); + + // mock installation of iid 1.0.0 + PluginInstallationContext context = mockInstallationOf(IID_PLUGIN, "1.0.0"); + + // mock dependency of iid 1.1.0 + mockOptionalDependingOf(IID_PLUGIN, "1.1.0"); + + DependencyVersionMismatchException exception = assertThrows( + DependencyVersionMismatchException.class, () -> PluginInstallationVerifier.verify(context, descriptor) + ); + assertThat(exception.getPlugin()).isEqualTo(HOG_PLUGIN); + assertThat(exception.getDependency()).isEqualTo(IID_PLUGIN); + assertThat(exception.getMinVersion()).isEqualTo("1.1.0"); + assertThat(exception.getCurrentVersion()).isEqualTo("1.0.0"); + } + + @Test + @SuppressWarnings("squid:S2699") // we are happy if no exception is thrown + void shouldVerifyPlugin() { + matchConditions(); + + PluginInstallationContext context = empty(); + PluginInstallationVerifier.verify(context, descriptor); + } + + @Test + @SuppressWarnings("squid:S2699") // we are happy if no exception is thrown + void shouldVerifyPluginWithDependencies() { + matchConditions(); + + // mock installation of iid 1.1.0 + PluginInstallationContext context = mockInstallationOf(IID_PLUGIN, "1.1.0"); + + // mock dependency of iid 1.1.0 + mockDependingOf(IID_PLUGIN, "1.1.0"); + + PluginInstallationVerifier.verify(context, descriptor); + } + + @Test + @SuppressWarnings("squid:S2699") // we are happy if no exception is thrown + void shouldVerifyPluginWithOptionalDependency() { + matchConditions(); + + PluginInstallationContext context = PluginInstallationContext.empty(); + + // mock dependency of iid 1.1.0 + mockOptionalDependingOf(IID_PLUGIN, "1.1.0"); + + PluginInstallationVerifier.verify(context, descriptor); + } + + private void mockOptionalDependingOf(String plugin, String version) { + when(descriptor.getOptionalDependenciesWithVersion()).thenReturn(Collections.singleton(new NameAndVersion(plugin, version))); + } + + private void mockDependingOf(String plugin, String version) { + when(descriptor.getDependenciesWithVersion()).thenReturn(Collections.singleton(new NameAndVersion(plugin, version))); + } + + private PluginInstallationContext mockInstallationOf(String plugin, String version) { + PluginInstallationContext context = mock(PluginInstallationContext.class); + when(context.find(IID_PLUGIN)).thenReturn(Optional.of(new NameAndVersion(plugin, version))); + return context; + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java index bccbb67f3c..0930536b99 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -83,7 +83,7 @@ class PluginInstallerTest { void shouldDownloadPlugin() throws IOException { mockContent("42"); - installer.install(createGitPlugin()); + installer.install(PluginInstallationContext.empty(), createGitPlugin()); assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).hasContent("42"); } @@ -93,7 +93,7 @@ class PluginInstallerTest { mockContent("42"); AvailablePlugin gitPlugin = createGitPlugin(); - PendingPluginInstallation pending = installer.install(gitPlugin); + PendingPluginInstallation pending = installer.install(PluginInstallationContext.empty(), gitPlugin); assertThat(pending).isNotNull(); assertThat(pending.getPlugin().getDescriptor()).isEqualTo(gitPlugin.getDescriptor()); @@ -117,14 +117,14 @@ class PluginInstallerTest { void shouldThrowPluginDownloadException() throws IOException { when(client.get("https://download.hitchhiker.com").request()).thenThrow(new IOException("failed to download")); - assertThrows(PluginDownloadException.class, () -> installer.install(createGitPlugin())); + assertThrows(PluginDownloadException.class, () -> installer.install(PluginInstallationContext.empty(), createGitPlugin())); } @Test void shouldThrowPluginChecksumMismatchException() throws IOException { mockContent("21"); - assertThrows(PluginChecksumMismatchException.class, () -> installer.install(createGitPlugin())); + assertThrows(PluginChecksumMismatchException.class, () -> installer.install(PluginInstallationContext.empty(), createGitPlugin())); assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } @@ -134,7 +134,7 @@ class PluginInstallerTest { when(stream.read(any(), anyInt(), anyInt())).thenThrow(new IOException("failed to read")); when(client.get("https://download.hitchhiker.com").request().contentAsStream()).thenReturn(stream); - assertThrows(PluginDownloadException.class, () -> installer.install(createGitPlugin())); + assertThrows(PluginDownloadException.class, () -> installer.install(PluginInstallationContext.empty(), createGitPlugin())); assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } @@ -144,7 +144,7 @@ class PluginInstallerTest { InstalledPluginDescriptor supportedPlugin = createPluginDescriptor(false); when(extractor.extractPluginDescriptor(any())).thenReturn(supportedPlugin); - assertThrows(PluginConditionFailedException.class, () -> installer.install(createGitPlugin())); + assertThrows(PluginConditionFailedException.class, () -> installer.install(PluginInstallationContext.empty(), createGitPlugin())); assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); }