From c984844f25a814530d46a81eabfc97e5384bff72 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 5 Aug 2020 08:02:58 +0200 Subject: [PATCH 01/21] adds versions to dependencies of the InstalledPluginDescriptor --- .../scm/plugin/InstalledPluginDescriptor.java | 59 ++++++++--- .../java/sonia/scm/plugin/NameAndVersion.java | 97 +++++++++++++++++++ .../plugin/InstalledPluginDescriptorTest.java | 63 ++++++++++++ .../sonia/scm/plugin/review-plugin.xml | 74 ++++++++++++++ 4 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java create mode 100644 scm-core/src/test/java/sonia/scm/plugin/InstalledPluginDescriptorTest.java create mode 100644 scm-core/src/test/resources/sonia/scm/plugin/review-plugin.xml diff --git a/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java index faedb2a6f5..785994bb54 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPluginDescriptor.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.plugin; //~--- non-JDK imports -------------------------------------------------------- @@ -37,6 +37,7 @@ import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlElementWrapper; import javax.xml.bind.annotation.XmlRootElement; import java.util.Set; +import java.util.stream.Collectors; //~--- JDK imports ------------------------------------------------------------ @@ -67,7 +68,11 @@ public final class InstalledPluginDescriptor extends ScmModule implements Plugin * @param condition * @param childFirstClassLoader * @param dependencies + * + * @deprecated this constructor uses dependencies with plain strings, + * this is deprecated because the version information is missing. */ + @Deprecated public InstalledPluginDescriptor(int scmVersion, PluginInformation information, PluginResources resources, PluginCondition condition, boolean childFirstClassLoader, Set dependencies, Set optionalDependencies) @@ -77,8 +82,17 @@ public final class InstalledPluginDescriptor extends ScmModule implements Plugin this.resources = resources; this.condition = condition; this.childFirstClassLoader = childFirstClassLoader; - this.dependencies = dependencies; - this.optionalDependencies = optionalDependencies; + this.dependencies = mapToNameAndVersionSet(dependencies); + this.optionalDependencies = mapToNameAndVersionSet(optionalDependencies); + } + + private static Set mapToNameAndVersionSet(Set dependencies) { + if (dependencies == null){ + return ImmutableSet.of(); + } + return dependencies.stream() + .map(d -> new NameAndVersion(d, null)) + .collect(Collectors.toSet()); } //~--- methods -------------------------------------------------------------- @@ -173,13 +187,19 @@ public final class InstalledPluginDescriptor extends ScmModule implements Plugin * @since 2.0.0 */ @Override - public Set getDependencies() - { - if (dependencies == null) - { + public Set getDependencies() { + return mapToStringSet(getDependenciesWithVersion()); + } + + /** + * Returns name and versions of the plugins which are this plugin depends on. + * @return dependencies with their versions + * @since 2.4.0 + */ + public Set getDependenciesWithVersion() { + if (dependencies == null) { dependencies = ImmutableSet.of(); } - return dependencies; } @@ -193,11 +213,18 @@ public final class InstalledPluginDescriptor extends ScmModule implements Plugin */ @Override public Set getOptionalDependencies() { - if (optionalDependencies == null) - { + return mapToStringSet(getOptionalDependenciesWithVersion()); + } + + /** + * Returns name and versions of the plugins which are this plugin optional depends on. + * @return optional dependencies with their versions + * @since 2.4.0 + */ + public Set getOptionalDependenciesWithVersion() { + if (optionalDependencies == null) { optionalDependencies = ImmutableSet.of(); } - return optionalDependencies; } @@ -205,6 +232,12 @@ public final class InstalledPluginDescriptor extends ScmModule implements Plugin return ImmutableSet.copyOf(Iterables.concat(getDependencies(), getOptionalDependencies())); } + private Set mapToStringSet(Set dependencies) { + return dependencies.stream() + .map(NameAndVersion::getName) + .collect(Collectors.toSet()); + } + /** * Method description * @@ -263,12 +296,12 @@ public final class InstalledPluginDescriptor extends ScmModule implements Plugin /** Field description */ @XmlElement(name = "dependency") @XmlElementWrapper(name = "dependencies") - private Set dependencies; + private Set dependencies; /** Field description */ @XmlElement(name = "dependency") @XmlElementWrapper(name = "optional-dependencies") - private Set optionalDependencies; + private Set optionalDependencies; /** Field description */ @XmlElement(name = "information") diff --git a/scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java b/scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java new file mode 100644 index 0000000000..f867c14c17 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/plugin/NameAndVersion.java @@ -0,0 +1,97 @@ +/* + * 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 com.google.common.base.Strings; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import sonia.scm.version.Version; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlAttribute; +import javax.xml.bind.annotation.XmlValue; +import javax.xml.bind.annotation.adapters.XmlAdapter; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.util.Optional; + +/** + * @since 2.4.0 + */ +@Getter +@EqualsAndHashCode +@XmlAccessorType(XmlAccessType.FIELD) +public class NameAndVersion { + + @XmlValue + private String name; + + @XmlAttribute(name = "version") + @XmlJavaTypeAdapter(VersionXmlAdapter.class) + private Version version; + + NameAndVersion() { + // required for jaxb + } + + public NameAndVersion(String name) { + this(name, null); + } + + public NameAndVersion(String name, String version) { + this.name = name; + if (!Strings.isNullOrEmpty(version)) { + this.version = Version.parse(version); + } + } + + public Optional getVersion() { + return Optional.ofNullable(version); + } + + @Override + public String toString() { + return name + (version != null ? ":" + version.getParsedVersion() : ""); + } + + static class VersionXmlAdapter extends XmlAdapter { + + @Override + public Version unmarshal(String v) { + if (Strings.isNullOrEmpty(v)) { + return null; + } + return Version.parse(v); + } + + @Override + public String marshal(Version v) { + if (v != null) { + return v.getUnparsedVersion(); + } + return null; + } + } +} diff --git a/scm-core/src/test/java/sonia/scm/plugin/InstalledPluginDescriptorTest.java b/scm-core/src/test/java/sonia/scm/plugin/InstalledPluginDescriptorTest.java new file mode 100644 index 0000000000..ce78d659a5 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/plugin/InstalledPluginDescriptorTest.java @@ -0,0 +1,63 @@ +/* + * 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 com.google.common.io.Resources; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import javax.xml.bind.JAXB; +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; + +class InstalledPluginDescriptorTest { + + private static InstalledPluginDescriptor descriptor; + + @BeforeAll + @SuppressWarnings("UnstableApiUsage") + static void unmarshal() { + URL resource = Resources.getResource("sonia/scm/plugin/review-plugin.xml"); + descriptor = JAXB.unmarshal(resource, InstalledPluginDescriptor.class); + } + + @Test + void shouldUnmarshallDependencies() { + assertThat(descriptor.getDependencies()).containsOnly("scm-mail-plugin"); + assertThat(descriptor.getOptionalDependencies()).containsOnly("scm-editor-plugin", "scm-landingpage-plugin"); + assertThat(descriptor.getDependenciesInclusiveOptionals()).containsOnly("scm-mail-plugin", "scm-editor-plugin", "scm-landingpage-plugin"); + } + + @Test + void shouldUnmarshallDependenciesWithVersion() { + assertThat(descriptor.getDependenciesWithVersion()).containsOnly(new NameAndVersion("scm-mail-plugin", "2.1.0")); + assertThat(descriptor.getOptionalDependenciesWithVersion()).containsOnly( + new NameAndVersion("scm-landingpage-plugin", "1.0.0"), + new NameAndVersion("scm-editor-plugin") + ); + } + +} diff --git a/scm-core/src/test/resources/sonia/scm/plugin/review-plugin.xml b/scm-core/src/test/resources/sonia/scm/plugin/review-plugin.xml new file mode 100644 index 0000000000..dccadb5b12 --- /dev/null +++ b/scm-core/src/test/resources/sonia/scm/plugin/review-plugin.xml @@ -0,0 +1,74 @@ + + + + 2 + + Review + Cloudogu GmbH + Workflow + scm-review-plugin + 2.3.0-SNAPSHOT + Depict a review process with pull requests + + + 2.4.0-SNAPSHOT + + + + + + com.cloudogu.scm.review.emailnotification.EmailNotificationHook + com.cloudogu.scm.review.pullrequest.service.PullRequestRejectedEvent + + + Each {@link Rule} class implementation defines a type of workflow rule.<br> + <br> + Rules applied to your repositories are represented by {@link AppliedRule}s<br> + to support multiple {@link Rule}s of the same type with distinct configuration. + + true + true + com.cloudogu.scm.review.workflow.Rule + + + v2/pull-requests + com.cloudogu.scm.review.config.api.RepositoryConfigResource + + + com.cloudogu.scm.review.pullrequest.service.PullRequestEvent + + + com.cloudogu.scm.review.ProcessChangedFilesHook + + + scm-mail-plugin + + + scm-editor-plugin + scm-landingpage-plugin + + From c946c130ebb81428070d878745df81aad30cd58a Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 5 Aug 2020 15:28:39 +0200 Subject: [PATCH 02/21] 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(); } From 1246fbd65c6be277553d1e3e502ff1e8aede7647 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 5 Aug 2020 15:41:18 +0200 Subject: [PATCH 03/21] fixes some sonarqube issues --- .../java/sonia/scm/version/VersionTest.java | 178 +++++++----------- .../sonia/scm/plugin/PluginInstallerTest.java | 16 +- 2 files changed, 84 insertions(+), 110 deletions(-) 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 77a26975aa..b58d355831 100644 --- a/scm-core/src/test/java/sonia/scm/version/VersionTest.java +++ b/scm-core/src/test/java/sonia/scm/version/VersionTest.java @@ -24,93 +24,74 @@ package sonia.scm.version; -//~--- non-JDK imports -------------------------------------------------------- -import org.junit.Test; - -import static org.junit.Assert.*; - -//~--- JDK imports ------------------------------------------------------------ +import org.junit.jupiter.api.Test; import java.util.Arrays; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + /** - * * @author Sebastian Sdorra */ -public class VersionTest -{ +class VersionTest { - /** - * Method description - * - */ @Test - public void parseSimpleVersion() - { + void parseSimpleVersion() { Version v = Version.parse("1.0"); - assertTrue(v.getMajor() == 1); - assertTrue(v.getMinor() == 0); - assertTrue(v.getPatch() == 0); - assertFalse(v.isSnapshot()); - assertTrue(v.getType() == VersionType.RELEASE); - assertEquals(v.getParsedVersion(), "1.0.0"); + assertThat(v.getMajor()).isOne(); + assertThat(v.getMinor()).isZero(); + assertThat(v.getPatch()).isZero(); + assertThat(v.isSnapshot()).isFalse(); + assertThat(v.getType()).isSameAs(VersionType.RELEASE); + assertThat(v.getParsedVersion()).isEqualTo("1.0.0"); // test with snapshot v = Version.parse("1.1-SNAPSHOT"); - assertTrue(v.getMajor() == 1); - assertTrue(v.getMinor() == 1); - assertTrue(v.getPatch() == 0); - assertTrue(v.isSnapshot()); - assertTrue(v.getType() == VersionType.RELEASE); - assertEquals(v.getParsedVersion(), "1.1.0-SNAPSHOT"); + assertThat(v.getMajor()).isOne(); + assertThat(v.getMinor()).isOne(); + assertThat(v.getPatch()).isZero(); + assertThat(v.isSnapshot()).isTrue(); + assertThat(v.getType()).isSameAs(VersionType.RELEASE); + assertThat(v.getParsedVersion()).isEqualTo("1.1.0-SNAPSHOT"); // test with maintenance v = Version.parse("2.3.14"); - assertTrue(v.getMajor() == 2); - assertTrue(v.getMinor() == 3); - assertTrue(v.getPatch() == 14); - assertFalse(v.isSnapshot()); - assertTrue(v.getType() == VersionType.RELEASE); - assertEquals(v.getParsedVersion(), "2.3.14"); + assertThat(v.getMajor()).isEqualTo(2); + assertThat(v.getMinor()).isEqualTo(3); + assertThat(v.getPatch()).isEqualTo(14); + assertThat(v.isSnapshot()).isFalse(); + assertThat(v.getType()).isSameAs(VersionType.RELEASE); + assertThat(v.getParsedVersion()).isEqualTo("2.3.14"); } - /** - * Method description - * - */ @Test - public void parseTypeVersions() - { + void parseTypeVersions() { Version v = Version.parse("1.0-alpha"); - assertTrue(v.getMajor() == 1); - assertTrue(v.getMinor() == 0); - assertTrue(v.getPatch() == 0); - assertFalse(v.isSnapshot()); - assertTrue(v.getType() == VersionType.ALPHA); - assertTrue(v.getTypeVersion() == 1); - assertEquals(v.getParsedVersion(), "1.0.0-alpha1"); + assertThat(v.getMajor()).isOne(); + assertThat(v.getMinor()).isZero(); + assertThat(v.getPatch()).isZero(); + assertThat(v.isSnapshot()).isFalse(); + assertThat(v.getType()).isSameAs(VersionType.ALPHA); + assertThat(v.getTypeVersion()).isOne(); + assertThat(v.getParsedVersion()).isEqualTo("1.0.0-alpha1"); // Test release candidate v = Version.parse("2.1.2-RC3"); - assertTrue(v.getMajor() == 2); - assertTrue(v.getMinor() == 1); - assertTrue(v.getPatch() == 2); - assertFalse(v.isSnapshot()); - assertTrue(v.getType() == VersionType.RELEASE_CANDIDAT); - assertTrue(v.getTypeVersion() == 3); - assertEquals(v.getParsedVersion(), "2.1.2-RC3"); + assertThat(v.getMajor()).isEqualTo(2); + assertThat(v.getMinor()).isEqualTo(1); + assertThat(v.getPatch()).isEqualTo(2); + assertThat(v.isSnapshot()).isFalse(); + assertThat(v.getType()).isSameAs(VersionType.RELEASE_CANDIDAT); + assertThat(v.getTypeVersion()).isEqualTo(3); + assertThat(v.getParsedVersion()).isEqualTo("2.1.2-RC3"); } - /** - * Method description - * - */ @Test - public void testCompareTo() - { + void testCompareTo() { Version[] versions = new Version[9]; versions[0] = Version.parse("2.3.1-SNAPSHOT"); @@ -123,60 +104,45 @@ public class VersionTest versions[7] = Version.parse("2.3"); versions[8] = Version.parse("2.4.6"); Arrays.sort(versions); - assertEquals(versions[0].getParsedVersion(), "2.4.6"); - assertEquals(versions[1].getParsedVersion(), "2.3.1"); - assertEquals(versions[2].getParsedVersion(), "2.3.1-SNAPSHOT"); - assertEquals(versions[3].getParsedVersion(), "2.3.1-RC1"); - assertEquals(versions[4].getParsedVersion(), "2.3.1-beta2"); - assertEquals(versions[5].getParsedVersion(), "2.3.1-beta1"); - assertEquals(versions[6].getParsedVersion(), "2.3.1-alpha2"); - assertEquals(versions[7].getParsedVersion(), "2.3.1-M1"); - assertEquals(versions[8].getParsedVersion(), "2.3.0"); - } - - /** - * Method description - * - */ - @Test - public void testIsNewer() - { - assertFalse(Version.parse("1.0").isNewer("1.0.1")); - assertTrue(Version.parse("1.1").isNewer("1.1-alpha1")); - assertTrue(Version.parse("1.1").isNewer("1.1-RC5")); - } - - /** - * Method description - * - */ - @Test - public void testIsOlder() - { - assertFalse(Version.parse("1.0.1").isOlder("1.0")); - assertTrue(Version.parse("1.1-alpha1").isOlder("1.1")); - assertTrue(Version.parse("1.1-RC5").isOlder("1.1")); + assertThat(versions[0].getParsedVersion()).isEqualTo("2.4.6"); + assertThat(versions[1].getParsedVersion()).isEqualTo("2.3.1"); + assertThat(versions[2].getParsedVersion()).isEqualTo("2.3.1-SNAPSHOT"); + assertThat(versions[3].getParsedVersion()).isEqualTo("2.3.1-RC1"); + assertThat(versions[4].getParsedVersion()).isEqualTo("2.3.1-beta2"); + assertThat(versions[5].getParsedVersion()).isEqualTo("2.3.1-beta1"); + assertThat(versions[6].getParsedVersion()).isEqualTo("2.3.1-alpha2"); + assertThat(versions[7].getParsedVersion()).isEqualTo("2.3.1-M1"); + assertThat(versions[8].getParsedVersion()).isEqualTo("2.3.0"); } @Test - public void testIsOlderOrEqual() { - assertTrue(Version.parse("1.0.0").isOlderOrEqual("1.0.1")); - assertTrue(Version.parse("1.0.1").isOlderOrEqual("1.0.1")); + void testIsNewer() { + assertThat(Version.parse("1.0").isNewer("1.0.1")).isFalse(); + assertThat(Version.parse("1.1").isNewer("1.1-alpha1")).isTrue(); + assertThat(Version.parse("1.1").isNewer("1.1-RC5")).isTrue(); } @Test - public void testINewerOrEqual() { - assertTrue(Version.parse("1.0.1").isNewerOrEqual("1.0.0")); - assertTrue(Version.parse("1.0.1").isOlderOrEqual("1.0.1")); + void testIsOlder() { + assertThat(Version.parse("1.0.1").isOlder("1.0")).isFalse(); + assertThat(Version.parse("1.1-alpha1").isOlder("1.1")).isTrue(); + assertThat(Version.parse("1.1-RC5").isOlder("1.1")).isTrue(); } - /** - * Method description - * - */ - @Test(expected = VersionParseException.class) - public void testUnparseable() - { - Version.parse("aaaa"); + @Test + void testIsOlderOrEqual() { + assertThat(Version.parse("1.0.0").isOlderOrEqual("1.0.1")).isTrue(); + assertThat(Version.parse("1.0.1").isOlderOrEqual("1.0.1")).isTrue(); + } + + @Test + void testINewerOrEqual() { + assertThat(Version.parse("1.0.1").isNewerOrEqual("1.0.0")).isTrue(); + assertThat(Version.parse("1.0.1").isOlderOrEqual("1.0.1")).isTrue(); + } + + @Test + void testUnparseable() { + assertThrows(VersionParseException.class, () -> Version.parse("aaaa")); } } 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 0930536b99..dced524ce1 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -117,14 +117,18 @@ 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(PluginInstallationContext.empty(), createGitPlugin())); + PluginInstallationContext context = PluginInstallationContext.empty(); + AvailablePlugin gitPlugin = createGitPlugin(); + assertThrows(PluginDownloadException.class, () -> installer.install(context, gitPlugin)); } @Test void shouldThrowPluginChecksumMismatchException() throws IOException { mockContent("21"); - assertThrows(PluginChecksumMismatchException.class, () -> installer.install(PluginInstallationContext.empty(), createGitPlugin())); + PluginInstallationContext context = PluginInstallationContext.empty(); + AvailablePlugin gitPlugin = createGitPlugin(); + assertThrows(PluginChecksumMismatchException.class, () -> installer.install(context, gitPlugin)); assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } @@ -134,7 +138,9 @@ 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(PluginInstallationContext.empty(), createGitPlugin())); + PluginInstallationContext context = PluginInstallationContext.empty(); + AvailablePlugin gitPlugin = createGitPlugin(); + assertThrows(PluginDownloadException.class, () -> installer.install(context, gitPlugin)); assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } @@ -144,7 +150,9 @@ class PluginInstallerTest { InstalledPluginDescriptor supportedPlugin = createPluginDescriptor(false); when(extractor.extractPluginDescriptor(any())).thenReturn(supportedPlugin); - assertThrows(PluginConditionFailedException.class, () -> installer.install(PluginInstallationContext.empty(), createGitPlugin())); + PluginInstallationContext context = PluginInstallationContext.empty(); + AvailablePlugin gitPlugin = createGitPlugin(); + assertThrows(PluginConditionFailedException.class, () -> installer.install(context, gitPlugin)); assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } From 0287724b97024d123f3613993f5980b0556da3bc Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 5 Aug 2020 16:54:48 +0200 Subject: [PATCH 04/21] adds verification if name and version of a downloaded plugin matches plugin center information --- .../DependencyVersionMismatchException.java | 2 +- .../PluginChecksumMismatchException.java | 1 + .../PluginInformationMismatchException.java | 51 +++++++++++++++++++ .../sonia/scm/plugin/PluginInstaller.java | 47 +++++++++++++---- .../sonia/scm/plugin/PluginInstallerTest.java | 37 +++++++++++++- 5 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginInformationMismatchException.java diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java b/scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java index 285a2a84b5..aefdec537b 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DependencyVersionMismatchException.java @@ -55,6 +55,6 @@ public class DependencyVersionMismatchException extends PluginInstallException { @Override public String getCode() { - return null; + return "E5S6niWwi1"; } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java index af419fa297..389ab6439f 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java @@ -26,6 +26,7 @@ package sonia.scm.plugin; import static sonia.scm.ContextEntry.ContextBuilder.entity; +@SuppressWarnings("java:S110") public class PluginChecksumMismatchException extends PluginInstallException { public PluginChecksumMismatchException(AvailablePlugin plugin, String calculatedChecksum, String expectedChecksum) { super( diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInformationMismatchException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInformationMismatchException.java new file mode 100644 index 0000000000..d4317f25e2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInformationMismatchException.java @@ -0,0 +1,51 @@ +/* + * 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 PluginInformationMismatchException extends PluginInstallException { + + private final PluginInformation api; + private final PluginInformation downloaded; + + public PluginInformationMismatchException(PluginInformation api, PluginInformation downloaded, String message) { + super( + entity("Plugin", api.getName()).build(), + message + ); + this.api = api; + this.downloaded = downloaded; + } + + @Override + public String getCode() { + return "4RS6niPRX1"; + } +} 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 e1115c9384..79fb14d647 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -60,14 +60,49 @@ class PluginInstaller { Files.copy(input, file); verifyChecksum(plugin, input.hash(), file); - verifyConditions(context, file); + + InstalledPluginDescriptor descriptor = smpDescriptorExtractor.extractPluginDescriptor(file); + PluginInstallationVerifier.verify(context, descriptor); + + verifyInformation(plugin.getDescriptor(), descriptor); + return new PendingPluginInstallation(plugin.install(), file); + } catch (PluginException ex) { + cleanup(file); + throw ex; } catch (IOException ex) { cleanup(file); throw new PluginDownloadException(plugin, ex); } } + private void verifyInformation(AvailablePluginDescriptor api, InstalledPluginDescriptor downloaded) { + verifyInformation(api.getInformation(), downloaded.getInformation()); + } + + private void verifyInformation(PluginInformation api, PluginInformation downloaded) { + if (!api.getName().equals(downloaded.getName())) { + throw new PluginInformationMismatchException( + api, downloaded, + String.format( + "downloaded plugin name \"%s\" does not match the expected name \"%s\" from plugin-center", + downloaded.getName(), + api.getName() + ) + ); + } + if (!api.getVersion().equals(downloaded.getVersion())) { + throw new PluginInformationMismatchException( + api, downloaded, + String.format( + "downloaded plugin version \"%s\" does not match the expected version \"%s\" from plugin-center", + downloaded.getVersion(), + api.getVersion() + ) + ); + } + } + private void cleanup(Path file) { try { if (file != null) { @@ -89,16 +124,6 @@ class PluginInstaller { } } - private void verifyConditions(PluginInstallationContext context, Path file) throws IOException { - InstalledPluginDescriptor pluginDescriptor = smpDescriptorExtractor.extractPluginDescriptor(file); - try { - PluginInstallationVerifier.verify(context, pluginDescriptor); - } catch (PluginException ex) { - cleanup(file); - throw ex; - } - } - private InputStream download(AvailablePlugin plugin) throws IOException { return client.get(plugin.getDescriptor().getUrl()).request().contentAsStream(); } 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 dced524ce1..a619289094 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -156,9 +156,38 @@ class PluginInstallerTest { assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } + @Test + void shouldFailForNameMismatch() throws IOException { + mockContent("42"); + + InstalledPluginDescriptor supportedPlugin = createPluginDescriptor("scm-svn-plugin", "1.0.0", true); + when(extractor.extractPluginDescriptor(any())).thenReturn(supportedPlugin); + + PluginInstallationContext context = PluginInstallationContext.empty(); + AvailablePlugin gitPlugin = createGitPlugin(); + PluginInformationMismatchException exception = assertThrows(PluginInformationMismatchException.class, () -> installer.install(context, gitPlugin)); + assertThat(exception.getApi().getName()).isEqualTo("scm-git-plugin"); + assertThat(exception.getDownloaded().getName()).isEqualTo("scm-svn-plugin"); + } + + @Test + void shouldFailForVersionMismatch() throws IOException { + mockContent("42"); + + InstalledPluginDescriptor supportedPlugin = createPluginDescriptor("scm-git-plugin", "1.1.0", true); + when(extractor.extractPluginDescriptor(any())).thenReturn(supportedPlugin); + + PluginInstallationContext context = PluginInstallationContext.empty(); + AvailablePlugin gitPlugin = createGitPlugin(); + PluginInformationMismatchException exception = assertThrows(PluginInformationMismatchException.class, () -> installer.install(context, gitPlugin)); + assertThat(exception.getApi().getVersion()).isEqualTo("1.0.0"); + assertThat(exception.getDownloaded().getVersion()).isEqualTo("1.1.0"); + } + private AvailablePlugin createPlugin(String name, String url, String checksum) { PluginInformation information = new PluginInformation(); information.setName(name); + information.setVersion("1.0.0"); AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( information, null, Collections.emptySet(), url, checksum ); @@ -166,9 +195,15 @@ class PluginInstallerTest { } private InstalledPluginDescriptor createPluginDescriptor(boolean supported) { + return createPluginDescriptor("scm-git-plugin", "1.0.0", supported); + } + + private InstalledPluginDescriptor createPluginDescriptor(String name, String version, boolean supported) { InstalledPluginDescriptor installedPluginDescriptor = mock(InstalledPluginDescriptor.class, RETURNS_DEEP_STUBS); + lenient().when(installedPluginDescriptor.getInformation().getId()).thenReturn(name); + lenient().when(installedPluginDescriptor.getInformation().getName()).thenReturn(name); + lenient().when(installedPluginDescriptor.getInformation().getVersion()).thenReturn(version); lenient().when(installedPluginDescriptor.getCondition().isSupported()).thenReturn(supported); - lenient().when(installedPluginDescriptor.getInformation().getId()).thenReturn("scm-git-plugin"); return installedPluginDescriptor; } } From 8b8ef7f826996586093a152f70d1869881e0f4d3 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Thu, 6 Aug 2020 21:35:12 +0200 Subject: [PATCH 05/21] Verify pending plugins on startup --- .../scm/plugin/DefaultPluginManager.java | 2 +- .../java/sonia/scm/plugin/ExplodedSmp.java | 6 +- .../scm/plugin/PluginInstallationContext.java | 28 +++++-- .../sonia/scm/plugin/PluginProcessor.java | 84 +++++++++++++------ .../java/sonia/scm/plugin/PluginTree.java | 5 +- .../scm/plugin/SmpDescriptorExtractor.java | 9 +- .../plugin/PluginInstallationContextTest.java | 31 +++++-- .../plugin/SmpDescriptorExtractorTest.java | 6 +- 8 files changed, 119 insertions(+), 52 deletions(-) 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 ac7bf67108..4eb48b76d8 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -80,7 +80,7 @@ public class DefaultPluginManager implements PluginManager { this.eventBus = eventBus; this.computeInstallationDependencies(); - this.contextFactory = (availablePlugins -> PluginInstallationContext.of(getInstalled(), availablePlugins)); + this.contextFactory = (availablePlugins -> PluginInstallationContext.from(getInstalled(), availablePlugins)); } @VisibleForTesting diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java index 5007be1e75..2b2aebd00b 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.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.plugin; //~--- non-JDK imports -------------------------------------------------------- @@ -30,11 +30,9 @@ import com.google.common.base.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -//~--- JDK imports ------------------------------------------------------------ - import java.nio.file.Path; -import java.util.Set; +//~--- JDK imports ------------------------------------------------------------ /** * The ExplodedSmp object represents an extracted SCM-Manager plugin. The object diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java index 31992d5cfb..f16da8bdeb 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallationContext.java @@ -41,18 +41,30 @@ public final class PluginInstallationContext { return new PluginInstallationContext(Collections.emptyMap()); } - public static PluginInstallationContext of(Iterable installed, Iterable pending) { + public static PluginInstallationContext fromDescriptors(Iterable installed, Iterable pending) { Map dependencies = new HashMap<>(); - append(dependencies, installed); - append(dependencies, pending); + appendDescriptors(dependencies, installed); + appendDescriptors(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 static PluginInstallationContext from(Iterable installed, Iterable pending) { + Map dependencies = new HashMap<>(); + appendPlugins(dependencies, installed); + appendPlugins(dependencies, pending); + return new PluginInstallationContext(dependencies); + } + + private static

