From 202a638a0ff933b07b82b01e5b8f9911536a8641 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Wed, 11 Sep 2019 14:51:38 +0200 Subject: [PATCH 01/15] Show updatable plugins --- .../v2/resources/InstalledPluginResource.java | 8 +- .../sonia/scm/api/v2/resources/PluginDto.java | 3 + .../resources/PluginDtoCollectionMapper.java | 7 +- .../scm/api/v2/resources/PluginDtoMapper.java | 31 +++- .../scm/plugin/DefaultPluginManager.java | 15 +- .../InstalledPluginResourceTest.java | 5 +- .../PluginDtoCollectionMapperTest.java | 171 ++++++++++++++++++ .../api/v2/resources/PluginDtoMapperTest.java | 4 +- .../scm/plugin/DefaultPluginManagerTest.java | 84 +++++---- 9 files changed, 277 insertions(+), 51 deletions(-) create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index bc9d4b397c..5b5d0f267b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -3,8 +3,8 @@ package sonia.scm.api.v2.resources; import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.InstalledPlugin; -import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; import sonia.scm.web.VndMediaType; @@ -50,7 +50,8 @@ public class InstalledPluginResource { public Response getInstalledPlugins() { PluginPermissions.read().check(); List plugins = pluginManager.getInstalled(); - return Response.ok(collectionMapper.mapInstalled(plugins)).build(); + List available = pluginManager.getAvailable(); + return Response.ok(collectionMapper.mapInstalled(plugins, available)).build(); } /** @@ -72,8 +73,9 @@ public class InstalledPluginResource { public Response getInstalledPlugin(@PathParam("name") String name) { PluginPermissions.read().check(); Optional pluginDto = pluginManager.getInstalled(name); + List available = pluginManager.getAvailable(); if (pluginDto.isPresent()) { - return Response.ok(mapper.mapInstalled(pluginDto.get())).build(); + return Response.ok(mapper.mapInstalled(pluginDto.get(), available)).build(); } else { throw notFound(entity("Plugin", name)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index bf20d1b67e..41d285d59c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import com.fasterxml.jackson.annotation.JsonInclude; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -16,6 +17,8 @@ public class PluginDto extends HalRepresentation { private String name; private String version; + @JsonInclude(JsonInclude.Include.NON_NULL) + private String newVersion; private String displayName; private String description; private String author; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index 7c1ee3d5a6..e8e85a0054 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -26,8 +26,11 @@ public class PluginDtoCollectionMapper { this.mapper = mapper; } - public HalRepresentation mapInstalled(List plugins) { - List dtos = plugins.stream().map(mapper::mapInstalled).collect(toList()); + public HalRepresentation mapInstalled(List plugins, List availablePlugins) { + List dtos = plugins + .stream() + .map(i -> mapper.mapInstalled(i, availablePlugins)) + .collect(toList()); return new HalRepresentation(createInstalledPluginsLinks(), embedDtos(dtos)); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 25faf0a101..00b363c6a3 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -11,6 +11,9 @@ import sonia.scm.plugin.PluginPermissions; import javax.inject.Inject; +import java.util.List; +import java.util.Optional; + import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @@ -22,8 +25,8 @@ public abstract class PluginDtoMapper { public abstract void map(PluginInformation plugin, @MappingTarget PluginDto dto); - public PluginDto mapInstalled(InstalledPlugin plugin) { - PluginDto dto = createDtoForInstalled(plugin); + public PluginDto mapInstalled(InstalledPlugin plugin, List availablePlugins) { + PluginDto dto = createDtoForInstalled(plugin, availablePlugins); map(dto, plugin); return dto; } @@ -57,13 +60,33 @@ public abstract class PluginDtoMapper { return new PluginDto(links.build()); } - private PluginDto createDtoForInstalled(InstalledPlugin plugin) { + private PluginDto createDtoForInstalled(InstalledPlugin plugin, List availablePlugins) { PluginInformation information = plugin.getDescriptor().getInformation(); + Optional availablePlugin = checkForUpdates(plugin, availablePlugins); Links.Builder links = linkingTo() .self(resourceLinks.installedPlugin() .self(information.getName())); + if (availablePlugin.isPresent() + && !availablePlugin.get().isPending() + && PluginPermissions.manage().isPermitted() + ) { + links.single(link("update", resourceLinks.availablePlugin().install(information.getName()))); + } - return new PluginDto(links.build()); + PluginDto dto = new PluginDto(links.build()); + + availablePlugin.ifPresent(value -> { + dto.setNewVersion(value.getDescriptor().getInformation().getVersion()); + dto.setPending(value.isPending()); + }); + + return dto; + } + + private Optional checkForUpdates(InstalledPlugin plugin, List availablePlugins) { + return availablePlugins.stream() + .filter(a -> a.getDescriptor().getInformation().getName().equals(plugin.getDescriptor().getInformation().getName())) + .findAny(); } } 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 807eaac317..9334f87822 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 org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; import sonia.scm.event.ScmEventBus; import sonia.scm.lifecycle.RestartEvent; +import sonia.scm.version.Version; //~--- JDK imports ------------------------------------------------------------ import javax.inject.Inject; @@ -83,7 +84,7 @@ public class DefaultPluginManager implements PluginManager { return center.getAvailable() .stream() .filter(filterByName(name)) - .filter(this::isNotInstalled) + .filter(this::isNotInstalledOrMoreUpToDate) .map(p -> getPending(name).orElse(p)) .findFirst(); } @@ -116,7 +117,7 @@ public class DefaultPluginManager implements PluginManager { PluginPermissions.read().check(); return center.getAvailable() .stream() - .filter(this::isNotInstalled) + .filter(this::isNotInstalledOrMoreUpToDate) .map(p -> getPending(p.getDescriptor().getInformation().getName()).orElse(p)) .collect(Collectors.toList()); } @@ -125,8 +126,14 @@ public class DefaultPluginManager implements PluginManager { return plugin -> name.equals(plugin.getDescriptor().getInformation().getName()); } - private boolean isNotInstalled(AvailablePlugin availablePlugin) { - return !getInstalled(availablePlugin.getDescriptor().getInformation().getName()).isPresent(); + private boolean isNotInstalledOrMoreUpToDate(AvailablePlugin availablePlugin) { + return getInstalled(availablePlugin.getDescriptor().getInformation().getName()) + .map(installedPlugin -> availableIsMoreUpToDateThanInstalled(availablePlugin, installedPlugin)) + .orElse(true); + } + + private boolean availableIsMoreUpToDateThanInstalled(AvailablePlugin availablePlugin, InstalledPlugin installed) { + return Version.parse(availablePlugin.getDescriptor().getInformation().getVersion()).isNewer(installed.getDescriptor().getInformation().getVersion()); } @Override diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index 7fa0081c5c..84cd827932 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -29,6 +29,7 @@ import java.net.URISyntaxException; import java.util.Collections; import java.util.Optional; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -87,7 +88,7 @@ class InstalledPluginResourceTest { void getInstalledPlugins() throws URISyntaxException, UnsupportedEncodingException { InstalledPlugin installedPlugin = createPlugin(); when(pluginManager.getInstalled()).thenReturn(Collections.singletonList(installedPlugin)); - when(collectionMapper.mapInstalled(Collections.singletonList(installedPlugin))).thenReturn(new MockedResultDto()); + when(collectionMapper.mapInstalled(Collections.singletonList(installedPlugin), Collections.emptyList())).thenReturn(new MockedResultDto()); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed"); request.accept(VndMediaType.PLUGIN_COLLECTION); @@ -110,7 +111,7 @@ class InstalledPluginResourceTest { PluginDto pluginDto = new PluginDto(); pluginDto.setName("pluginName"); - when(mapper.mapInstalled(installedPlugin)).thenReturn(pluginDto); + when(mapper.mapInstalled(installedPlugin, emptyList())).thenReturn(pluginDto); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/installed/pluginName"); request.accept(VndMediaType.PLUGIN); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java new file mode 100644 index 0000000000..fb368d12f2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java @@ -0,0 +1,171 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.subject.support.SubjectThreadState; +import org.apache.shiro.util.ThreadContext; +import org.apache.shiro.util.ThreadState; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.plugin.AvailablePlugin; +import sonia.scm.plugin.AvailablePluginDescriptor; +import sonia.scm.plugin.InstalledPlugin; +import sonia.scm.plugin.InstalledPluginDescriptor; +import sonia.scm.plugin.PluginInformation; + +import java.net.URI; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PluginDtoCollectionMapperTest { + + ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); + + @InjectMocks + PluginDtoMapperImpl pluginDtoMapper; + + Subject subject = mock(Subject.class); + ThreadState subjectThreadState = new SubjectThreadState(subject); + + @BeforeEach + void bindSubject() { + subjectThreadState.bind(); + ThreadContext.bind(subject); + } + + @AfterEach + public void unbindSubject() { + ThreadContext.unbindSubject(); + } + + + @Test + void shouldMapInstalledPluginsWithoutUpdateWhenNoNewerVersionIsAvailable() { + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + + HalRepresentation result = mapper.mapInstalled( + singletonList(createInstalledPlugin("scm-some-plugin", "1")), + singletonList(createAvailablePlugin("scm-other-plugin", "2"))); + + List plugins = result.getEmbedded().getItemsBy("plugins"); + assertThat(plugins).hasSize(1); + PluginDto plugin = (PluginDto) plugins.get(0); + assertThat(plugin.getVersion()).isEqualTo("1"); + assertThat(plugin.getNewVersion()).isNull(); + } + + @Test + void shouldSetNewVersionInInstalledPluginWhenAvailableVersionIsNewer() { + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + + HalRepresentation result = mapper.mapInstalled( + singletonList(createInstalledPlugin("scm-some-plugin", "1")), + singletonList(createAvailablePlugin("scm-some-plugin", "2"))); + + PluginDto plugin = getPluginDtoFromResult(result); + assertThat(plugin.getVersion()).isEqualTo("1"); + assertThat(plugin.getNewVersion()).isEqualTo("2"); + } + + @Test + void shouldNotAddInstallLinkForNewVersionWhenNotPermitted() { + when(subject.isPermitted("plugin:manage")).thenReturn(false); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + + HalRepresentation result = mapper.mapInstalled( + singletonList(createInstalledPlugin("scm-some-plugin", "1")), + singletonList(createAvailablePlugin("scm-some-plugin", "2"))); + + PluginDto plugin = getPluginDtoFromResult(result); + assertThat(plugin.getLinks().getLinkBy("update")).isEmpty(); + } + + @Test + void shouldNotAddInstallLinkForNewVersionWhenInstallationIsPending() { + when(subject.isPermitted("plugin:manage")).thenReturn(true); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + + AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2"); + when(availablePlugin.isPending()).thenReturn(true); + HalRepresentation result = mapper.mapInstalled( + singletonList(createInstalledPlugin("scm-some-plugin", "1")), + singletonList(availablePlugin)); + + PluginDto plugin = getPluginDtoFromResult(result); + assertThat(plugin.getLinks().getLinkBy("update")).isEmpty(); + } + + @Test + void shouldAddInstallLinkForNewVersionWhenPermitted() { + when(subject.isPermitted("plugin:manage")).thenReturn(true); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + + HalRepresentation result = mapper.mapInstalled( + singletonList(createInstalledPlugin("scm-some-plugin", "1")), + singletonList(createAvailablePlugin("scm-some-plugin", "2"))); + + PluginDto plugin = getPluginDtoFromResult(result); + assertThat(plugin.getLinks().getLinkBy("update")).isNotEmpty(); + } + + @Test + void shouldSetInstalledPluginPendingWhenCorrespondingAvailablePluginIsPending() { + when(subject.isPermitted("plugin:manage")).thenReturn(true); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + + AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2"); + when(availablePlugin.isPending()).thenReturn(true); + HalRepresentation result = mapper.mapInstalled( + singletonList(createInstalledPlugin("scm-some-plugin", "1")), + singletonList(availablePlugin)); + + PluginDto plugin = getPluginDtoFromResult(result); + assertThat(plugin.isPending()).isTrue(); + } + + private PluginDto getPluginDtoFromResult(HalRepresentation result) { + assertThat(result.getEmbedded().getItemsBy("plugins")).hasSize(1); + List plugins = result.getEmbedded().getItemsBy("plugins"); + return (PluginDto) plugins.get(0); + } + + private InstalledPlugin createInstalledPlugin(String name, String version) { + PluginInformation information = new PluginInformation(); + information.setName(name); + information.setVersion(version); + return createInstalledPlugin(information); + } + + private InstalledPlugin createInstalledPlugin(PluginInformation information) { + InstalledPlugin plugin = mock(InstalledPlugin.class); + InstalledPluginDescriptor descriptor = mock(InstalledPluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(information); + lenient().when(plugin.getDescriptor()).thenReturn(descriptor); + return plugin; + } + + private AvailablePlugin createAvailablePlugin(String name, String version) { + PluginInformation information = new PluginInformation(); + information.setName(name); + information.setVersion(version); + return createAvailablePlugin(information); + } + + private AvailablePlugin createAvailablePlugin(PluginInformation information) { + AvailablePlugin plugin = mock(AvailablePlugin.class); + AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(information); + lenient().when(plugin.getDescriptor()).thenReturn(descriptor); + return plugin; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java index 5cf6bdd45a..1dc5e3d135 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoMapperTest.java @@ -17,7 +17,9 @@ import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.PluginInformation; import java.net.URI; +import java.util.Collections; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -74,7 +76,7 @@ class PluginDtoMapperTest { void shouldAppendInstalledSelfLink() { InstalledPlugin plugin = createInstalled(); - PluginDto dto = mapper.mapInstalled(plugin); + PluginDto dto = mapper.mapInstalled(plugin, emptyList()); assertThat(dto.getLinks().getLinkBy("self").get().getHref()) .isEqualTo("https://hitchhiker.com/v2/plugins/installed/scm-cas-plugin"); } 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 322163ee1a..705319f63f 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -23,7 +23,6 @@ import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.in; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; @@ -71,8 +70,8 @@ class DefaultPluginManagerTest { @Test void shouldReturnInstalledPlugins() { - InstalledPlugin review = createInstalled("scm-review-plugin"); - InstalledPlugin git = createInstalled("scm-git-plugin"); + InstalledPlugin review = createInstalled("scm-review-plugin", "1"); + InstalledPlugin git = createInstalled("scm-git-plugin", "1"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); @@ -82,8 +81,8 @@ class DefaultPluginManagerTest { @Test void shouldReturnReviewPlugin() { - InstalledPlugin review = createInstalled("scm-review-plugin"); - InstalledPlugin git = createInstalled("scm-git-plugin"); + InstalledPlugin review = createInstalled("scm-review-plugin", "1"); + InstalledPlugin git = createInstalled("scm-git-plugin", "1"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(review, git)); @@ -101,8 +100,8 @@ class DefaultPluginManagerTest { @Test void shouldReturnAvailablePlugins() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - AvailablePlugin git = createAvailable("scm-git-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); + AvailablePlugin git = createAvailable("scm-git-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); @@ -111,22 +110,35 @@ class DefaultPluginManagerTest { } @Test - void shouldFilterOutAllInstalled() { - InstalledPlugin installedGit = createInstalled("scm-git-plugin"); + void shouldFilterOutAllInstalledWithSameVersion() { + InstalledPlugin installedGit = createInstalled("scm-git-plugin", "1"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); - AvailablePlugin review = createAvailable("scm-review-plugin"); - AvailablePlugin git = createAvailable("scm-git-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); + AvailablePlugin git = createAvailable("scm-git-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); List available = manager.getAvailable(); assertThat(available).containsOnly(review); } + @Test + void shouldKeepInstalledWithOlderVersion() { + InstalledPlugin installedGit = createInstalled("scm-git-plugin", "1"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); + + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); + AvailablePlugin git = createAvailable("scm-git-plugin", "1.1"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + + List available = manager.getAvailable(); + assertThat(available).contains(git, review); + } + @Test void shouldReturnAvailable() { - AvailablePlugin review = createAvailable("scm-review-plugin"); - AvailablePlugin git = createAvailable("scm-git-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); + AvailablePlugin git = createAvailable("scm-git-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); Optional available = manager.getAvailable("scm-git-plugin"); @@ -135,7 +147,7 @@ class DefaultPluginManagerTest { @Test void shouldReturnEmptyForNonExistingAvailable() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); Optional available = manager.getAvailable("scm-git-plugin"); @@ -144,10 +156,10 @@ class DefaultPluginManagerTest { @Test void shouldReturnEmptyForInstalledPlugin() { - InstalledPlugin installedGit = createInstalled("scm-git-plugin"); + InstalledPlugin installedGit = createInstalled("scm-git-plugin", "1"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); - AvailablePlugin git = createAvailable("scm-git-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); Optional available = manager.getAvailable("scm-git-plugin"); @@ -156,7 +168,7 @@ class DefaultPluginManagerTest { @Test void shouldInstallThePlugin() { - AvailablePlugin git = createAvailable("scm-git-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); manager.install("scm-git-plugin", false); @@ -167,9 +179,9 @@ class DefaultPluginManagerTest { @Test void shouldInstallDependingPlugins() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); - AvailablePlugin mail = createAvailable("scm-mail-plugin"); + AvailablePlugin mail = createAvailable("scm-mail-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); manager.install("scm-review-plugin", false); @@ -180,12 +192,12 @@ class DefaultPluginManagerTest { @Test void shouldNotInstallAlreadyInstalledDependencies() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); - AvailablePlugin mail = createAvailable("scm-mail-plugin"); + AvailablePlugin mail = createAvailable("scm-mail-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); - InstalledPlugin installedMail = createInstalled("scm-mail-plugin"); + InstalledPlugin installedMail = createInstalled("scm-mail-plugin", "1"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); manager.install("scm-review-plugin", false); @@ -198,11 +210,11 @@ class DefaultPluginManagerTest { @Test void shouldRollbackOnFailedInstallation() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); - AvailablePlugin mail = createAvailable("scm-mail-plugin"); + AvailablePlugin mail = createAvailable("scm-mail-plugin", "1"); when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); - AvailablePlugin notification = createAvailable("scm-notification-plugin"); + AvailablePlugin notification = createAvailable("scm-notification-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail, notification)); PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class); @@ -221,9 +233,9 @@ class DefaultPluginManagerTest { @Test void shouldInstallNothingIfOneOfTheDependenciesIsNotAvailable() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); - AvailablePlugin mail = createAvailable("scm-mail-plugin"); + AvailablePlugin mail = createAvailable("scm-mail-plugin", "1"); when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); @@ -234,7 +246,7 @@ class DefaultPluginManagerTest { @Test void shouldSendRestartEventAfterInstallation() { - AvailablePlugin git = createAvailable("scm-git-plugin"); + AvailablePlugin git = createAvailable("scm-git-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); manager.install("scm-git-plugin", true); @@ -245,7 +257,7 @@ class DefaultPluginManagerTest { @Test void shouldNotSendRestartEventIfNoPluginWasInstalled() { - InstalledPlugin gitInstalled = createInstalled("scm-git-plugin"); + InstalledPlugin gitInstalled = createInstalled("scm-git-plugin", "1"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(gitInstalled)); manager.install("scm-git-plugin", true); @@ -254,7 +266,7 @@ class DefaultPluginManagerTest { @Test void shouldNotInstallAlreadyPendingPlugins() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); @@ -265,7 +277,7 @@ class DefaultPluginManagerTest { @Test void shouldSendRestartEvent() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); @@ -283,7 +295,7 @@ class DefaultPluginManagerTest { @Test void shouldReturnSingleAvailableAsPending() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); @@ -294,7 +306,7 @@ class DefaultPluginManagerTest { @Test void shouldReturnAvailableAsPending() { - AvailablePlugin review = createAvailable("scm-review-plugin"); + AvailablePlugin review = createAvailable("scm-review-plugin", "1"); when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); @@ -355,15 +367,17 @@ class DefaultPluginManagerTest { } - private AvailablePlugin createAvailable(String name) { + private AvailablePlugin createAvailable(String name, String version) { PluginInformation information = new PluginInformation(); information.setName(name); + information.setVersion(version); return createAvailable(information); } - private InstalledPlugin createInstalled(String name) { + private InstalledPlugin createInstalled(String name, String version) { PluginInformation information = new PluginInformation(); information.setName(name); + information.setVersion(version); return createInstalled(information); } From a7cb1d311621538761f506aeb944f515f97d2f4f Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Wed, 11 Sep 2019 15:05:25 +0200 Subject: [PATCH 02/15] Make updatable plugins installable --- .../scm/plugin/DefaultPluginManager.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 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 9334f87822..56850758a2 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -178,22 +178,18 @@ public class DefaultPluginManager implements PluginManager { private List collectPluginsToInstall(String name) { List plugins = new ArrayList<>(); - collectPluginsToInstall(plugins, name); + collectPluginsToInstall(plugins, name, true); return plugins; } - private boolean isInstalledOrPending(String name) { - return getInstalled(name).isPresent() || getPending(name).isPresent(); - } - - private void collectPluginsToInstall(List plugins, String name) { - if (!isInstalledOrPending(name)) { + private void collectPluginsToInstall(List plugins, String name, boolean isUpdate) { + if (!isInstalledOrPending(name) || isUpdate && isUpdatable(name)) { AvailablePlugin plugin = getAvailable(name).orElseThrow(() -> NotFoundException.notFound(entity(AvailablePlugin.class, name))); Set dependencies = plugin.getDescriptor().getDependencies(); if (dependencies != null) { for (String dependency: dependencies){ - collectPluginsToInstall(plugins, dependency); + collectPluginsToInstall(plugins, dependency, false); } } @@ -202,4 +198,12 @@ public class DefaultPluginManager implements PluginManager { LOG.info("plugin {} is already installed or installation is pending, skipping installation", name); } } + + private boolean isInstalledOrPending(String name) { + return getInstalled(name).isPresent() || getPending(name).isPresent(); + } + + private boolean isUpdatable(String name) { + return getAvailable(name).isPresent() && !getPending(name).isPresent(); + } } From 0fdd1cea178e67357652c50a15640c327c6dd3b8 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Wed, 11 Sep 2019 16:46:27 +0200 Subject: [PATCH 03/15] Detect core plugins and prevent installation --- .../sonia/scm/plugin/InstalledPlugin.java | 13 +++++++--- .../sonia/scm/api/v2/resources/PluginDto.java | 2 ++ .../scm/api/v2/resources/PluginDtoMapper.java | 5 +++- .../scm/plugin/DefaultPluginManager.java | 9 +++++++ .../sonia/scm/plugin/PluginProcessor.java | 5 +++- .../scm/plugin/DefaultPluginManagerTest.java | 26 +++++++++++++++++++ .../DefaultUberWebResourceLoaderTest.java | 2 +- 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java index 2021d4d00f..80ad1da7b8 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java @@ -47,19 +47,20 @@ public final class InstalledPlugin implements Plugin /** * Constructs a new plugin wrapper. - * - * @param descriptor wrapped plugin + * @param descriptor wrapped plugin * @param classLoader plugin class loader * @param webResourceLoader web resource loader * @param directory plugin directory + * @param core marked as core or not */ public InstalledPlugin(InstalledPluginDescriptor descriptor, ClassLoader classLoader, - WebResourceLoader webResourceLoader, Path directory) + WebResourceLoader webResourceLoader, Path directory, boolean core) { this.descriptor = descriptor; this.classLoader = classLoader; this.webResourceLoader = webResourceLoader; this.directory = directory; + this.core = core; } //~--- get methods ---------------------------------------------------------- @@ -120,6 +121,10 @@ public final class InstalledPlugin implements Plugin return webResourceLoader; } + public boolean isCore() { + return core; + } + //~--- fields --------------------------------------------------------------- /** plugin class loader */ @@ -133,4 +138,6 @@ public final class InstalledPlugin implements Plugin /** plugin web resource loader */ private final WebResourceLoader webResourceLoader; + + private final boolean core; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java index 41d285d59c..d9782643b2 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDto.java @@ -25,6 +25,8 @@ public class PluginDto extends HalRepresentation { private String category; private String avatarUrl; private boolean pending; + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean core; private Set dependencies; public PluginDto(Links links) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 00b363c6a3..222e34832a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java @@ -67,7 +67,8 @@ public abstract class PluginDtoMapper { Links.Builder links = linkingTo() .self(resourceLinks.installedPlugin() .self(information.getName())); - if (availablePlugin.isPresent() + if (!plugin.isCore() + && availablePlugin.isPresent() && !availablePlugin.get().isPending() && PluginPermissions.manage().isPermitted() ) { @@ -81,6 +82,8 @@ public abstract class PluginDtoMapper { dto.setPending(value.isPending()); }); + dto.setCore(plugin.isCore()); + return dto; } 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 56850758a2..2c3e92d618 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -40,6 +40,7 @@ import com.google.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; +import sonia.scm.ScmConstraintViolationException; import sonia.scm.event.ScmEventBus; import sonia.scm.lifecycle.RestartEvent; import sonia.scm.version.Version; @@ -54,6 +55,7 @@ import java.util.function.Predicate; import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; /** * @@ -139,6 +141,13 @@ public class DefaultPluginManager implements PluginManager { @Override public void install(String name, boolean restartAfterInstallation) { PluginPermissions.manage().check(); + + getInstalled(name) + .map(InstalledPlugin::isCore) + .ifPresent( + core -> doThrow().violation("plugin is a core plugin and cannot be updated").when(core) + ); + List plugins = collectPluginsToInstall(name); List pendingInstallations = new ArrayList<>(); for (AvailablePlugin plugin : plugins) { 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 fced7a01ed..e1f0367948 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -461,13 +461,16 @@ public final class PluginProcessor Path descriptorPath = directory.resolve(PluginConstants.FILE_DESCRIPTOR); if (Files.exists(descriptorPath)) { + + boolean core = Files.exists(directory.resolve("core")); + ClassLoader cl = createClassLoader(classLoader, smp); InstalledPluginDescriptor descriptor = createDescriptor(cl, descriptorPath); WebResourceLoader resourceLoader = createWebResourceLoader(directory); - plugin = new InstalledPlugin(descriptor, cl, resourceLoader, directory); + plugin = new InstalledPlugin(descriptor, cl, resourceLoader, directory, core); } else { logger.warn("found plugin directory without plugin descriptor"); } 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 705319f63f..6551b7d51d 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -16,6 +16,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.NotFoundException; +import sonia.scm.ScmConstraintViolationException; import sonia.scm.event.ScmEventBus; import sonia.scm.lifecycle.RestartEvent; @@ -177,6 +178,31 @@ class DefaultPluginManagerTest { verify(eventBus, never()).post(any()); } + @Test + void shouldUpdateNormalPlugin() { + AvailablePlugin available = createAvailable("scm-git-plugin", "2"); + InstalledPlugin installed = createInstalled("scm-git-plugin", "1"); + when(installed.isCore()).thenReturn(false); + lenient().when(center.getAvailable()).thenReturn(ImmutableSet.of(available)); + when(loader.getInstalledPlugins()).thenReturn(ImmutableSet.of(installed)); + + manager.install("scm-git-plugin", false); + + verify(installer).install(available); + verify(eventBus, never()).post(any()); + } + + @Test + void shouldNotUpdateCorePlugin() { + AvailablePlugin available = createAvailable("scm-git-plugin", "2"); + InstalledPlugin installed = createInstalled("scm-git-plugin", "1"); + when(installed.isCore()).thenReturn(true); + lenient().when(center.getAvailable()).thenReturn(ImmutableSet.of(available)); + when(loader.getInstalledPlugins()).thenReturn(ImmutableSet.of(installed)); + + assertThrows(ScmConstraintViolationException.class, () -> manager.install("scm-git-plugin", false)); + } + @Test void shouldInstallDependingPlugins() { AvailablePlugin review = createAvailable("scm-review-plugin", "1"); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java index 7cb534c7ba..438d1ab305 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultUberWebResourceLoaderTest.java @@ -248,7 +248,7 @@ public class DefaultUberWebResourceLoaderTest extends WebResourceLoaderTestBase private InstalledPlugin createPluginWrapper(Path directory) { return new InstalledPlugin(null, null, new PathWebResourceLoader(directory), - directory); + directory, false); } //~--- fields --------------------------------------------------------------- From 67d924f5b4ee1f5739cac10fe3dd62d25cf6e913 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Wed, 11 Sep 2019 17:01:47 +0200 Subject: [PATCH 04/15] Do not list available plugins in REST API when they are installed --- .../v2/resources/AvailablePluginResource.java | 10 +++- .../AvailablePluginResourceTest.java | 48 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 3ee4a4ea73..dab5c2652e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -4,7 +4,7 @@ import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import sonia.scm.plugin.AvailablePlugin; -import sonia.scm.plugin.InstalledPluginDescriptor; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; import sonia.scm.web.VndMediaType; @@ -19,6 +19,7 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -51,10 +52,15 @@ public class AvailablePluginResource { @Produces(VndMediaType.PLUGIN_COLLECTION) public Response getAvailablePlugins() { PluginPermissions.read().check(); - List available = pluginManager.getAvailable(); + List installed = pluginManager.getInstalled(); + List available = pluginManager.getAvailable().stream().filter(a -> notInstalled(a, installed)).collect(Collectors.toList()); return Response.ok(collectionMapper.mapAvailable(available)).build(); } + private boolean notInstalled(AvailablePlugin a, List installed) { + return installed.stream().noneMatch(installedPlugin -> installedPlugin.getDescriptor().getInformation().getName().equals(a.getDescriptor().getInformation().getName())); + } + /** * Returns available plugin. * diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index c108d4ee7a..436e0b630a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -18,6 +18,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePluginDescriptor; +import sonia.scm.plugin.InstalledPlugin; +import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginCondition; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginManager; @@ -25,7 +27,6 @@ import sonia.scm.web.VndMediaType; import javax.inject.Provider; import javax.servlet.http.HttpServletResponse; - import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; import java.util.Collections; @@ -34,6 +35,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -90,9 +92,10 @@ class AvailablePluginResourceTest { @Test void getAvailablePlugins() throws URISyntaxException, UnsupportedEncodingException { - AvailablePlugin plugin = createPlugin(); + AvailablePlugin plugin = createAvailablePlugin(); when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(plugin)); + when(pluginManager.getInstalled()).thenReturn(Collections.emptyList()); when(collectionMapper.mapAvailable(Collections.singletonList(plugin))).thenReturn(new MockedResultDto()); MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available"); @@ -105,13 +108,32 @@ class AvailablePluginResourceTest { assertThat(response.getContentAsString()).contains("\"marker\":\"x\""); } + @Test + void shouldNotReturnInstalledPlugins() throws URISyntaxException, UnsupportedEncodingException { + AvailablePlugin availablePlugin = createAvailablePlugin(); + InstalledPlugin installedPlugin = createInstalledPlugin(); + + when(pluginManager.getAvailable()).thenReturn(Collections.singletonList(availablePlugin)); + when(pluginManager.getInstalled()).thenReturn(Collections.singletonList(installedPlugin)); + lenient().when(collectionMapper.mapAvailable(Collections.singletonList(availablePlugin))).thenReturn(new MockedResultDto()); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available"); + request.accept(VndMediaType.PLUGIN_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); + assertThat(response.getContentAsString()).doesNotContain("\"marker\":\"x\""); + } + @Test void getAvailablePlugin() throws UnsupportedEncodingException, URISyntaxException { PluginInformation pluginInformation = new PluginInformation(); pluginInformation.setName("pluginName"); pluginInformation.setVersion("2.0.0"); - AvailablePlugin plugin = createPlugin(pluginInformation); + AvailablePlugin plugin = createAvailablePlugin(pluginInformation); when(pluginManager.getAvailable("pluginName")).thenReturn(Optional.of(plugin)); @@ -152,17 +174,31 @@ class AvailablePluginResourceTest { } } - private AvailablePlugin createPlugin() { - return createPlugin(new PluginInformation()); + private AvailablePlugin createAvailablePlugin() { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setName("scm-some-plugin"); + return createAvailablePlugin(pluginInformation); } - private AvailablePlugin createPlugin(PluginInformation pluginInformation) { + private AvailablePlugin createAvailablePlugin(PluginInformation pluginInformation) { AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( pluginInformation, new PluginCondition(), Collections.emptySet(), "https://download.hitchhiker.com", null ); return new AvailablePlugin(descriptor); } + private InstalledPlugin createInstalledPlugin() { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setName("scm-some-plugin"); + return createInstalledPlugin(pluginInformation); + } + + private InstalledPlugin createInstalledPlugin(PluginInformation pluginInformation) { + InstalledPluginDescriptor descriptor = mock(InstalledPluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(pluginInformation); + return new InstalledPlugin(descriptor, null, null, null, false); + } + @Nested class WithoutAuthorization { From b7aa2ea1cd8a8283c43b1cdb4143e06e90b36eb4 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Fri, 13 Sep 2019 12:01:07 +0200 Subject: [PATCH 05/15] Fix unit test --- .../AvailablePluginResourceTest.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 436e0b630a..705e13c3ff 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -1,6 +1,7 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; +import org.apache.shiro.ShiroException; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.jboss.resteasy.core.Dispatcher; @@ -35,6 +36,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -65,7 +68,8 @@ class AvailablePluginResourceTest { PluginRootResource pluginRootResource; - private final Subject subject = mock(Subject.class); + @Mock + Subject subject; @BeforeEach @@ -82,7 +86,7 @@ class AvailablePluginResourceTest { @BeforeEach void bindSubject() { ThreadContext.bind(subject); - when(subject.isPermitted(any(String.class))).thenReturn(true); + doNothing().when(subject).checkPermission(any(String.class)); } @AfterEach @@ -203,10 +207,15 @@ class AvailablePluginResourceTest { class WithoutAuthorization { @BeforeEach - void unbindSubject() { - ThreadContext.unbindSubject(); + void bindSubject() { + ThreadContext.bind(subject); + doThrow(new ShiroException()).when(subject).checkPermission(any(String.class)); } + @AfterEach + public void unbindSubject() { + ThreadContext.unbindSubject(); + } @Test void shouldNotGetAvailablePluginsIfMissingPermission() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.get("/v2/plugins/available"); @@ -214,6 +223,7 @@ class AvailablePluginResourceTest { MockHttpResponse response = new MockHttpResponse(); assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + verify(subject).checkPermission(any(String.class)); } @Test @@ -223,16 +233,17 @@ class AvailablePluginResourceTest { MockHttpResponse response = new MockHttpResponse(); assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + verify(subject).checkPermission(any(String.class)); } @Test void shouldNotInstallPluginIfMissingPermission() throws URISyntaxException { - ThreadContext.unbindSubject(); MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/pluginName/install"); request.accept(VndMediaType.PLUGIN); MockHttpResponse response = new MockHttpResponse(); assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + verify(subject).checkPermission(any(String.class)); } } From 0ceb1ad295bf9ae0c93dea7b598a4a25b1b9cd27 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Fri, 13 Sep 2019 14:17:06 +0200 Subject: [PATCH 06/15] Add dedicated endpoint for pending plugins --- .../v2/resources/AvailablePluginResource.java | 12 -- .../api/v2/resources/IndexDtoGenerator.java | 3 + .../v2/resources/PendingPluginResource.java | 77 +++++++ .../resources/PluginDtoCollectionMapper.java | 4 - .../api/v2/resources/PluginRootResource.java | 7 +- .../scm/api/v2/resources/ResourceLinks.java | 20 +- .../AvailablePluginResourceTest.java | 16 +- .../InstalledPluginResourceTest.java | 2 +- .../resources/PendingPluginResourceTest.java | 192 ++++++++++++++++++ .../api/v2/resources/ResourceLinksMock.java | 1 + 10 files changed, 299 insertions(+), 35 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index dab5c2652e..5d77470637 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -101,16 +101,4 @@ public class AvailablePluginResource { pluginManager.install(name, restartAfterInstallation); return Response.ok().build(); } - - @POST - @Path("/install-pending") - @StatusCodes({ - @ResponseCode(code = 200, condition = "success"), - @ResponseCode(code = 500, condition = "internal server error") - }) - public Response installPending() { - PluginPermissions.manage().check(); - pluginManager.installPendingAndRestart(); - return Response.ok().build(); - } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index d05596abd5..b54c831662 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -54,6 +54,9 @@ public class IndexDtoGenerator extends HalAppenderMapper { builder.single(link("installedPlugins", resourceLinks.installedPluginCollection().self())); builder.single(link("availablePlugins", resourceLinks.availablePluginCollection().self())); } + if (PluginPermissions.manage().isPermitted()) { + builder.single(link("pendingPlugins", resourceLinks.pendingPluginCollection().self())); + } if (UserPermissions.list().isPermitted()) { builder.single(link("users", resourceLinks.userCollection().self())); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java new file mode 100644 index 0000000000..c9d9982ccc --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java @@ -0,0 +1,77 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import sonia.scm.plugin.AvailablePlugin; +import sonia.scm.plugin.PluginManager; +import sonia.scm.plugin.PluginPermissions; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; +import java.util.List; + +import static de.otto.edison.hal.Link.link; +import static de.otto.edison.hal.Links.linkingTo; +import static java.util.stream.Collectors.toList; + +public class PendingPluginResource { + + private final PluginManager pluginManager; + private final ResourceLinks resourceLinks; + private final PluginDtoMapper mapper; + + @Inject + public PendingPluginResource(PluginManager pluginManager, ResourceLinks resourceLinks, PluginDtoMapper mapper) { + this.pluginManager = pluginManager; + this.resourceLinks = resourceLinks; + this.mapper = mapper; + } + + @GET + @Path("") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.PLUGIN_COLLECTION) + public Response getPending() { + PluginPermissions.manage().check(); + + List pending = pluginManager + .getAvailable() + .stream() + .filter(AvailablePlugin::isPending) + .collect(toList()); + + Links.Builder linksBuilder = linkingTo().self(resourceLinks.pendingPluginCollection().self()); + + if (!pending.isEmpty()) { + linksBuilder.single(link("install", resourceLinks.pendingPluginCollection().installPending())); + } + + Embedded.Builder embedded = Embedded.embeddedBuilder(); + embedded.with("available", pending.stream().map(mapper::mapAvailable).collect(toList())); + + return Response.ok(new HalRepresentation(linksBuilder.build(), embedded.build())).build(); + } + + @POST + @Path("/install") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response installPending() { + PluginPermissions.manage().check(); + pluginManager.installPendingAndRestart(); + return Response.ok().build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index e8e85a0054..3d817625c7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -53,10 +53,6 @@ public class PluginDtoCollectionMapper { Links.Builder linksBuilder = linkingTo() .with(Links.linkingTo().self(baseUrl).build()); - if (PluginPermissions.manage().isPermitted() && containsPending(plugins)) { - linksBuilder.single(Link.link("installPending", resourceLinks.availablePluginCollection().installPending())); - } - return linksBuilder.build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java index 79c46369a3..14abbe73d8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginRootResource.java @@ -9,11 +9,13 @@ public class PluginRootResource { private Provider installedPluginResourceProvider; private Provider availablePluginResourceProvider; + private Provider pendingPluginResourceProvider; @Inject - public PluginRootResource(Provider installedPluginResourceProvider, Provider availablePluginResourceProvider) { + public PluginRootResource(Provider installedPluginResourceProvider, Provider availablePluginResourceProvider, Provider pendingPluginResourceProvider) { this.installedPluginResourceProvider = installedPluginResourceProvider; this.availablePluginResourceProvider = availablePluginResourceProvider; + this.pendingPluginResourceProvider = pendingPluginResourceProvider; } @Path("/installed") @@ -23,4 +25,7 @@ public class PluginRootResource { @Path("/available") public AvailablePluginResource availablePlugins() { return availablePluginResourceProvider.get(); } + + @Path("/pending") + public PendingPluginResource pendingPlugins() { return pendingPluginResourceProvider.get(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index bf92c567cd..3a797734ea 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -715,12 +715,28 @@ class ResourceLinks { availablePluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, AvailablePluginResource.class); } + String self() { + return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugins").parameters().href(); + } + } + + public PendingPluginCollectionLinks pendingPluginCollection() { + return new PendingPluginCollectionLinks(scmPathInfoStore.get()); + } + + static class PendingPluginCollectionLinks { + private final LinkBuilder pendingPluginCollectionLinkBuilder; + + PendingPluginCollectionLinks(ScmPathInfo pathInfo) { + pendingPluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PendingPluginResource.class); + } + String installPending() { - return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("installPending").parameters().href(); + return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("installPending").parameters().href(); } String self() { - return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugins").parameters().href(); + return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("getPending").parameters().href(); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 705e13c3ff..32eadf7af0 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -48,9 +48,6 @@ class AvailablePluginResourceTest { private Dispatcher dispatcher; - @Mock - Provider installedPluginResourceProvider; - @Mock Provider availablePluginResourceProvider; @@ -75,7 +72,7 @@ class AvailablePluginResourceTest { @BeforeEach void prepareEnvironment() { dispatcher = MockDispatcherFactory.createDispatcher(); - pluginRootResource = new PluginRootResource(installedPluginResourceProvider, availablePluginResourceProvider); + pluginRootResource = new PluginRootResource(null, availablePluginResourceProvider, null); when(availablePluginResourceProvider.get()).thenReturn(availablePluginResource); dispatcher.getRegistry().addSingletonResource(pluginRootResource); } @@ -165,17 +162,6 @@ class AvailablePluginResourceTest { verify(pluginManager).install("pluginName", false); assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); } - - @Test - void installPendingPlugin() throws URISyntaxException { - MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/install-pending"); - MockHttpResponse response = new MockHttpResponse(); - - dispatcher.invoke(request, response); - - verify(pluginManager).installPendingAndRestart(); - assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus()); - } } private AvailablePlugin createAvailablePlugin() { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java index 84cd827932..d231bc2cef 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/InstalledPluginResourceTest.java @@ -65,7 +65,7 @@ class InstalledPluginResourceTest { @BeforeEach void prepareEnvironment() { dispatcher = MockDispatcherFactory.createDispatcher(); - pluginRootResource = new PluginRootResource(installedPluginResourceProvider, availablePluginResourceProvider); + pluginRootResource = new PluginRootResource(installedPluginResourceProvider, null, null); when(installedPluginResourceProvider.get()).thenReturn(installedPluginResource); dispatcher.getRegistry().addSingletonResource(pluginRootResource); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java new file mode 100644 index 0000000000..065ff9d723 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java @@ -0,0 +1,192 @@ +package sonia.scm.api.v2.resources; + +import com.google.inject.util.Providers; +import org.apache.shiro.ShiroException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockDispatcherFactory; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.plugin.AvailablePlugin; +import sonia.scm.plugin.AvailablePluginDescriptor; +import sonia.scm.plugin.PluginCondition; +import sonia.scm.plugin.PluginInformation; +import sonia.scm.plugin.PluginManager; + +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.util.Collections; + +import static java.net.URI.create; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PendingPluginResourceTest { + + Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); + + ResourceLinks resourceLinks = ResourceLinksMock.createMock(create("/")); + + @Mock + PluginManager pluginManager; + @Mock + PluginDtoMapper mapper; + + @Mock + Subject subject; + + @InjectMocks + PendingPluginResource pendingPluginResource; + + MockHttpResponse response = new MockHttpResponse(); + + @BeforeEach + void prepareEnvironment() { + dispatcher = MockDispatcherFactory.createDispatcher(); + dispatcher.getProviderFactory().register(new PermissionExceptionMapper()); + PluginRootResource pluginRootResource = new PluginRootResource(null, null, Providers.of(pendingPluginResource)); + dispatcher.getRegistry().addSingletonResource(pluginRootResource); + } + + @BeforeEach + void mockMapper() { + lenient().when(mapper.mapAvailable(any())).thenAnswer(invocation -> { + PluginDto dto = new PluginDto(); + dto.setName(((AvailablePlugin)invocation.getArgument(0)).getDescriptor().getInformation().getName()); + return dto; + }); + } + + @Nested + class withAuthorization { + + @BeforeEach + void bindSubject() { + ThreadContext.bind(subject); + doNothing().when(subject).checkPermission("plugin:manage"); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldGetEmptyPluginListsWithoutInstallLinkWhenNoPendingPluginsPresent() throws URISyntaxException, UnsupportedEncodingException { + AvailablePlugin availablePlugin = createAvailablePlugin("not-available-plugin"); + when(availablePlugin.isPending()).thenReturn(false); + when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getContentAsString()).contains("\"_links\":{\"self\":{\"href\":\"/v2/plugins/pending\"}}"); + assertThat(response.getContentAsString()).doesNotContain("not-available-plugin"); + } + + @Test + void shouldGetPendingAvailablePluginListsWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { + AvailablePlugin availablePlugin = createAvailablePlugin("available-plugin"); + when(availablePlugin.isPending()).thenReturn(true); + when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getContentAsString()).contains("\"_embedded\":{\"available\":[{\"name\":\"available-plugin\""); + assertThat(response.getContentAsString()).contains("\"install\":{\"href\":\"/v2/plugins/pending/install\"}"); + System.out.println(response.getContentAsString()); + } + + @Test + void shouldInstallPendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/install"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + verify(pluginManager).installPendingAndRestart(); + } + } + + @Nested + class WithoutAuthorization { + + @BeforeEach + void bindSubject() { + ThreadContext.bind(subject); + doThrow(new ShiroException()).when(subject).checkPermission("plugin:manage"); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldNotListPendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + verify(pluginManager, never()).installPendingAndRestart(); + } + + @Test + void shouldNotInstallPendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/install"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + verify(pluginManager, never()).installPendingAndRestart(); + } + } + + static class PermissionExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(ShiroException exception) { + return Response.status(401).entity(exception.getMessage()).build(); + } + } + + private AvailablePlugin createAvailablePlugin(String name) { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setName(name); + return createAvailablePlugin(pluginInformation); + } + + private AvailablePlugin createAvailablePlugin(PluginInformation pluginInformation) { + AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( + pluginInformation, new PluginCondition(), Collections.emptySet(), "https://download.hitchhiker.com", null + ); + AvailablePlugin availablePlugin = mock(AvailablePlugin.class); + lenient().when(availablePlugin.getDescriptor()).thenReturn(descriptor); + return availablePlugin; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 1aef4e57cb..478b3efc92 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -38,6 +38,7 @@ public class ResourceLinksMock { when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(uriInfo)); when(resourceLinks.installedPluginCollection()).thenReturn(new ResourceLinks.InstalledPluginCollectionLinks(uriInfo)); when(resourceLinks.availablePluginCollection()).thenReturn(new ResourceLinks.AvailablePluginCollectionLinks(uriInfo)); + when(resourceLinks.pendingPluginCollection()).thenReturn(new ResourceLinks.PendingPluginCollectionLinks(uriInfo)); when(resourceLinks.installedPlugin()).thenReturn(new ResourceLinks.InstalledPluginLinks(uriInfo)); when(resourceLinks.availablePlugin()).thenReturn(new ResourceLinks.AvailablePluginLinks(uriInfo)); when(resourceLinks.uiPluginCollection()).thenReturn(new ResourceLinks.UIPluginCollectionLinks(uriInfo)); From 2e42d8be915db7601651216eb73964fe48f73ba5 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Fri, 13 Sep 2019 14:44:32 +0200 Subject: [PATCH 07/15] Return pending plugins in endpoint --- .../v2/resources/PendingPluginResource.java | 30 +++++++++- .../resources/PendingPluginResourceTest.java | 55 +++++++++++++++---- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java index c9d9982ccc..47e3d2f9f6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java @@ -6,6 +6,7 @@ import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import sonia.scm.plugin.AvailablePlugin; +import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginPermissions; import sonia.scm.web.VndMediaType; @@ -16,7 +17,9 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; +import java.util.Collection; import java.util.List; +import java.util.stream.Stream; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @@ -50,6 +53,14 @@ public class PendingPluginResource { .stream() .filter(AvailablePlugin::isPending) .collect(toList()); + List installed = pluginManager.getInstalled(); + + Stream newPlugins = pending + .stream() + .filter(a -> !contains(installed, a)); + Stream updatePlugins = installed + .stream() + .filter(i -> contains(pending, i)); Links.Builder linksBuilder = linkingTo().self(resourceLinks.pendingPluginCollection().self()); @@ -58,11 +69,28 @@ public class PendingPluginResource { } Embedded.Builder embedded = Embedded.embeddedBuilder(); - embedded.with("available", pending.stream().map(mapper::mapAvailable).collect(toList())); + embedded.with("new", newPlugins.map(mapper::mapAvailable).collect(toList())); + embedded.with("update", updatePlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList())); return Response.ok(new HalRepresentation(linksBuilder.build(), embedded.build())).build(); } + private boolean contains(Collection installedPlugins, AvailablePlugin availablePlugin) { + return installedPlugins + .stream() + .anyMatch(installedPlugin -> haveSameName(installedPlugin, availablePlugin)); + } + + private boolean contains(Collection availablePlugins, InstalledPlugin installedPlugin) { + return availablePlugins + .stream() + .anyMatch(availablePlugin -> haveSameName(installedPlugin, availablePlugin)); + } + + private boolean haveSameName(InstalledPlugin installedPlugin, AvailablePlugin availablePlugin) { + return installedPlugin.getDescriptor().getInformation().getName().equals(availablePlugin.getDescriptor().getInformation().getName()); + } + @POST @Path("/install") @StatusCodes({ diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java index 065ff9d723..d4b8a5bf5a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java @@ -18,7 +18,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePluginDescriptor; -import sonia.scm.plugin.PluginCondition; +import sonia.scm.plugin.InstalledPlugin; +import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginManager; @@ -27,7 +28,6 @@ import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; -import java.util.Collections; import static java.net.URI.create; import static java.util.Collections.singletonList; @@ -76,6 +76,11 @@ class PendingPluginResourceTest { dto.setName(((AvailablePlugin)invocation.getArgument(0)).getDescriptor().getInformation().getName()); return dto; }); + lenient().when(mapper.mapInstalled(any(), any())).thenAnswer(invocation -> { + PluginDto dto = new PluginDto(); + dto.setName(((InstalledPlugin)invocation.getArgument(0)).getDescriptor().getInformation().getName()); + return dto; + }); } @Nested @@ -94,7 +99,7 @@ class PendingPluginResourceTest { @Test void shouldGetEmptyPluginListsWithoutInstallLinkWhenNoPendingPluginsPresent() throws URISyntaxException, UnsupportedEncodingException { - AvailablePlugin availablePlugin = createAvailablePlugin("not-available-plugin"); + AvailablePlugin availablePlugin = createAvailablePlugin("not-pending-plugin"); when(availablePlugin.isPending()).thenReturn(false); when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); @@ -103,12 +108,12 @@ class PendingPluginResourceTest { assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); assertThat(response.getContentAsString()).contains("\"_links\":{\"self\":{\"href\":\"/v2/plugins/pending\"}}"); - assertThat(response.getContentAsString()).doesNotContain("not-available-plugin"); + assertThat(response.getContentAsString()).doesNotContain("not-pending-plugin"); } @Test - void shouldGetPendingAvailablePluginListsWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { - AvailablePlugin availablePlugin = createAvailablePlugin("available-plugin"); + void shouldGetPendingAvailablePluginListWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { + AvailablePlugin availablePlugin = createAvailablePlugin("pending-available-plugin"); when(availablePlugin.isPending()).thenReturn(true); when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); @@ -116,7 +121,24 @@ class PendingPluginResourceTest { dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); - assertThat(response.getContentAsString()).contains("\"_embedded\":{\"available\":[{\"name\":\"available-plugin\""); + assertThat(response.getContentAsString()).contains("\"new\":[{\"name\":\"pending-available-plugin\""); + assertThat(response.getContentAsString()).contains("\"install\":{\"href\":\"/v2/plugins/pending/install\"}"); + System.out.println(response.getContentAsString()); + } + + @Test + void shouldGetPendingUpdatePluginListWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { + AvailablePlugin availablePlugin = createAvailablePlugin("available-plugin"); + when(availablePlugin.isPending()).thenReturn(true); + when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); + InstalledPlugin installedPlugin = createInstalledPlugin("available-plugin"); + when(pluginManager.getInstalled()).thenReturn(singletonList(installedPlugin)); + + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getContentAsString()).contains("\"update\":[{\"name\":\"available-plugin\""); assertThat(response.getContentAsString()).contains("\"install\":{\"href\":\"/v2/plugins/pending/install\"}"); System.out.println(response.getContentAsString()); } @@ -182,11 +204,24 @@ class PendingPluginResourceTest { } private AvailablePlugin createAvailablePlugin(PluginInformation pluginInformation) { - AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( - pluginInformation, new PluginCondition(), Collections.emptySet(), "https://download.hitchhiker.com", null - ); + AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(pluginInformation); AvailablePlugin availablePlugin = mock(AvailablePlugin.class); lenient().when(availablePlugin.getDescriptor()).thenReturn(descriptor); return availablePlugin; } + + private InstalledPlugin createInstalledPlugin(String name) { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setName(name); + return createInstalledPlugin(pluginInformation); + } + + private InstalledPlugin createInstalledPlugin(PluginInformation pluginInformation) { + InstalledPluginDescriptor descriptor = mock(InstalledPluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(pluginInformation); + InstalledPlugin installedPlugin = mock(InstalledPlugin.class); + lenient().when(installedPlugin.getDescriptor()).thenReturn(descriptor); + return installedPlugin; + } } From ba59713c7f171d66ece39503cd0156a5b66b833c Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Mon, 16 Sep 2019 14:07:48 +0200 Subject: [PATCH 08/15] fix unit test --- scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java index 7e3577d775..2bd3dce1c2 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java @@ -10,12 +10,14 @@ public class PluginTestHelper { public static AvailablePlugin createAvailable(String name) { PluginInformation information = new PluginInformation(); information.setName(name); + information.setVersion("1.0"); return createAvailable(information); } public static InstalledPlugin createInstalled(String name) { PluginInformation information = new PluginInformation(); information.setName(name); + information.setVersion("1.0"); return createInstalled(information); } From d60918c8209fa7d85e2229d0cb2360b0dac813a0 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 16 Sep 2019 14:31:55 +0200 Subject: [PATCH 09/15] implement update function for plugins on frontend / adjust the plugin pending modal to show pending installations and updates --- .../packages/ui-types/src/Plugin.js | 9 + .../packages/ui-types/src/index.js | 2 +- scm-ui/public/locales/de/admin.json | 16 +- scm-ui/public/locales/en/admin.json | 16 +- ...ndingAction.js => ExecutePendingAction.js} | 18 +- ...PendingModal.js => ExecutePendingModal.js} | 75 +++-- .../{PluginModal.js => InstallPluginModal.js} | 10 +- .../admin/plugins/components/PluginEntry.js | 71 +++-- ...Notification.js => SuccessNotification.js} | 0 .../plugins/components/UpdatePluginModal.js | 288 ++++++++++++++++++ .../plugins/containers/PluginsOverview.js | 64 ++-- scm-ui/src/admin/plugins/modules/plugins.js | 68 ++++- scm-ui/src/modules/indexResource.js | 4 + .../v2/resources/PendingPluginResource.java | 11 +- 14 files changed, 568 insertions(+), 84 deletions(-) rename scm-ui/src/admin/plugins/components/{InstallPendingAction.js => ExecutePendingAction.js} (67%) rename scm-ui/src/admin/plugins/components/{InstallPendingModal.js => ExecutePendingModal.js} (55%) rename scm-ui/src/admin/plugins/components/{PluginModal.js => InstallPluginModal.js} (96%) rename scm-ui/src/admin/plugins/components/{InstallSuccessNotification.js => SuccessNotification.js} (100%) create mode 100644 scm-ui/src/admin/plugins/components/UpdatePluginModal.js diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index 0114716757..f7175fc209 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -4,6 +4,7 @@ import type {Collection, Links} from "./hal"; export type Plugin = { name: string, version: string, + newVersion: string, displayName: string, description?: string, author: string, @@ -24,3 +25,11 @@ export type PluginGroup = { name: string, plugins: Plugin[] }; + +export type PendingPlugins = { + _links: Links, + _embedded: { + new: [], + update: [], + } +} diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index ba2b9f5481..9207868a73 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -25,7 +25,7 @@ export type { SubRepository, File } from "./Sources"; export type { SelectValue, AutocompleteObject } from "./Autocomplete"; -export type { Plugin, PluginCollection, PluginGroup } from "./Plugin"; +export type { Plugin, PluginCollection, PluginGroup, PendingPlugins } from "./Plugin"; export type { RepositoryRole } from "./RepositoryRole"; diff --git a/scm-ui/public/locales/de/admin.json b/scm-ui/public/locales/de/admin.json index 8d9c876537..d2cd2c6640 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -29,22 +29,32 @@ "installedNavLink": "Installiert", "availableNavLink": "Verfügbar" }, - "installPending": "Austehende Plugins installieren", + "executePending": "Austehende Plugin-Änderungen ausführen", "noPlugins": "Keine Plugins gefunden.", "modal": { - "title": "{{name}} Plugin installieren", + "title": { + "install": "{{name}} Plugin installieren", + "update": "{{name}} Plugin aktualisieren" + }, "restart": "Neustarten um Plugin zu aktivieren", "install": "Installieren", + "update": "Aktualisieren", + "installQueue": "Werden installiert:", + "updateQueue": "Werden aktualisiert:", "installAndRestart": "Installieren und Neustarten", + "updateAndRestart": "Aktualisieren und Neustarten", + "executeAndRestart": "Ausführen und Neustarten", "abort": "Abbrechen", "author": "Autor", "version": "Version", + "currentVersion": "Installierte Version", + "newVersion": "Neue Version", "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installiert, wenn sie noch nicht vorhanden sind!", "dependencies": "Abhängigkeiten", "successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:", "reload": "jetzt neu laden", "restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet.", - "installPending": "Die folgenden Plugins werden installiert. Anschließend wird der SCM-Manager Kontext neu gestartet." + "executePending": "Die folgenden Plugin-Änderungen werden ausgeführt. Anschließend wird der SCM-Manager Kontext neu gestartet." } }, "repositoryRole": { diff --git a/scm-ui/public/locales/en/admin.json b/scm-ui/public/locales/en/admin.json index 99febb68fc..63f6089b09 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -29,22 +29,32 @@ "installedNavLink": "Installed", "availableNavLink": "Available" }, - "installPending": "Install pending plugins", + "executePending": "Execute pending plugin changes", "noPlugins": "No plugins found.", "modal": { - "title": "Install {{name}} Plugin", + "title": { + "install": "Install {{name}} Plugin", + "update": "Update {{name}} Plugin" + }, "restart": "Restart to activate", "install": "Install", + "update": "Update", + "installQueue": "Will be installed:", + "updateQueue": "Will be updated:", "installAndRestart": "Install and Restart", + "updateAndRestart": "Update and Restart", + "executeAndRestart": "Execute and Restart", "abort": "Abort", "author": "Author", "version": "Version", + "currentVersion": "Installed version", + "newVersion": "New version", "dependencyNotification": "With this plugin, the following dependencies will be installed if they are not available yet!", "dependencies": "Dependencies", "successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:", "reload": "reload now", "restartNotification": "You should only restart the scm-manager context if no one else is currently working with it.", - "installPending": "The following plugins will be installed and after installation the scm-manager context will be restarted." + "executePending": "The following plugin changes will be executed and after that the scm-manager context will be restarted." } }, "repositoryRole": { diff --git a/scm-ui/src/admin/plugins/components/InstallPendingAction.js b/scm-ui/src/admin/plugins/components/ExecutePendingAction.js similarity index 67% rename from scm-ui/src/admin/plugins/components/InstallPendingAction.js rename to scm-ui/src/admin/plugins/components/ExecutePendingAction.js index 49a444de11..6c8d407205 100644 --- a/scm-ui/src/admin/plugins/components/InstallPendingAction.js +++ b/scm-ui/src/admin/plugins/components/ExecutePendingAction.js @@ -1,12 +1,12 @@ // @flow import React from "react"; import { Button } from "@scm-manager/ui-components"; -import type { PluginCollection } from "@scm-manager/ui-types"; +import type { PendingPlugins } from "@scm-manager/ui-types"; import { translate } from "react-i18next"; -import InstallPendingModal from "./InstallPendingModal"; +import ExecutePendingModal from "./ExecutePendingModal"; type Props = { - collection: PluginCollection, + pendingPlugins: PendingPlugins, // context props t: string => string @@ -16,7 +16,7 @@ type State = { showModal: boolean }; -class InstallPendingAction extends React.Component { +class ExecutePendingAction extends React.Component { constructor(props: Props) { super(props); this.state = { @@ -38,11 +38,11 @@ class InstallPendingAction extends React.Component { renderModal = () => { const { showModal } = this.state; - const { collection } = this.props; + const { pendingPlugins } = this.props; if (showModal) { return ( - ); @@ -57,7 +57,7 @@ class InstallPendingAction extends React.Component { {this.renderModal()}