void appendDescriptors(Map dependencies, Iterable

descriptors) { + descriptors.forEach(desc -> appendPlugins(dependencies, desc.getInformation())); + } + + private static

void appendPlugins(Map dependencies, Iterable

plugins) { + plugins.forEach(plugin -> appendPlugins(dependencies, plugin.getDescriptor().getInformation())); + } + + private static void appendPlugins(Map dependencies, PluginInformation information) { + dependencies.put(information.getName(), new NameAndVersion(information.getName(), information.getVersion())); } public Optional find(String name) { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 8d9b137245..96fa648976 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.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.plugin; //~--- non-JDK imports -------------------------------------------------------- @@ -35,7 +35,6 @@ import com.google.common.hash.Hashing; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.lifecycle.classloading.ClassLoaderLifeCycle; -import sonia.scm.plugin.ExplodedSmp.PathTransformer; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; @@ -50,11 +49,14 @@ import java.nio.file.Path; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; -import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; //~--- JDK imports ------------------------------------------------------------ @@ -162,25 +164,16 @@ public final class PluginProcessor { logger.info("collect plugins"); - Set archives = collect(pluginDirectory, new PluginArchiveFilter()); + Set installedPlugins = findInstalledPlugins(); + logger.debug("found {} installed plugins", installedPlugins.size()); - logger.debug("extract {} archives", archives.size()); + Set newlyInstalledPlugins = installPending(installedPlugins); + logger.debug("finished installation of {} plugins", newlyInstalledPlugins.size()); - extract(archives); - - List dirs = - collectPluginDirectories(pluginDirectory) - .stream() - .filter(isPluginDirectory()) - .collect(toList()); - - logger.debug("process {} directories: {}", dirs.size(), dirs); - - List smps = Lists.transform(dirs, new PathTransformer()); + Set plugins = concat(installedPlugins, newlyInstalledPlugins); logger.trace("start building plugin tree"); - - PluginTree pluginTree = new PluginTree(smps); + PluginTree pluginTree = new PluginTree(plugins); logger.info("install plugin tree:\n{}", pluginTree); @@ -195,6 +188,46 @@ public final class PluginProcessor return ImmutableSet.copyOf(wrappers); } + private ImmutableSet concat(Set installedPlugins, Set newlyInstalledPlugins) { + return ImmutableSet.builder().addAll(installedPlugins).addAll(newlyInstalledPlugins).build(); + } + + private Set installPending(Set installedPlugins) throws IOException { + Set archives = collect(pluginDirectory, new PluginArchiveFilter()); + logger.debug("start installation of {} pending archives", archives.size()); + + Map pending = new HashMap<>(); + for (Path archive : archives) { + pending.put(archive, SmpDescriptorExtractor.extractPluginDescriptor(archive)); + } + + PluginInstallationContext installationContext = PluginInstallationContext.fromDescriptors( + installedPlugins.stream().map(ExplodedSmp::getPlugin).collect(toSet()), + pending.values() + ); + + for (Map.Entry entry : pending.entrySet()) { + try { + PluginInstallationVerifier.verify(installationContext, entry.getValue()); + } catch (PluginException ex) { + Path path = entry.getKey(); + logger.error("failed to install smp {}, because it could not be verified", path); + logger.error("to restore scm-manager functionality remove the smp file {} from the plugin directory", path); + throw ex; + } + } + + return extract(archives); + } + + private Set findInstalledPlugins() throws IOException { + return collectPluginDirectories(pluginDirectory) + .stream() + .filter(isPluginDirectory()) + .map(ExplodedSmp::create) + .collect(Collectors.toSet()); + } + private Predicate isPluginDirectory() { return dir -> Files.exists(dir.resolve(DIRECTORY_METAINF).resolve("scm").resolve("plugin.xml")); } @@ -505,10 +538,12 @@ public final class PluginProcessor * * @throws IOException */ - private void extract(Iterable archives) throws IOException + private Set extract(Iterable archives) throws IOException { logger.debug("extract archives"); + ImmutableSet.Builder extracted = ImmutableSet.builder(); + for (Path archive : archives) { File archiveFile = archive.toFile(); @@ -519,17 +554,18 @@ public final class PluginProcessor logger.debug("extract plugin {}", smp.getPlugin()); - File directory = - PluginsInternal.createPluginDirectory(pluginDirectory.toFile(), - smp.getPlugin()); + File directory = PluginsInternal.createPluginDirectory(pluginDirectory.toFile(), smp.getPlugin()); - String checksum = com.google.common.io.Files.hash(archiveFile, - Hashing.sha256()).toString(); + String checksum = com.google.common.io.Files.hash(archiveFile, Hashing.sha256()).toString(); File checksumFile = PluginsInternal.getChecksumFile(directory); PluginsInternal.extract(smp, checksum, directory, checksumFile, false); moveArchive(archive); + + extracted.add(ExplodedSmp.create(directory.toPath())); } + + return extracted.build(); } /** diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java index fdcaf945d7..70ba97250e 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.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.plugin; //~--- non-JDK imports -------------------------------------------------------- @@ -30,6 +30,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Arrays; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; @@ -71,7 +72,7 @@ public final class PluginTree * * @param smps */ - public PluginTree(List smps) + public PluginTree(Collection smps) { smps.forEach(s -> { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java b/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java index 80e78d6455..aae8439d19 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.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.plugin; import javax.xml.bind.JAXBContext; @@ -33,9 +33,12 @@ import java.nio.file.Path; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; -class SmpDescriptorExtractor { +final class SmpDescriptorExtractor { - InstalledPluginDescriptor extractPluginDescriptor(Path file) throws IOException { + private SmpDescriptorExtractor() { + } + + static InstalledPluginDescriptor extractPluginDescriptor(Path file) throws IOException { try (ZipInputStream zipInputStream = new ZipInputStream(Files.newInputStream(file), StandardCharsets.UTF_8)) { ZipEntry nextEntry; while ((nextEntry = zipInputStream.getNextEntry()) != null) { diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java index 35c2ece7ba..621e705c04 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallationContextTest.java @@ -45,7 +45,7 @@ class PluginInstallationContextTest { Set installed = installed("scm-git-plugin", "1.0.0"); Set pending = Collections.emptySet(); - PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + PluginInstallationContext context = PluginInstallationContext.from(installed, pending); Optional plugin = context.find("scm-git-plugin"); assertThat(plugin).contains(new NameAndVersion("scm-git-plugin", "1.0.0")); } @@ -55,7 +55,7 @@ class PluginInstallationContextTest { Set installed = Collections.emptySet(); Set pending = pending("scm-hg-plugin", "1.0.0"); - PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + PluginInstallationContext context = PluginInstallationContext.from(installed, pending); Optional plugin = context.find("scm-hg-plugin"); assertThat(plugin).contains(new NameAndVersion("scm-hg-plugin", "1.0.0")); } @@ -65,7 +65,7 @@ class PluginInstallationContextTest { Set installed = installed("scm-svn-plugin", "1.1.0"); Set pending = pending("scm-svn-plugin", "1.2.0"); - PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + PluginInstallationContext context = PluginInstallationContext.from(installed, pending); Optional plugin = context.find("scm-svn-plugin"); assertThat(plugin).contains(new NameAndVersion("scm-svn-plugin", "1.2.0")); } @@ -75,24 +75,41 @@ class PluginInstallationContextTest { Set installed = Collections.emptySet(); Set pending = Collections.emptySet(); - PluginInstallationContext context = PluginInstallationContext.of(installed, pending); + PluginInstallationContext context = PluginInstallationContext.from(installed, pending); Optional plugin = context.find("scm-legacy-plugin"); assertThat(plugin).isEmpty(); } + @Test + void shouldCreateContextFromDescriptor() { + Set installed = mockDescriptor(InstalledPluginDescriptor.class, "scm-svn-plugin", "1.1.0"); + Set pending = mockDescriptor(AvailablePluginDescriptor.class, "scm-svn-plugin", "1.2.0"); + + PluginInstallationContext context = PluginInstallationContext.fromDescriptors(installed, pending); + Optional plugin = context.find("scm-svn-plugin"); + assertThat(plugin).contains(new NameAndVersion("scm-svn-plugin", "1.2.0")); + } + private Set installed(String name, String version) { - return mockSingleton(InstalledPlugin.class, name, version); + return mockPlugin(InstalledPlugin.class, name, version); } private Set pending(String name, String version) { - return mockSingleton(AvailablePlugin.class, name, version); + return mockPlugin(AvailablePlugin.class, name, version); } - private

Set

mockSingleton(Class

pluginClass, String name, String version) { + private

Set

mockPlugin(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); } + private Set mockDescriptor(Class descriptorClass, String name, String version) { + D desc = mock(descriptorClass, Answers.RETURNS_DEEP_STUBS); + when(desc.getInformation().getName()).thenReturn(name); + when(desc.getInformation().getVersion()).thenReturn(version); + return Collections.singleton(desc); + } + } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java index dd8dad5eca..aa6e2223c3 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java @@ -67,7 +67,7 @@ class SmpDescriptorExtractorTest { void shouldExtractPluginXml(@TempDir Path tempDir) throws IOException { Path pluginFile = createZipFile(tempDir, "META-INF/scm/plugin.xml", PLUGIN_XML); - InstalledPluginDescriptor installedPluginDescriptor = new SmpDescriptorExtractor().extractPluginDescriptor(pluginFile); + InstalledPluginDescriptor installedPluginDescriptor = SmpDescriptorExtractor.extractPluginDescriptor(pluginFile); Assertions.assertThat(installedPluginDescriptor.getInformation().getName()).isEqualTo("scm-test-plugin"); } @@ -76,14 +76,14 @@ class SmpDescriptorExtractorTest { void shouldFailWithoutPluginXml(@TempDir Path tempDir) throws IOException { Path pluginFile = createZipFile(tempDir, "META-INF/wrong/plugin.xml", PLUGIN_XML); - assertThrows(IOException.class, () -> new SmpDescriptorExtractor().extractPluginDescriptor(pluginFile)); + assertThrows(IOException.class, () -> SmpDescriptorExtractor.extractPluginDescriptor(pluginFile)); } @Test void shouldFailWithIllegalPluginXml(@TempDir Path tempDir) throws IOException { Path pluginFile = createZipFile(tempDir, "META-INF/scm/plugin.xml", "content"); - assertThrows(IOException.class, () -> new SmpDescriptorExtractor().extractPluginDescriptor(pluginFile)); + assertThrows(IOException.class, () -> SmpDescriptorExtractor.extractPluginDescriptor(pluginFile)); } Path createZipFile(Path tempDir, String internalFileName, String content) throws IOException { From 49c980e9af1ada0a03e8acd635391ca06288a2bd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Fri, 7 Aug 2020 08:13:52 +0200 Subject: [PATCH 06/21] Add missing PluginProcessor tests --- .../sonia/scm/plugin/PluginProcessorTest.java | 38 +++++++++++++++- .../sonia/scm/plugin/scm-g-plugin.xml | 45 +++++++++++++++++++ .../sonia/scm/plugin/scm-h-plugin.xml | 41 +++++++++++++++++ .../sonia/scm/plugin/scm-i-plugin.xml | 45 +++++++++++++++++++ 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml create mode 100644 scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml create mode 100644 scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java index 923e569d16..e78fc1db77 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.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.plugin; //~--- non-JDK imports -------------------------------------------------------- @@ -37,6 +37,8 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import sonia.scm.lifecycle.classloading.ClassLoaderLifeCycle; +import javax.xml.bind.JAXB; + import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; @@ -53,6 +55,8 @@ import java.lang.reflect.InvocationTargetException; import java.net.URL; import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; /** * @@ -96,8 +100,40 @@ public class PluginProcessorTest new PluginResource("sonia/scm/plugin/scm-f-plugin-1.0.1.smp", "scm-f-plugin.smp", "scm-f-plugin:1.0.1"); + private static final String PLUGIN_G = "sonia/scm/plugin/scm-g-plugin.xml"; + private static final String PLUGIN_H = "sonia/scm/plugin/scm-h-plugin.xml"; + private static final String PLUGIN_I = "sonia/scm/plugin/scm-i-plugin.xml"; + //~--- methods -------------------------------------------------------------- + @Test(expected = PluginConditionFailedException.class) + public void testFailedPluginCondition() throws IOException { + createPlugin(PLUGIN_G); + collectPlugins(); + } + + + @Test(expected = DependencyVersionMismatchException.class) + public void testWrongVersionOfDependency() throws IOException { + createPlugin(PLUGIN_H); + createPlugin(PLUGIN_I); + collectPlugins(); + } + + @SuppressWarnings("UnstableApiUsage") + private void createPlugin(String descriptorResource) throws IOException { + URL resource = Resources.getResource(descriptorResource); + InstalledPluginDescriptor descriptor = JAXB.unmarshal(resource, InstalledPluginDescriptor.class); + + File file = new File(pluginDirectory, descriptor.getInformation().getName() + ".smp"); + + try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(file))) { + zip.putNextEntry(new ZipEntry("META-INF/scm/plugin.xml")); + Resources.copy(resource, zip); + } + } + + /** * Method description * diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml new file mode 100644 index 0000000000..c6b3339938 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml @@ -0,0 +1,45 @@ + + + + + 2 + + + sonia.scm.plugins + scm-g-plugin + 1.0.0 + scm-g-plugin + Plugin g has a min version over 9000 + + + + 9000.0.0 + + + + sonia.scm.plugins + + + diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml new file mode 100644 index 0000000000..fa2d0767cc --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml @@ -0,0 +1,41 @@ + + + + + 2 + + + sonia.scm.plugins + scm-h-plugin + 1.0.0 + scm-h-plugin + Plugin h is nothing special + + + + sonia.scm.plugins + + + diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml new file mode 100644 index 0000000000..0fd2c1999a --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml @@ -0,0 +1,45 @@ + + + + + 2 + + + sonia.scm.plugins + scm-i-plugin + 1.0.0 + scm-i-plugin + Plugin i depends on h in version 1.2.0 + + + + sonia.scm.plugins + + + + scm-h-plugin + + + From d66f0ef141813a21ee0aeb06ee38b744c3a111fb Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Fri, 7 Aug 2020 10:52:20 +0200 Subject: [PATCH 07/21] Fix broken test and missing license headers --- .../sonia/scm/plugin/PluginProcessor.java | 4 +- .../scm/plugin/SmpDescriptorExtractor.java | 6 +-- .../plugin/SmpDescriptorExtractorTest.java | 8 ++-- .../sonia/scm/plugin/scm-g-plugin.xml | 46 ++++++++++--------- .../sonia/scm/plugin/scm-h-plugin.xml | 46 ++++++++++--------- .../sonia/scm/plugin/scm-i-plugin.xml | 46 ++++++++++--------- 6 files changed, 83 insertions(+), 73 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 96fa648976..1265a28977 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -105,6 +105,8 @@ public final class PluginProcessor //~--- constructors --------------------------------------------------------- + private final SmpDescriptorExtractor extractor = new SmpDescriptorExtractor(); + private ClassLoaderLifeCycle classLoaderLifeCycle; /** @@ -198,7 +200,7 @@ public final class PluginProcessor Map pending = new HashMap<>(); for (Path archive : archives) { - pending.put(archive, SmpDescriptorExtractor.extractPluginDescriptor(archive)); + pending.put(archive, extractor.extractPluginDescriptor(archive)); } PluginInstallationContext installationContext = PluginInstallationContext.fromDescriptors( diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java b/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java index aae8439d19..6e1255175d 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java @@ -33,12 +33,12 @@ import java.nio.file.Path; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; -final class SmpDescriptorExtractor { + class SmpDescriptorExtractor { - private SmpDescriptorExtractor() { + public SmpDescriptorExtractor() { } - static InstalledPluginDescriptor extractPluginDescriptor(Path file) throws IOException { + InstalledPluginDescriptor extractPluginDescriptor(Path file) throws IOException { try (ZipInputStream zipInputStream = new ZipInputStream(Files.newInputStream(file), StandardCharsets.UTF_8)) { ZipEntry nextEntry; while ((nextEntry = zipInputStream.getNextEntry()) != null) { diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java index aa6e2223c3..79f5d29060 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java @@ -63,11 +63,13 @@ class SmpDescriptorExtractorTest { "\n" + "\n"; + private final SmpDescriptorExtractor extractor = new SmpDescriptorExtractor(); + @Test void shouldExtractPluginXml(@TempDir Path tempDir) throws IOException { Path pluginFile = createZipFile(tempDir, "META-INF/scm/plugin.xml", PLUGIN_XML); - InstalledPluginDescriptor installedPluginDescriptor = SmpDescriptorExtractor.extractPluginDescriptor(pluginFile); + InstalledPluginDescriptor installedPluginDescriptor = extractor.extractPluginDescriptor(pluginFile); Assertions.assertThat(installedPluginDescriptor.getInformation().getName()).isEqualTo("scm-test-plugin"); } @@ -76,14 +78,14 @@ class SmpDescriptorExtractorTest { void shouldFailWithoutPluginXml(@TempDir Path tempDir) throws IOException { Path pluginFile = createZipFile(tempDir, "META-INF/wrong/plugin.xml", PLUGIN_XML); - assertThrows(IOException.class, () -> SmpDescriptorExtractor.extractPluginDescriptor(pluginFile)); + assertThrows(IOException.class, () -> extractor.extractPluginDescriptor(pluginFile)); } @Test void shouldFailWithIllegalPluginXml(@TempDir Path tempDir) throws IOException { Path pluginFile = createZipFile(tempDir, "META-INF/scm/plugin.xml", "content"); - assertThrows(IOException.class, () -> SmpDescriptorExtractor.extractPluginDescriptor(pluginFile)); + assertThrows(IOException.class, () -> extractor.extractPluginDescriptor(pluginFile)); } Path createZipFile(Path tempDir, String internalFileName, String content) throws IOException { diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml index c6b3339938..8fca8bbdfc 100644 --- a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml +++ b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-g-plugin.xml @@ -1,27 +1,29 @@ + + 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. + +--> 2 diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml index fa2d0767cc..fb7977da6f 100644 --- a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml +++ b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-h-plugin.xml @@ -1,27 +1,29 @@ + + 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. + +--> 2 diff --git a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml index 0fd2c1999a..e49dd45da8 100644 --- a/scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml +++ b/scm-webapp/src/test/resources/sonia/scm/plugin/scm-i-plugin.xml @@ -1,27 +1,29 @@ + + 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. + +--> 2 From a447b44da36f550ea60813e577c0bd8f254359d2 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Fri, 7 Aug 2020 11:48:26 +0200 Subject: [PATCH 08/21] Add missing translation for new exceptions --- .../src/main/resources/locales/de/plugins.json | 12 ++++++++++++ .../src/main/resources/locales/en/plugins.json | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index e5972f31c9..ebb15d7038 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -241,6 +241,18 @@ "displayName": "Fehler beim Löschen falscher Downloads", "description": "Ein fehlerhaft heruntergeladenes Plugin konnte nicht gelöscht werden. Bitte prĂ¼fen Sie die Server Logs und löschen die Datei manuell." }, + "5GS6lwvWF1": { + "displayName": "Abhänigkeit konnte nicht gefunden werden", + "description": "Eine der Abhänigkeiten des Plugins konnte nicht gefunden werden. Bitte prĂ¼fen Sie die Logs fĂ¼r weitere Informationen." + }, + "E5S6niWwi1": { + "displayName": "Version einer Abhänigkeit zu niedrig", + "description": "Die Version einer Abhänigkeit des Plugin ist zu niedrig. Bitte prĂ¼fen Sie die Logs fĂ¼r weitere Informationen." + }, + "4RS6niPRX1": { + "displayName": "Plugin information stimmen nicht Ă¼berein", + "description": "Die Informationen des heruntergeladenen Plugins stimmen nicht mit den Informationen des Plugin Centers Ă¼berein. Bitte prĂ¼fen Sie die Logs fĂ¼r weitere Informationen." + }, "2qRyyaVcJ1": { "displayName": "UngĂ¼ltig formatiertes Element", "description": "Die Eingabe beinhaltete unfĂ¼ltige Formate. Bitte prĂ¼fen Sie die Server Logs fĂ¼r genauere Informationen." diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 8a897f82ff..5cb6ae09df 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -241,6 +241,18 @@ "displayName": "Error while cleaning up failed plugin", "description": "A failed plugin download could not be removed correctly. Please check the server log and remove the plugin manually." }, + "5GS6lwvWF1": { + "displayName": "Dependency not found", + "description": "One of the plugin dependencies could not be found. Please check the server logs for more details." + }, + "E5S6niWwi1": { + "displayName": "Dependency version mismatch", + "description": "The plugin depends on a newer version of an already installed plugin. Please check the server logs for more details." + }, + "4RS6niPRX1": { + "displayName": "Plugin information mismatch", + "description": "The downloaded plugin does not match the information provided by the plugin center. Please check the server logs for more details." + }, "2qRyyaVcJ1": { "displayName": "Invalid format in element", "description": "The input had some invalid formats. Please check the server log for further information." From 45ad4bdf0956516a1a9ebcd618a44ba90d2298c9 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Fri, 7 Aug 2020 11:51:26 +0200 Subject: [PATCH 09/21] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1413127c..466a5e5393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added +- Check dependency versions on plugin installation ([#1283](https://github.com/scm-manager/scm-manager/pull/1283)) + ## [2.3.1] - 2020-08-04 ### Added - New api to resolve SCM-Manager root url ([#1276](https://github.com/scm-manager/scm-manager/pull/1276)) From c813a5f72350d82e9b4917a4bd1e578fb8bc2c85 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 10 Aug 2020 11:54:19 +0200 Subject: [PATCH 10/21] ".git" extension not allowed on the end of repository name --- scm-core/src/main/java/sonia/scm/util/ValidationUtil.java | 2 +- scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/util/ValidationUtil.java b/scm-core/src/main/java/sonia/scm/util/ValidationUtil.java index 0342e9b392..730a08c545 100644 --- a/scm-core/src/main/java/sonia/scm/util/ValidationUtil.java +++ b/scm-core/src/main/java/sonia/scm/util/ValidationUtil.java @@ -47,7 +47,7 @@ public final class ValidationUtil public static final String REGEX_NAME = "^[A-Za-z0-9\\.\\-_][A-Za-z0-9\\.\\-_@]*$"; - public static final String REGEX_REPOSITORYNAME = "(?!^\\.\\.$)(?!^\\.$)(?!.*[\\\\\\[\\]])^[A-Za-z0-9\\.][A-Za-z0-9\\.\\-_]*$"; + public static final String REGEX_REPOSITORYNAME = "(?!^\\.\\.$)(?!^\\.$)(?!.*[\\\\\\[\\]])(?!.*[.]git$)^[A-Za-z0-9\\.][A-Za-z0-9\\.\\-_]*$"; /** Field description */ private static final Pattern PATTERN_REPOSITORYNAME = Pattern.compile(REGEX_REPOSITORYNAME); diff --git a/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java b/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java index 184d5c3bd2..a5f81a7150 100644 --- a/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java +++ b/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java @@ -202,7 +202,9 @@ public class ValidationUtilTest "scm/main", "scm/plugins/git-plugin", "_scm", - "-scm" + "-scm", + "scm.git", + "scm.git.git" }; for (String path : validPaths) { From 1912084a71667bb42056d099ba83c2e14b5b68f5 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 10 Aug 2020 13:58:26 +0200 Subject: [PATCH 11/21] Update CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: RenĂ© Pfeuffer --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466a5e5393..bf0df3f0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added -- Check dependency versions on plugin installation ([#1283](https://github.com/scm-manager/scm-manager/pull/1283)) +- Check versions of plugin dependencies on plugin installation ([#1283](https://github.com/scm-manager/scm-manager/pull/1283)) ## [2.3.1] - 2020-08-04 ### Added From 3549ce86ec2e53eefce5fc26a3adf48da7c11a6c Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 10 Aug 2020 13:57:52 +0200 Subject: [PATCH 12/21] Add preselected value to options in DropDown.tsx if missing --- CHANGELOG.md | 1 + .../src/__snapshots__/storyshots.test.ts.snap | 42 +++++++++++++++++++ .../src/forms/DropDown.stories.tsx | 10 +++++ scm-ui/ui-components/src/forms/DropDown.tsx | 5 +++ 4 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 410d0f533e..259603e9a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Repository names may not end with ".git" ([#1277](https://github.com/scm-manager/scm-manager/pull/1277)) +- Add preselected value to options in dropdown component if missing ([#1287](https://github.com/scm-manager/scm-manager/pull/1287)) ## [2.3.1] - 2020-08-04 ### Added diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 92c6869962..05297adf93 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -38608,6 +38608,42 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = ` `; +exports[`Storyshots Forms|DropDown Add preselect if missing in options 1`] = ` +

+ +
+`; + exports[`Storyshots Forms|DropDown Default 1`] = `
The Meaning Of Liff +
`; diff --git a/scm-ui/ui-components/src/forms/DropDown.stories.tsx b/scm-ui/ui-components/src/forms/DropDown.stories.tsx index e8c9006b50..7e1e3ed995 100644 --- a/scm-ui/ui-components/src/forms/DropDown.stories.tsx +++ b/scm-ui/ui-components/src/forms/DropDown.stories.tsx @@ -48,4 +48,14 @@ storiesOf("Forms|DropDown", module) // nothing to do }} /> + )) + .add("Add preselect if missing in options", () => ( + { + // nothing to do + }} + /> )); diff --git a/scm-ui/ui-components/src/forms/DropDown.tsx b/scm-ui/ui-components/src/forms/DropDown.tsx index 8462d5bcb4..c141b35cd5 100644 --- a/scm-ui/ui-components/src/forms/DropDown.tsx +++ b/scm-ui/ui-components/src/forms/DropDown.tsx @@ -36,6 +36,11 @@ type Props = { class DropDown extends React.Component { render() { const { options, optionValues, preselectedOption, className, disabled } = this.props; + + if (preselectedOption && options.filter(o => o === preselectedOption).length === 0) { + options.push(preselectedOption); + } + return (