diff --git a/scm-core/src/main/java/sonia/scm/BadRequestException.java b/scm-core/src/main/java/sonia/scm/BadRequestException.java index 544ed75a0b..3290e77521 100644 --- a/scm-core/src/main/java/sonia/scm/BadRequestException.java +++ b/scm-core/src/main/java/sonia/scm/BadRequestException.java @@ -6,4 +6,8 @@ public abstract class BadRequestException extends ExceptionWithContext { public BadRequestException(List context, String message) { super(context, message); } + + public BadRequestException(List context, String message, Exception cause) { + super(context, message, cause); + } } 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..09f05e2c99 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java +++ b/scm-core/src/main/java/sonia/scm/plugin/InstalledPlugin.java @@ -45,21 +45,24 @@ import java.nio.file.Path; public final class InstalledPlugin implements Plugin { + public static final String UNINSTALL_MARKER_FILENAME = "uninstall"; + /** * 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,7 +123,27 @@ public final class InstalledPlugin implements Plugin return webResourceLoader; } - //~--- fields --------------------------------------------------------------- + public boolean isCore() { + return core; + } + + public boolean isMarkedForUninstall() { + return markedForUninstall; + } + + public void setMarkedForUninstall(boolean markedForUninstall) { + this.markedForUninstall = markedForUninstall; + } + + public boolean isUninstallable() { + return uninstallable; + } + + public void setUninstallable(boolean uninstallable) { + this.uninstallable = uninstallable; + } + +//~--- fields --------------------------------------------------------------- /** plugin class loader */ private final ClassLoader classLoader; @@ -133,4 +156,9 @@ public final class InstalledPlugin implements Plugin /** plugin web resource loader */ private final WebResourceLoader webResourceLoader; + + private final boolean core; + + private boolean markedForUninstall = false; + private boolean uninstallable = false; } diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java index b7b8f69519..3d0ea94536 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java @@ -81,8 +81,16 @@ public interface PluginManager { */ void install(String name, boolean restartAfterInstallation); + /** + * Marks the plugin with the given name for uninstall. + * + * @param name plugin name + * @param restartAfterInstallation restart context after plugin has been marked to really uninstall the plugin + */ + void uninstall(String name, boolean restartAfterInstallation); + /** * Install all pending plugins and restart the scm context. */ - void installPendingAndRestart(); + void executePendingAndRestart(); } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java index a108a73546..d61e17c785 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java @@ -122,10 +122,12 @@ public class ModifyCommandRequest implements Resetable, Validateable { } void cleanup() { - try { - IOUtil.delete(content); - } catch (IOException e) { - LOG.warn("could not delete temporary file {}", content, e); + if (content.exists()) { + try { + IOUtil.delete(content); + } catch (IOException e) { + LOG.warn("could not delete temporary file {}", content, e); + } } } } diff --git a/scm-ui-components/packages/ui-components/src/Breadcrumb.js b/scm-ui-components/packages/ui-components/src/Breadcrumb.js index d2f3409af6..5b0f151c61 100644 --- a/scm-ui-components/packages/ui-components/src/Breadcrumb.js +++ b/scm-ui-components/packages/ui-components/src/Breadcrumb.js @@ -1,10 +1,9 @@ //@flow import React from "react"; -import { Link } from "react-router-dom"; -import type { Branch, Repository } from "@scm-manager/ui-types"; +import {Link} from "react-router-dom"; +import type {Branch, Repository} from "@scm-manager/ui-types"; import injectSheet from "react-jss"; -import { ExtensionPoint, binder } from "@scm-manager/ui-extensions"; -import {ButtonGroup} from "./buttons"; +import {binder, ExtensionPoint} from "@scm-manager/ui-extensions"; import classNames from "classnames"; type Props = { @@ -64,33 +63,48 @@ class Breadcrumb extends React.Component { } render() { - const { classes, baseUrl, branch, defaultBranch, branches, revision, path, repository } = this.props; + const { + classes, + baseUrl, + branch, + defaultBranch, + branches, + revision, + path, + repository + } = this.props; return ( <>
-

diff --git a/scm-ui-components/packages/ui-components/src/CardColumn.js b/scm-ui-components/packages/ui-components/src/CardColumn.js index 713e1bced3..58722c0cac 100644 --- a/scm-ui-components/packages/ui-components/src/CardColumn.js +++ b/scm-ui-components/packages/ui-components/src/CardColumn.js @@ -48,6 +48,7 @@ type Props = { footerRight: React.Node, link?: string, action?: () => void, + className?: string, // context props classes: any @@ -72,13 +73,14 @@ class CardColumn extends React.Component { contentRight, footerLeft, footerRight, - classes + classes, + className } = this.props; const link = this.createLink(); return ( <> {link} -
+
{avatar}
diff --git a/scm-ui-components/packages/ui-components/src/validation.js b/scm-ui-components/packages/ui-components/src/validation.js index fcfffcee45..98a36c6f0e 100644 --- a/scm-ui-components/packages/ui-components/src/validation.js +++ b/scm-ui-components/packages/ui-components/src/validation.js @@ -5,7 +5,7 @@ export const isNameValid = (name: string) => { return nameRegex.test(name); }; -const mailRegex = /^[ -~]+@[A-Za-z0-9][\w\-.]*\.[A-Za-z0-9][A-Za-z0-9-]+$/; +const mailRegex = /^[ -~]+@[A-Za-z0-9][\w\-.]*\.[A-Za-z0-9][A-Za-z0-9-]+$/; export const isMailValid = (mail: string) => { return mailRegex.test(mail); @@ -14,3 +14,9 @@ export const isMailValid = (mail: string) => { export const isNumberValid = (number: string) => { return !isNaN(number); }; + +const pathRegex = /^((?!\/{2,}).)*$/; + +export const isPathValid = (path: string) => { + return pathRegex.test(path); +}; diff --git a/scm-ui-components/packages/ui-components/src/validation.test.js b/scm-ui-components/packages/ui-components/src/validation.test.js index d50996ff2b..4af5d755d0 100644 --- a/scm-ui-components/packages/ui-components/src/validation.test.js +++ b/scm-ui-components/packages/ui-components/src/validation.test.js @@ -2,102 +2,117 @@ import * as validator from "./validation"; describe("test name validation", () => { - it("should return false", () => { - // invalid names taken from ValidationUtilTest.java - const invalidNames = [ - "@test", - " test 123", - " test 123 ", - "test 123 ", - "test/123", - "test%123", - "test:123", - "t ", - " t", - " t ", - "", + // invalid names taken from ValidationUtilTest.java + const invalidNames = [ + "@test", + " test 123", + " test 123 ", + "test 123 ", + "test/123", + "test%123", + "test:123", + "t ", + " t", + " t ", + "", - " invalid_name", - "another%one", - "!!!", - "!_!" - ]; - for (let name of invalidNames) { + " invalid_name", + "another%one", + "!!!", + "!_!" + ]; + for (let name of invalidNames) { + it(`should return false for '${name}'`, () => { expect(validator.isNameValid(name)).toBe(false); - } - }); + }); + } - it("should return true", () => { - // valid names taken from ValidationUtilTest.java - const validNames = [ - "test", - "test.git", - "Test123.git", - "Test123-git", - "Test_user-123.git", - "test@scm-manager.de", - "test123", - "tt", - "t", - "valid_name", - "another1", - "stillValid", - "this.one_as-well", - "and@this" - ]; - for (let name of validNames) { + // valid names taken from ValidationUtilTest.java + const validNames = [ + "test", + "test.git", + "Test123.git", + "Test123-git", + "Test_user-123.git", + "test@scm-manager.de", + "test123", + "tt", + "t", + "valid_name", + "another1", + "stillValid", + "this.one_as-well", + "and@this" + ]; + for (let name of validNames) { + it(`should return true for '${name}'`, () => { expect(validator.isNameValid(name)).toBe(true); - } - }); + }); + } }); describe("test mail validation", () => { - it("should return false", () => { - // invalid taken from ValidationUtilTest.java - const invalid = [ - "ostfalia.de", - "@ostfalia.de", - "s.sdorra@", - "s.sdorra@ostfalia", - "s.sdorra@ ostfalia.de", - "s.sdorra@[ostfalia.de" - ]; - for (let mail of invalid) { + // invalid taken from ValidationUtilTest.java + const invalid = [ + "ostfalia.de", + "@ostfalia.de", + "s.sdorra@", + "s.sdorra@ostfalia", + "s.sdorra@ ostfalia.de", + "s.sdorra@[ostfalia.de" + ]; + for (let mail of invalid) { + it(`should return false for '${mail}'`, () => { expect(validator.isMailValid(mail)).toBe(false); - } - }); + }); + } - it("should return true", () => { - // valid taken from ValidationUtilTest.java - const valid = [ - "s.sdorra@ostfalia.de", - "sdorra@ostfalia.de", - "s.sdorra@hbk-bs.de", - "s.sdorra@gmail.com", - "s.sdorra@t.co", - "s.sdorra@ucla.college", - "s.sdorra@example.xn--p1ai", - "s.sdorra@scm.solutions", - "s'sdorra@scm.solutions", - "\"S Sdorra\"@scm.solutions" - ]; - for (let mail of valid) { + // valid taken from ValidationUtilTest.java + const valid = [ + "s.sdorra@ostfalia.de", + "sdorra@ostfalia.de", + "s.sdorra@hbk-bs.de", + "s.sdorra@gmail.com", + "s.sdorra@t.co", + "s.sdorra@ucla.college", + "s.sdorra@example.xn--p1ai", + "s.sdorra@scm.solutions", + "s'sdorra@scm.solutions", + "\"S Sdorra\"@scm.solutions" + ]; + for (let mail of valid) { + it(`should return true for '${mail}'`, () => { expect(validator.isMailValid(mail)).toBe(true); - } - }); + }); + } }); describe("test number validation", () => { - it("should return false", () => { - const invalid = ["1a", "35gu", "dj6", "45,5", "test"]; - for (let number of invalid) { - expect(validator.isNumberValid(number)).toBe(false); - } - }); - it("should return true", () => { - const valid = ["1", "35", "2", "235", "34.4"]; - for (let number of valid) { + const invalid = ["1a", "35gu", "dj6", "45,5", "test"]; + for (let number of invalid) { + it(`should return false for '${number}'`, () => { + expect(validator.isNumberValid(number)).toBe(false); + }); + } + const valid = ["1", "35", "2", "235", "34.4"]; + for (let number of valid) { + it(`should return true for '${number}'`, () => { expect(validator.isNumberValid(number)).toBe(true); - } - }); + }); + } +}); + +describe("test path validation", () => { + const invalid = ["//", "some//path", "end//"]; + for (let path of invalid) { + it(`should return false for '${path}'`, () => { + expect(validator.isPathValid(path)).toBe(false); + }); + } + const valid = ["", "/", "dir", "some/path", "end/"]; + for (let path of valid) { + it(`should return true for '${path}'`, () => { + expect(validator.isPathValid(path)).toBe(true); + }); + } }); diff --git a/scm-ui-components/packages/ui-types/src/Plugin.js b/scm-ui-components/packages/ui-types/src/Plugin.js index 0114716757..c7612a8bf8 100644 --- a/scm-ui-components/packages/ui-types/src/Plugin.js +++ b/scm-ui-components/packages/ui-types/src/Plugin.js @@ -4,12 +4,14 @@ import type {Collection, Links} from "./hal"; export type Plugin = { name: string, version: string, + newVersion?: string, displayName: string, description?: string, author: string, category: string, avatarUrl: string, pending: boolean, + markedForUninstall?: boolean, dependencies: string[], _links: Links }; @@ -24,3 +26,12 @@ export type PluginGroup = { name: string, plugins: Plugin[] }; + +export type PendingPlugins = { + _links: Links, + _embedded: { + new: [], + update: [], + uninstall: [] + } +} 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..d4f3567059 100644 --- a/scm-ui/public/locales/de/admin.json +++ b/scm-ui/public/locales/de/admin.json @@ -29,22 +29,36 @@ "installedNavLink": "Installiert", "availableNavLink": "Verfügbar" }, - "installPending": "Austehende Plugins installieren", + "executePending": "Ausstehende Plugin-Änderungen ausführen", "noPlugins": "Keine Plugins gefunden.", "modal": { - "title": "{{name}} Plugin installieren", - "restart": "Neustarten um Plugin zu aktivieren", + "title": { + "install": "{{name}} Plugin installieren", + "update": "{{name}} Plugin aktualisieren", + "uninstall": "{{name}} Plugin deinstallieren" + }, + "restart": "Neustarten, um Plugin-Änderungen wirksam zu machen", "install": "Installieren", + "update": "Aktualisieren", + "uninstall": "Deinstallieren", + "installQueue": "Werden installiert:", + "updateQueue": "Werden aktualisiert:", + "uninstallQueue": "Werden deinstalliert:", "installAndRestart": "Installieren und Neustarten", + "updateAndRestart": "Aktualisieren und Neustarten", + "uninstallAndRestart": "Deinstallieren and 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..c23a88a920 100644 --- a/scm-ui/public/locales/en/admin.json +++ b/scm-ui/public/locales/en/admin.json @@ -29,22 +29,36 @@ "installedNavLink": "Installed", "availableNavLink": "Available" }, - "installPending": "Install pending plugins", + "executePending": "Execute pending plugin changes", "noPlugins": "No plugins found.", "modal": { - "title": "Install {{name}} Plugin", - "restart": "Restart to activate", + "title": { + "install": "Install {{name}} Plugin", + "update": "Update {{name}} Plugin", + "uninstall": "Uninstall {{name}} Plugin" + }, + "restart": "Restart to make plugin changes effective", "install": "Install", + "update": "Update", + "uninstall": "Uninstall", + "installQueue": "Will be installed:", + "updateQueue": "Will be updated:", + "uninstallQueue": "Will be uninstalled:", "installAndRestart": "Install and Restart", + "updateAndRestart": "Update and Restart", + "uninstallAndRestart": "Uninstall 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/public/locales/es/admin.json b/scm-ui/public/locales/es/admin.json new file mode 100644 index 0000000000..14abf626d6 --- /dev/null +++ b/scm-ui/public/locales/es/admin.json @@ -0,0 +1,83 @@ +{ + "admin": { + "menu": { + "navigationLabel": "Menú de administración", + "informationNavLink": "Información", + "settingsNavLink": "Ajustes", + "generalNavLink": "General" + }, + "info": { + "currentAppVersion": "Versión actual de la aplicación", + "communityTitle": "Soporte de la comunidad", + "communityIconAlt": "Icono del soporte de la comunidad", + "communityInfo": "Contacte con el equipo de soporte de SCM-Manager para questiones acerca de SCM-Manager, para informar de errores o pedir nuevas funcionalidades use los canales oficiales.", + "communityButton": "Contactar con nuestro equipo", + "enterpriseTitle": "Soporte empresarial", + "enterpriseIconAlt": "Icono del soporte para empresas", + "enterpriseInfo": "¿Necesita ayuda para la integración de SMC-Manager en sus procesos, con la personalización de la herramienta o simplemente un acuerdo de nivel de servicio (SLA)?", + "enterprisePartner": "Póngase en contacto con nuestro socio de desarrollo Cloudogu! Su equipo está esperando para tratar sus requisitos con usted y estará encantado de darle un presupuesto.", + "enterpriseLink": "https://cloudogu.com/en/scm-manager-enterprise/", + "enterpriseButton": "Pedir soporte empresarial" + } + }, + "plugins": { + "title": "Complementos", + "installedSubtitle": "Complementos instalados", + "availableSubtitle": "Complementos disponibles", + "menu": { + "pluginsNavLink": "Complementos", + "installedNavLink": "Instalados", + "availableNavLink": "Disponibles" + }, + "installPending": "Instalar los complementos pendientes", + "noPlugins": "No se han encontrado complementos.", + "modal": { + "title": "Instalar complemento {{name}} ", + "restart": "Reiniciar para activar", + "install": "Instalar", + "installAndRestart": "Instalar y reiniciar", + "abort": "Cancelar", + "author": "Autor", + "version": "Versión", + "dependencyNotification": "Con este complemento las siguientes dependencias serán instaladas si no lo han sido ya", + "dependencies": "Dependencias", + "successNotification": "Complemento instalado correctamente. Necesita recargar la página para ver los cambios:", + "reload": "recargar ahora", + "restartNotification": "Usted debería reiniciar scm-manager sólo si actualmente no hay nadie trabajando con el.", + "installPending": "Los siguientes complementos serán instalados y después de la instalación sdm-manager será reiniciado." + } + }, + "repositoryRole": { + "navLink": "Roles y permisos", + "title": "Roles y permisos", + "errorTitle": "Error", + "errorSubtitle": "Error desconocido", + "createSubtitle": "Crear nuevo rol", + "editSubtitle": "Editar rol", + "overview": { + "title": "Visión general de todos los roles", + "noPermissionRoles": "No se han encontrado roles.", + "createButton": "Crear rol" + }, + "editButton": "Editar", + "name": "Nombre", + "type": "Tipo", + "verbs": "Permisos", + "system": "Sistema", + "form": { + "name": "Nombre", + "permissions": "Permisos", + "submit": "Guardar" + }, + "delete": { + "button": "Borrar", + "subtitle": "Eliminar el rol", + "confirmAlert": { + "title": "Eliminar el rol", + "message": "¿Realmente desea borrar el rol? Todos los usuarios de este rol perderń sus permisos.", + "submit": "Sí", + "cancel": "No" + } + } + } +} diff --git a/scm-ui/public/locales/es/commons.json b/scm-ui/public/locales/es/commons.json new file mode 100644 index 0000000000..e130ce8b17 --- /dev/null +++ b/scm-ui/public/locales/es/commons.json @@ -0,0 +1,90 @@ +{ + "login": { + "title": "Iniciar sesión", + "subtitle": "Por favor inicie sesión para continuar", + "logo-alt": "SCM-Manager", + "username-placeholder": "Su nombre de usuario", + "password-placeholder": "Su contraseña", + "submit": "Iniciar sesión", + "plugin": "Plugin", + "feature": "Feature", + "tip": "Tip", + "loading": "Cargando ...", + "error": "Error" + }, + "logout": { + "error": { + "title": "Cierre de sesión fallido", + "subtitle": "Ha ocurrido un error al cerrar la sesión" + } + }, + "app": { + "error": { + "title": "Error", + "subtitle": "Ha ocurrido un error desconocido" + } + }, + "errorNotification": { + "prefix": "Error", + "loginLink": "Aquí puede iniciar la sesión de nuevo.", + "timeout": "La sesión ha caducado", + "wrongLoginCredentials": "Credenciales incorrectas", + "forbidden": "Usted no tiene permiso para ver esta sección" + }, + "loading": { + "alt": "Cargando ..." + }, + "logo": { + "alt": "SCM-Manager" + }, + "primary-navigation": { + "repositories": "Repositorios", + "users": "Usuarios", + "logout": "Cerrar sesión", + "groups": "Grupos", + "admin": "Administración" + }, + "filterEntries": "Filtrar entradas", + "autocomplete": { + "group": "Grupo", + "user": "Usuario", + "noGroupOptions": "No hay sugerencias disponibles", + "groupPlaceholder": "Nombre del grupo", + "noUserOptions": "No hay sugerencias disponibles", + "userPlaceholder": "Nombre de usuario", + "loading": "Cargando..." + }, + "paginator": { + "next": "Siguiente", + "previous": "Anterior" + }, + "profile": { + "navigationLabel": "Menú de sección", + "informationNavLink": "Información", + "changePasswordNavLink": "Cambiar contraseña", + "settingsNavLink": "Ajustes", + "username": "Nombre de usuario", + "displayName": "Nombre a mostrar", + "mail": "Correo electrónico", + "groups": "Grupos", + "information": "Información", + "change-password": "Cambiar contraseña", + "error-title": "Error", + "error-subtitle": "No se puede mostrar la sección", + "error": "Error", + "error-message": "'me' no está definido" + }, + "password": { + "label": "Contraseña", + "newPassword": "Nueva contraseña", + "passwordHelpText": "Contraseña del usuario en texto plano", + "passwordConfirmHelpText": "Repita la contraseña para confirmar", + "currentPassword": "Contraseña actual", + "currentPasswordHelpText": "La contraseña ya está en uso", + "confirmPassword": "Confirme la contraseña", + "passwordInvalid": "La contraseña debe tener entre 6 y 32 caracteres", + "passwordConfirmFailed": "Las contraseñas deben ser identicas", + "submit": "Guardar", + "changedSuccessfully": "Contraseña cambiada correctamente" + } +} diff --git a/scm-ui/public/locales/es/config.json b/scm-ui/public/locales/es/config.json new file mode 100644 index 0000000000..3b5ab843dd --- /dev/null +++ b/scm-ui/public/locales/es/config.json @@ -0,0 +1,78 @@ +{ + "config": { + "navigationLabel": "Menú de administración", + "title": "Configuración global", + "errorTitle": "Error", + "errorSubtitle": "Error de configuración desconocido", + "form": { + "submit": "Enviar", + "submit-success-notification": "¡Configuración cambiada correctamente!", + "no-read-permission-notification": "Por favor, tenga en cuenta: ¡No tiene permiso para ver la configuración!", + "no-write-permission-notification": "Por favor, tenga en cuenta: ¡No tiene permiso para editar la configuración!" + } + }, + "proxy-settings": { + "name": "Ajustes del proxy", + "proxy-password": "Contraseña del proxy", + "proxy-port": "Puerto del proxy", + "proxy-server": "Servidor proxy", + "proxy-user": "Usuario del proxy", + "enable-proxy": "Habilitar proxy", + "proxy-excludes": "Excepciones del proxy", + "remove-proxy-exclude-button": "Eliminar las excepciones del proxy", + "add-proxy-exclude-error": "La excepción que desea añadir al proxy es incorrecta", + "add-proxy-exclude-textfield": "Añada aquí las excepciones que desee incluir al proxy", + "add-proxy-exclude-button": "Añadir excepción al proxy" + }, + "base-url-settings": { + "name": "Ajustes de la URL base", + "base-url": "URL base", + "force-base-url": "Forzar la URL base" + }, + "login-attempt": { + "name": "Intento de inicio de sesión", + "login-attempt-limit": "Límite de intentos de inicio de sesión", + "login-attempt-limit-timeout": "Tiempo de espera para el intento de inicio de sesión" + }, + "general-settings": { + "realm-description": "Descripción del dominio", + "disable-grouping-grid": "Deshabilitar grupos", + "date-format": "Formato de la fecha", + "anonymous-access-enabled": "Acceso anónimo habilitado", + "skip-failed-authenticators": "Omitir autenticadores fallidos", + "plugin-url": "URL del almacén de complementos", + "enabled-xsrf-protection": "Protección XSRF habilitada", + "namespace-strategy": "Estrategia para el espacio de nombres", + "login-info-url": "URL de información de inicio de sesión" + }, + "validation": { + "date-format-invalid": "El formato de la fecha es incorrecto", + "login-attempt-limit-timeout-invalid": "El valor no es un número", + "login-attempt-limit-invalid": "El valor no es un número", + "plugin-url-invalid": "La URL es incorrecta" + }, + "help": { + "realmDescriptionHelpText": "Descripción del dominio de autenticación.", + "dateFormatHelpText": "Formato de la fecha. Por favor, heche un vistazo a la documentación de MomentJS.", + "pluginUrlHelpText": "La URL de la API del almacén de complementos. Explicación de los marcadores: version = Versión de SCM-Manager; os = Sistema operativo; arch = Arquitectura", + "enableForwardingHelpText": "Habilitar el redireccionamiento de puertos para mod_proxy.", + "disableGroupingGridHelpText": "Deshabilitar los grupos de repositorios. Se requiere una recarga completa de la página después de un cambio en este valor.", + "allowAnonymousAccessHelpText": "Los usuarios anónimos tienen acceso de lectura en los repositorios públicos.", + "skipFailedAuthenticatorsHelpText": "No detenga la cadena de autenticación si un autenticador encuentra al usuario pero no puede autenticarlo.", + "adminGroupsHelpText": "Nombres de los grupos con permisos de administrador.", + "adminUsersHelpText": "Nombres de los usuarios con permisos de administrador.", + "forceBaseUrlHelpText": "Redirige a la URL base si la solicitud proviene de otra URL.", + "baseUrlHelpText": "La URL de la aplicación (con la ruta del contexto), por ejemplo: http://localhost:8080/scm", + "loginAttemptLimitHelpText": "Máximo número permitido de intentos de inicio de sesión. Use -1 para deshabilitar este límite.", + "loginAttemptLimitTimeoutHelpText": "Tiempo de espera en segundos para los usuarios que están deshabilitados temporalmente debido a demasiado intentos fallidos de inicio de sesión.", + "enableProxyHelpText": "Habilitar proxy", + "proxyPortHelpText": "El puerto del proxy", + "proxyPasswordHelpText": "La contraseña para la autenticación del servidor proxy.", + "proxyServerHelpText": "El servidor proxy", + "proxyUserHelpText": "El nombre de usuario para la autenticación del servidor proxy.", + "proxyExcludesHelpText": "Patrones globales para hostnames que deben excluirse de la configuración del proxy.", + "enableXsrfProtectionHelpText": "Habilitar la protección de cookies XSRF. Nota: Esta funcionalidad todavía es experimental.", + "nameSpaceStrategyHelpText": "La estrategia para el espacio de nombres.", + "loginInfoUrlHelpText": "URL para la información en el inicio de sesión (consejos sobre complementos y funcionalidades en la página de inicio de sesión). Si esto se omite, no se mostrará información de inicio de sesión." + } +} diff --git a/scm-ui/public/locales/es/groups.json b/scm-ui/public/locales/es/groups.json new file mode 100644 index 0000000000..ffba16c354 --- /dev/null +++ b/scm-ui/public/locales/es/groups.json @@ -0,0 +1,76 @@ +{ + "group": { + "name": "Nombre", + "description": "Descripción", + "creationDate": "Fecha de creación", + "lastModified": "Última modificación", + "type": "Tipo", + "external": "Externo", + "internal": "Interno", + "members": "Miembros" + }, + "groups": { + "title": "Grupos", + "subtitle": "Crear, leer, actualizar y borrar grupos", + "noGroups": "No se han encontrado grupos." + }, + "singleGroup": { + "errorTitle": "Error", + "errorSubtitle": "Error de grupo desconocido", + "menu": { + "navigationLabel": "Menú de grupo", + "informationNavLink": "Información", + "settingsNavLink": "Ajustes", + "generalNavLink": "General", + "setPermissionsNavLink": "Permisos" + } + }, + "add-group": { + "title": "Crear grupo", + "subtitle": "Crear un nuevo grupo" + }, + "create-group-button": { + "label": "Crear grupo" + }, + "edit-group-button": { + "label": "Editar" + }, + "add-member-button": { + "label": "Añadir miembro" + }, + "remove-member-button": { + "label": "Eliminar miembro" + }, + "add-member-textfield": { + "label": "Añadir miembro", + "error": "El nombre del miembro es incorrecto" + }, + "add-member-autocomplete": { + "placeholder": "Introducir el nombre del miembro", + "loading": "Cargando...", + "no-options": "No hay sugerencias disponibles" + }, + "groupForm": { + "subtitle": "Editar grupo", + "externalSubtitle": "Editar grupo externo", + "submit": "Guardar", + "nameError": "El nombre del grupo es incorrecto", + "descriptionError": "La descripción es incorrecta", + "help": { + "nameHelpText": "Nombre único del grupo", + "descriptionHelpText": "Descripción breve del grupo", + "memberHelpText": "Nombres de usuario de los miembros del grupo", + "externalHelpText": "Los miembros son gestionados por un sistema externo como por ejemplo LDAP" + } + }, + "deleteGroup": { + "subtitle": "Borrar grupo", + "button": "Borrar", + "confirmAlert": { + "title": "Borrar grupo", + "message": "¿Realmente desea borrar el grupo?", + "submit": "Sí", + "cancel": "No" + } + } +} diff --git a/scm-ui/public/locales/es/permissions.json b/scm-ui/public/locales/es/permissions.json new file mode 100644 index 0000000000..d3da1103ea --- /dev/null +++ b/scm-ui/public/locales/es/permissions.json @@ -0,0 +1,6 @@ +{ + "setPermissions": { + "button": "Guardar", + "setPermissionsSuccessful": "Permisos guardados correctamente" + } +} diff --git a/scm-ui/public/locales/es/repos.json b/scm-ui/public/locales/es/repos.json new file mode 100644 index 0000000000..08c9235593 --- /dev/null +++ b/scm-ui/public/locales/es/repos.json @@ -0,0 +1,189 @@ +{ + "repository": { + "namespace": "Espacio de nombres", + "name": "Nombre", + "type": "Tipo", + "contact": "Contacto", + "description": "Descripción", + "creationDate": "Fecha de creación", + "lastModified": "Última modificación" + }, + "validation": { + "namespace-invalid": "El espacio de nombres del repositorio es incorrecto", + "name-invalid": "El nombre del repositorio es incorrecto", + "contact-invalid": "El contacto debe ser una dirección de correo electrónico válida", + "branch": { + "nameInvalid": "El nombre de la rama es incorrecto" + } + }, + "help": { + "namespaceHelpText": "El espacio de nombres del repositorio. Este nombre formará parte de la URL del repositorio.", + "nameHelpText": "El nombre del repositorio. Este nombre formará parte de la URL del repositorio.", + "typeHelpText": "El tipo del repositorio (Mercurial, Git or Subversion).", + "contactHelpText": "Dirección del correo electrónico de la persona responsable del repositorio.", + "descriptionHelpText": "Breve descripción del repositorio." + }, + "repositoryRoot": { + "errorTitle": "Error", + "errorSubtitle": "Error de repositorio desconocido", + "menu": { + "navigationLabel": "Menú de repositorio", + "informationNavLink": "Información", + "branchesNavLink": "Ramas", + "historyNavLink": "Commits", + "sourcesNavLink": "Fuentes", + "settingsNavLink": "Ajustes", + "generalNavLink": "General", + "permissionsNavLink": "Permisos" + } + }, + "overview": { + "title": "Repositorios", + "subtitle": "Visión general de los repositorios disponibles", + "noRepositories": "No se han encontrado repositorios.", + "createButton": "Crear repositorio" + }, + "create": { + "title": "Crear repositorio", + "subtitle": "Crear un nuevo repositorio" + }, + "branches": { + "overview": { + "title": "Vivisón general de todas las ramas", + "noBranches": "No se han encontrado ramas.", + "createButton": "Crear rama" + }, + "table": { + "branches": "Ramas" + }, + "create": { + "title": "Crear rama", + "source": "Rama padre", + "name": "Nombre", + "submit": "Crear rama" + } + }, + "branch": { + "name": "Nombre:", + "commits": "Commits", + "sources": "Fuentes", + "defaultTag": "Por defecto" + }, + "changesets": { + "errorTitle": "Error", + "errorSubtitle": "No se han podido recuperar los changesets", + "noChangesets": "No se han encontrado changesets para esta rama branch.", + "branchSelectorLabel": "Ramas" + }, + "changeset": { + "description": "Descripción", + "summary": "El changeset {{id}} fue entregado {{time}}", + "shortSummary": "Entregado {{id}} {{time}}", + "tags": "Etiquetas", + "diffNotSupported": "La comparación de changesets no es soportada por el tipo de repositorio", + "author": { + "prefix": "Creado por", + "mailto": "Enviar correo electrónico a" + }, + "buttons": { + "details": "Detalles", + "sources": "Fuentes" + } + }, + "repositoryForm": { + "subtitle": "Editar repositorio", + "submit": "Guardar" + }, + "sources": { + "file-tree": { + "name": "Nombre", + "length": "Longitud", + "lastModified": "Última modificación", + "description": "Descripción", + "branch": "Rama" + }, + "content": { + "historyButton": "Historia", + "sourcesButton": "Fuentes", + "downloadButton": "Descargar", + "path": "Ruta", + "branch": "Rama", + "lastModified": "Última modificación", + "description": "Discripción", + "size": "tamaño" + }, + "noSources": "No se han encontrado fuentes para esta rama." + }, + "permission": { + "title": "Editar permisos", + "user": "Usuario", + "group": "Grupo", + "error-title": "Error", + "error-subtitle": "Error de permisos desconocido", + "name": "Usuario o grupo", + "role": "Rol", + "custom": "Personalizar", + "permissions": "Permisos", + "group-permission": "Permiso de grupo", + "user-permission": "Permiso de usuario", + "edit-permission": { + "delete-button": "Borrar", + "save-button": "Guardar cambios" + }, + "advanced-button": { + "label": "Avanzado" + }, + "delete-permission-button": { + "label": "Borrar", + "confirm-alert": { + "title": "Borrar permiso", + "message": "¿Realmente desea borrar el permiso?", + "submit": "Sí", + "cancel": "No" + } + }, + "add-permission": { + "add-permission-heading": "Añadir nuevo permiso", + "submit-button": "Guardar", + "name-input-invalid": "¡No se permiten permisos vacíos! ¡Si el permiso no está vacío, su nombre es inválido o ya existe!" + }, + "help": { + "groupPermissionHelpText": "Establece si un permiso es de grupo. Si no está marcado es un permiso de usuario.", + "nameHelpText": "Gestionar los permisos de un usuario o grupo.", + "roleHelpText": "READ = leer; WRITE = leer and escribir; OWNER = leer, escribir y también la capacidad de gestionar las propiedades y permisos. Si no hay nada seleccionado use el botón 'Avanzado' para ver los permisos en detalle.", + "permissionsHelpText": "Use esto para especificar su propio conjunto de permisos independientemente de los roles predefinidos." + }, + "advanced": { + "dialog": { + "title": "Permisos avanzados", + "submit": "Guardar", + "abort": "Cancelar" + } + } + }, + "deleteRepo": { + "subtitle": "Borrar repositorio", + "button": "Borrar", + "confirmAlert": { + "title": "Borrar repositorio", + "message": "¿Realmente desea borrar el repositorio?", + "submit": "sí", + "cancel": "No" + } + }, + "diff": { + "changes": { + "add": "añadido", + "delete": "borrado", + "modify": "modificado", + "rename": "renombrado", + "copy": "copiado" + }, + "sideBySide": "dos columnas", + "combined": "combinado" + }, + "fileUpload": { + "clickHere": "Haga click aquí para seleccionar su fichero", + "dragAndDrop": "Arrastre y suelte los ficheros aquí" + } +} diff --git a/scm-ui/public/locales/es/users.json b/scm-ui/public/locales/es/users.json new file mode 100644 index 0000000000..d5ea97b95a --- /dev/null +++ b/scm-ui/public/locales/es/users.json @@ -0,0 +1,65 @@ +{ + "user": { + "name": "Nombre de usuario", + "displayName": "Nombre a mostrar", + "mail": "Correo electrónico", + "password": "Contraseña", + "active": "Activo", + "inactive": "Inactivo", + "type": "Tipo", + "creationDate": "Fecha de creación", + "lastModified": "Última modificación" + }, + "validation": { + "mail-invalid": "El correo electrónico es incorrecto", + "name-invalid": "El nombre es incorrecto", + "displayname-invalid": "El nombre a mostrar es incorrecto" + }, + "help": { + "usernameHelpText": "Nombre único del usuario.", + "displayNameHelpText": "Nombre de usuario a mostrar.", + "mailHelpText": "Dirección de correo electrónico del usuario.", + "adminHelpText": "Un administrador es capaz de crear, modificar y borrar repositorios, grupos y usuarios.", + "activeHelpText": "Activar o desactivar el usuario." + }, + "users": { + "title": "Usuarios", + "subtitle": "Crear, leer, actualizar y borrar usuarios", + "noUsers": "No se han encontrado usuarios.", + "createButton": "Crear usuario" + }, + "singleUser": { + "errorTitle": "Error", + "errorSubtitle": "Error de usuario desconocido", + "menu": { + "navigationLabel": "Menú de usuario", + "informationNavLink": "Información", + "settingsNavLink": "Ajustes", + "generalNavLink": "General", + "setPasswordNavLink": "Contraseña", + "setPermissionsNavLink": "Permisos" + } + }, + "createUser": { + "title": "Crear usuario", + "subtitle": "Crear un nuevo usuario" + }, + "deleteUser": { + "subtitle": "Borrar usuario", + "button": "Borrar", + "confirmAlert": { + "title": "Borrar usuario", + "message": "¿Realmente desea borrar el usuario?", + "submit": "Sí", + "cancel": "No" + } + }, + "singleUserPassword": { + "button": "Guardar", + "setPasswordSuccessful": "Contraseña guardada correctamente" + }, + "userForm": { + "subtitle": "Editar usuario", + "button": "Guardar" + } +} 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()}
diff --git a/scm-ui/src/repos/sources/containers/Sources.js b/scm-ui/src/repos/sources/containers/Sources.js index d09e18e5be..065dfb5a51 100644 --- a/scm-ui/src/repos/sources/containers/Sources.js +++ b/scm-ui/src/repos/sources/containers/Sources.js @@ -80,20 +80,17 @@ class Sources extends React.Component { } redirectToDefaultBranch = () => { - const { branches, baseUrl } = this.props; - if (this.shouldRedirect()) { + const { branches } = this.props; + if (this.shouldRedirectToDefaultBranch()) { const defaultBranches = branches.filter(b => b.defaultBranch); if (defaultBranches.length > 0) { - this.props.history.push( - `${baseUrl}/${encodeURIComponent(defaultBranches[0].name)}/` - ); - this.setState({ selectedBranch: defaultBranches[0] }); + this.branchSelected(defaultBranches[0]); } } }; - shouldRedirect = () => { + shouldRedirectToDefaultBranch = () => { const { branches, revision } = this.props; return branches && !revision; }; diff --git a/scm-ui/src/users/components/userValidation.js b/scm-ui/src/users/components/userValidation.js index c9460fdd50..e53dce229c 100644 --- a/scm-ui/src/users/components/userValidation.js +++ b/scm-ui/src/users/components/userValidation.js @@ -2,9 +2,9 @@ import { validation } from "@scm-manager/ui-components"; -const { isNameValid, isMailValid } = validation; +const { isNameValid, isMailValid, isPathValid } = validation; -export { isNameValid, isMailValid }; +export { isNameValid, isMailValid, isPathValid }; export const isDisplayNameValid = (displayName: string) => { if (displayName) { 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..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 @@ -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. * @@ -95,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/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index bc9d4b397c..f0dff26757 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,17 +3,19 @@ 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; import javax.inject.Inject; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import java.util.List; import java.util.Optional; @@ -50,7 +52,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,10 +75,28 @@ 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)); } } + + /** + * Triggers plugin uninstall. + * @param name plugin name + * @return HTTP Status. + */ + @POST + @Path("/{name}/uninstall") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response uninstallPlugin(@PathParam("name") String name, @QueryParam("restart") boolean restartAfterInstallation) { + PluginPermissions.manage().check(); + pluginManager.uninstall(name, restartAfterInstallation); + return Response.ok().build(); + } } 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..3997a5e7c5 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java @@ -0,0 +1,113 @@ +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.InstalledPlugin; +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.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; +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()); + List installed = pluginManager.getInstalled(); + + Stream newPlugins = pending + .stream() + .filter(a -> !contains(installed, a)); + Stream updatePlugins = installed + .stream() + .filter(i -> contains(pending, i)); + Stream uninstallPlugins = installed + .stream() + .filter(InstalledPlugin::isMarkedForUninstall); + + Links.Builder linksBuilder = linkingTo().self(resourceLinks.pendingPluginCollection().self()); + + List installDtos = newPlugins.map(mapper::mapAvailable).collect(toList()); + List updateDtos = updatePlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList()); + List uninstallDtos = uninstallPlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList()); + + if (!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty()) { + linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().executePending())); + } + + Embedded.Builder embedded = Embedded.embeddedBuilder(); + embedded.with("new", installDtos); + embedded.with("update", updateDtos); + embedded.with("uninstall", uninstallDtos); + + 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("/execute") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response executePending() { + PluginPermissions.manage().check(); + pluginManager.executePendingAndRestart(); + return Response.ok().build(); + } +} 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..19067b1865 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,12 +17,17 @@ 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; private String category; private String avatarUrl; private boolean pending; + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean core; + private Boolean markedForUninstall; private Set dependencies; public PluginDto(Links links) { 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..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 @@ -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)); } @@ -50,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/PluginDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoMapper.java index 25faf0a101..160ec22354 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,43 @@ 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 (!plugin.isCore() + && availablePlugin.isPresent() + && !availablePlugin.get().isPending() + && PluginPermissions.manage().isPermitted() + ) { + links.single(link("update", resourceLinks.availablePlugin().install(information.getName()))); + } + if (plugin.isUninstallable() + && (!availablePlugin.isPresent() || !availablePlugin.get().isPending()) + && PluginPermissions.manage().isPermitted() + ) { + links.single(link("uninstall", resourceLinks.installedPlugin().uninstall(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()); + }); + + dto.setCore(plugin.isCore()); + dto.setMarkedForUninstall(plugin.isMarkedForUninstall()); + + 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/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..c36cfc09ad 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 @@ -666,6 +666,10 @@ class ResourceLinks { String self(String id) { return installedPluginLinkBuilder.method("installedPlugins").parameters().method("getInstalledPlugin").parameters(id).href(); } + + public String uninstall(String name) { + return installedPluginLinkBuilder.method("installedPlugins").parameters().method("uninstallPlugin").parameters(name).href(); + } } public InstalledPluginCollectionLinks installedPluginCollection() { @@ -715,12 +719,28 @@ class ResourceLinks { availablePluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, AvailablePluginResource.class); } - String installPending() { - return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("installPending").parameters().href(); + 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 executePending() { + return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("executePending").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/main/java/sonia/scm/lifecycle/PluginBootstrap.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java index ff8c28f51d..0382187c26 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginBootstrap.java @@ -29,9 +29,11 @@ import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.nio.file.Path; import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.stream.Stream; public final class PluginBootstrap { @@ -78,12 +80,37 @@ public final class PluginBootstrap { LOG.info("core plugin extraction is disabled"); } + uninstallMarkedPlugins(pluginDirectory.toPath()); return PluginsInternal.collectPlugins(classLoaderLifeCycle, pluginDirectory.toPath()); } catch (IOException ex) { throw new PluginLoadException("could not load plugins", ex); } } + private void uninstallMarkedPlugins(Path pluginDirectory) { + try (Stream list = java.nio.file.Files.list(pluginDirectory)) { + list + .filter(java.nio.file.Files::isDirectory) + .filter(this::isMarkedForUninstall) + .forEach(this::uninstall); + } catch (IOException e) { + LOG.warn("error occurred while checking for plugins that should be uninstalled", e); + } + } + + private boolean isMarkedForUninstall(Path path) { + return java.nio.file.Files.exists(path.resolve(InstalledPlugin.UNINSTALL_MARKER_FILENAME)); + } + + private void uninstall(Path path) { + try { + LOG.info("deleting plugin directory {}", path); + IOUtil.delete(path.toFile()); + } catch (IOException e) { + LOG.warn("could not delete plugin directory {}", path, e); + } + } + private void renameOldPluginsFolder(File pluginDirectory) { if (new File(pluginDirectory, "classpath.xml").exists()) { File backupDirectory = new File(pluginDirectory.getParentFile(), "plugins.v1"); @@ -96,7 +123,6 @@ public final class PluginBootstrap { } } - private boolean isCorePluginExtractionDisabled() { return Boolean.getBoolean("sonia.scm.boot.disable-core-plugin-extraction"); } 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..d54645f10d 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -33,8 +33,7 @@ package sonia.scm.plugin; -//~--- non-JDK imports -------------------------------------------------------- - +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; import org.slf4j.Logger; @@ -42,10 +41,13 @@ 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; +import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -53,6 +55,9 @@ import java.util.function.Predicate; import java.util.stream.Collectors; import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; + +//~--- JDK imports ------------------------------------------------------------ /** * @@ -67,7 +72,8 @@ public class DefaultPluginManager implements PluginManager { private final PluginLoader loader; private final PluginCenter center; private final PluginInstaller installer; - private final List pendingQueue = new ArrayList<>(); + private final Collection pendingQueue = new ArrayList<>(); + private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker(); @Inject public DefaultPluginManager(ScmEventBus eventBus, PluginLoader loader, PluginCenter center, PluginInstaller installer) { @@ -75,6 +81,17 @@ public class DefaultPluginManager implements PluginManager { this.loader = loader; this.center = center; this.installer = installer; + + this.computeInstallationDependencies(); + } + + @VisibleForTesting + synchronized void computeInstallationDependencies() { + loader.getInstalledPlugins() + .stream() + .map(InstalledPlugin::getDescriptor) + .forEach(dependencyTracker::addInstalled); + updateMayUninstallFlag(); } @Override @@ -83,7 +100,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 +133,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,18 +142,32 @@ 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 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) { try { PendingPluginInstallation pending = installer.install(plugin); + dependencyTracker.addInstalled(plugin.getDescriptor()); pendingInstallations.add(pending); } catch (PluginInstallException ex) { cancelPending(pendingInstallations); @@ -149,15 +180,54 @@ public class DefaultPluginManager implements PluginManager { restart("plugin installation"); } else { pendingQueue.addAll(pendingInstallations); + updateMayUninstallFlag(); } } } @Override - public void installPendingAndRestart() { + public void uninstall(String name, boolean restartAfterInstallation) { PluginPermissions.manage().check(); - if (!pendingQueue.isEmpty()) { - restart("install pending plugins"); + InstalledPlugin installed = getInstalled(name) + .orElseThrow(() -> NotFoundException.notFound(entity(InstalledPlugin.class, name))); + doThrow().violation("plugin is a core plugin and cannot be uninstalled").when(installed.isCore()); + + dependencyTracker.removeInstalled(installed.getDescriptor()); + installed.setMarkedForUninstall(true); + + createMarkerFile(installed, InstalledPlugin.UNINSTALL_MARKER_FILENAME); + + if (restartAfterInstallation) { + restart("plugin installation"); + } else { + updateMayUninstallFlag(); + } + } + + private void updateMayUninstallFlag() { + loader.getInstalledPlugins() + .forEach(p -> p.setUninstallable(isUninstallable(p))); + } + + private boolean isUninstallable(InstalledPlugin p) { + return !p.isCore() + && !p.isMarkedForUninstall() + && dependencyTracker.mayUninstall(p.getDescriptor().getInformation().getName()); + } + + private void createMarkerFile(InstalledPlugin plugin, String markerFile) { + try { + Files.createFile(plugin.getDirectory().resolve(markerFile)); + } catch (IOException e) { + throw new PluginException("could not mark plugin " + plugin.getId() + " in path " + plugin.getDirectory() + "as " + markerFile, e); + } + } + + @Override + public void executePendingAndRestart() { + PluginPermissions.manage().check(); + if (!pendingQueue.isEmpty() || getInstalled().stream().anyMatch(InstalledPlugin::isMarkedForUninstall)) { + restart("execute pending plugin changes"); } } @@ -171,22 +241,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); } } @@ -195,4 +261,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(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java index 2b0928891e..bd294cbd75 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java @@ -33,8 +33,8 @@ class PluginCenterLoader { LOG.info("fetch plugins from {}", url); PluginCenterDto pluginCenterDto = client.get(url).request().contentFromJson(PluginCenterDto.class); return mapper.map(pluginCenterDto); - } catch (IOException ex) { - LOG.error("failed to load plugins from plugin center, returning empty list"); + } catch (Exception ex) { + LOG.error("failed to load plugins from plugin center, returning empty list", ex); return Collections.emptySet(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java new file mode 100644 index 0000000000..a68b391b97 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java @@ -0,0 +1,38 @@ +package sonia.scm.plugin; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; + +class PluginDependencyTracker { + + private final Map> plugins = new HashMap<>(); + + void addInstalled(PluginDescriptor plugin) { + if (plugin.getDependencies() != null) { + plugin.getDependencies().forEach(dependency -> addDependency(plugin.getInformation().getName(), dependency)); + } + } + + void removeInstalled(PluginDescriptor plugin) { + doThrow() + .violation("Plugin is needed as a dependency for other plugins", "plugin") + .when(!mayUninstall(plugin.getInformation().getName())); + plugin.getDependencies().forEach(dependency -> removeDependency(plugin.getInformation().getName(), dependency)); + } + + boolean mayUninstall(String name) { + return plugins.computeIfAbsent(name, x -> new HashSet<>()).isEmpty(); + } + + private void addDependency(String from, String to) { + plugins.computeIfAbsent(to, name -> new HashSet<>()).add(from); + } + + private void removeDependency(String from, String to) { + plugins.get(to).remove(from); + } +} 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..2bbab3c650 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(PluginConstants.FILE_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/main/resources/locales/es/plugins.json b/scm-webapp/src/main/resources/locales/es/plugins.json new file mode 100644 index 0000000000..cef33f6007 --- /dev/null +++ b/scm-webapp/src/main/resources/locales/es/plugins.json @@ -0,0 +1,186 @@ +{ + "permissions": { + "*": { + "displayName": "Administrador global", + "description": "Puede administrar la instancia completa" + }, + "repository": { + "read,pull": { + "*": { + "displayName": "Leer todos los repositorios", + "description": "Puede ver y clonar todos los repositorios" + } + }, + "read,pull,push": { + "*": { + "displayName": "Escribir todos los repositorios", + "description": "Puede ver, clonar y enviar cambios a todos los repositorios" + } + }, + "*": { + "displayName": "Poseer todos los repositorios", + "description": "Puede ver, clonar, enviar cambios, configurar y eliminar todos los repositorios" + }, + "create": { + "displayName": "Crear repositorios", + "description": "Puede crear repositorios" + } + }, + "user": { + "*": { + "displayName": "Administrar usuarios", + "description": "Puede administrar todos los usuarios" + } + }, + "group": { + "*": { + "displayName": "Administrar grupos", + "description": "Puede administrar todos los grupos" + } + }, + "permission": { + "*": { + "displayName": "Administrar permisos", + "description": "Puede administrar permisos (addicionalmente necesita 'administrar usuarios' y/o 'administrar grupos')." + } + }, + "configuration": { + "list": { + "displayName": "Administración basíca", + "description": "Prerequisito para todos los permisos administrativos. Sin este permiso la configuración no será visible." + }, + "read,write": { + "global": { + "displayName": "Administrar núcleo", + "description": "Puede administrar las opciones centrales de SCM-Manager" + }, + "*": { + "displayName": "Administrar núcleo y complementos", + "description": "Puede configurar las opciones centrales de SCM-Manager y todos los complementos (plugins)" + } + } + }, + "repositoryRole": { + "read,write": { + "displayName": "Administrar permisos de roles de repositorio personalizados", + "description": "Puede crear, modificar y borrar roles de repositorios personalizados y sus permisos" + } + }, + "plugin": { + "read": { + "displayName": "Leer todos los complementos", + "description": "Puede leer todos los complementos instalados y los disponibles" + }, + "read,write": { + "displayName": "Leer y gestionar todos los complementos", + "description": "Puede leer y gestionar todos los complementos instalados y los diponibles" + } + }, + "unknown": "Permiso desconocido" + }, + "verbs": { + "repository": { + "read": { + "displayName": "leer repositorio", + "description": "Puede leer el repositorio dentro de SCM-Manager" + }, + "modify": { + "displayName": "modificar los metadatos del repositorio", + "description": "Puede modificar las propiedades básicas del repositorio" + }, + "delete": { + "displayName": "borrar repositorio", + "description": "Puede borrar el repositorio" + }, + "pull": { + "displayName": "Traer los cambios o crear copia local del repositorio (pull/checkout)", + "description": "Puede traer los cambios o crear una copia de trabajo local desde el repositorio (pull/checkout)" + }, + "push": { + "displayName": "Publicar o enviar cambios al repositorio (push/commit)", + "description": "Puede cambiar el contenido del repositorio (push/commit)" + }, + "permissionRead": { + "displayName": "leer permisos", + "description": "Puede ver los permisos del repositorio" + }, + "permissionWrite": { + "displayName": "modificar permisos", + "description": "Puede modificar los permisos del repositorio" + }, + "*": { + "displayName": "poseer repositorio", + "description": "Puede cambiar todo en el repositorio (incluidos los demás permisos)" + } + } + }, + "errors": { + "context": "Contexto", + "errorCode": "Código de error", + "transactionId": "Identificador de la transacción", + "moreInfo": "Para más información ver", + "violations": "Violaciones:", + "AGR7UzkhA1": { + "displayName": "No encontrado", + "description": "No se pudo encontrar la entidad solicitada. Puede haber sido eliminado en otra sesión" + }, + "FtR7UznKU1": { + "displayName": "Ya existe", + "description": "Ya hay una entidad con el mismo valor de clave." + }, + "9BR7qpDAe1": { + "displayName": "No se permite cambiar la contraseña", + "description": "No tiene permiso para cambiar la contraseña." + }, + "2wR7UzpPG1": { + "displayName": "Modificaciones actuales", + "description": "La entidad ha sido modificada concurrentemente por otro usuario o proceso. Por favor recarge la entidad." + }, + "9SR8G0kmU1": { + "displayName": "Funcionalidad no soportada", + "description": "El sistema de control de versiones de este repositorio no admite la funcionalidad solicitada." + }, + "CmR8GCJb31": { + "displayName": "Error interno del servidor", + "description": "Se ha producido un error interno en el servidor. Por favor contacte con su administrador para obtener más ayuda." + }, + "92RCCCMHO1": { + "displayName": "No se ha podido encontrar la URL interna", + "description": "Una petición interna no ha podido ser manejada por el servidor. Por favor contacte con su administrador para obtener más ayuda." + }, + "2VRCrvpL71": { + "displayName": "Formato de datos invorrecto", + "description": "Los datos enviados al servidor son son correctos. Por favor revise los datos enviados o contacte con su administrador para obtener más ayuda." + }, + "8pRBYDURx1": { + "displayName": "Tipo de datos incorrecto", + "description": "El tipo de los datos enviados el servidor es incorrecto. Por favor contacte con su administrador para obtener más ayuda." + }, + "1wR7ZBe7H1": { + "displayName": "Entrada incorrecta", + "description": "Los datos no pueden ser validados. Por favor corríjalos e inténtelo de nuevo." + }, + "3zR9vPNIE1": { + "displayName": "Entrada incorrecta", + "description": "Los datos no pueden ser validados. Por favor corríjalos e inténtelo de nuevo." + }, + "CHRM7IQzo1": { + "displayName": "Cambio fallido", + "description": "El cambio ha fallado. Por favor contacte con su administrador para obtener más ayuda." + }, + "thbsUFokjk": { + "displayName": "Cambio inválido en el identificador", + "description": "Un identificador ha sido cambiado en la entidad. Esto no está permitido." + }, + "40RaYIeeR1": { + "displayName": "No se han efectuado modificaciones.", + "description": "Ningún fichero del repositorio ha sido modificado. Por lo tanto, no se confirmará ninguna modificación." + } + }, + "namespaceStrategies": { + "UsernameNamespaceStrategy": "Nombre de usuario", + "CustomNamespaceStrategy": "Personalizar", + "CurrentYearNamespaceStrategy": "Año actual", + "RepositoryTypeNamespaceStrategy": "Tipo de repositorio" + } +} 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..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 @@ -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; @@ -18,6 +19,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 +28,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 +36,9 @@ 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; import static org.mockito.Mockito.when; @@ -43,9 +48,6 @@ class AvailablePluginResourceTest { private Dispatcher dispatcher; - @Mock - Provider installedPluginResourceProvider; - @Mock Provider availablePluginResourceProvider; @@ -63,13 +65,14 @@ class AvailablePluginResourceTest { PluginRootResource pluginRootResource; - private final Subject subject = mock(Subject.class); + @Mock + Subject subject; @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); } @@ -80,7 +83,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 @@ -90,9 +93,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 +109,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)); @@ -139,38 +162,46 @@ 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 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 { @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"); @@ -178,6 +209,7 @@ class AvailablePluginResourceTest { MockHttpResponse response = new MockHttpResponse(); assertThrows(UnhandledException.class, () -> dispatcher.invoke(request, response)); + verify(subject).checkPermission(any(String.class)); } @Test @@ -187,16 +219,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)); } } 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..e2a23f0d52 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,10 +29,12 @@ 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; import static org.mockito.Mockito.*; +import static sonia.scm.plugin.PluginTestHelper.createInstalled; @ExtendWith(MockitoExtension.class) class InstalledPluginResourceTest { @@ -64,7 +66,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); } @@ -85,9 +87,9 @@ class InstalledPluginResourceTest { @Test void getInstalledPlugins() throws URISyntaxException, UnsupportedEncodingException { - InstalledPlugin installedPlugin = createPlugin(); + InstalledPlugin installedPlugin = createInstalled(""); 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); @@ -104,13 +106,13 @@ class InstalledPluginResourceTest { PluginInformation pluginInformation = new PluginInformation(); pluginInformation.setVersion("2.0.0"); pluginInformation.setName("pluginName"); - InstalledPlugin installedPlugin = createPlugin(pluginInformation); + InstalledPlugin installedPlugin = createInstalled(pluginInformation); when(pluginManager.getInstalled("pluginName")).thenReturn(Optional.of(installedPlugin)); 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); @@ -123,18 +125,6 @@ class InstalledPluginResourceTest { } } - private InstalledPlugin createPlugin() { - return createPlugin(new PluginInformation()); - } - - private InstalledPlugin createPlugin(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; - } - @Nested class WithoutAuthorization { 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..e5587b78cd --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java @@ -0,0 +1,241 @@ +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.InstalledPlugin; +import sonia.scm.plugin.InstalledPluginDescriptor; +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 static java.net.URI.create; +import static java.util.Collections.emptyList; +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; + }); + lenient().when(mapper.mapInstalled(any(), any())).thenAnswer(invocation -> { + PluginDto dto = new PluginDto(); + dto.setName(((InstalledPlugin)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-pending-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-pending-plugin"); + } + + @Test + void shouldGetPendingAvailablePluginListWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { + AvailablePlugin availablePlugin = createAvailablePlugin("pending-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("\"new\":[{\"name\":\"pending-available-plugin\""); + assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); + } + + @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("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); + } + + @Test + void shouldGetPendingUninstallPluginListWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { + when(pluginManager.getAvailable()).thenReturn(emptyList()); + InstalledPlugin installedPlugin = createInstalledPlugin("uninstalled-plugin"); + when(installedPlugin.isMarkedForUninstall()).thenReturn(true); + 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("\"uninstall\":[{\"name\":\"uninstalled-plugin\""); + assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); + } + + @Test + void shouldExecutePendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/execute"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + verify(pluginManager).executePendingAndRestart(); + } + } + + @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()).executePendingAndRestart(); + } + + @Test + void shouldNotExecutePendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/execute"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + verify(pluginManager, never()).executePendingAndRestart(); + } + } + + 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 = 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; + } +} 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..0da2331bd3 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,10 +17,14 @@ 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; +import static sonia.scm.plugin.PluginTestHelper.createAvailable; +import static sonia.scm.plugin.PluginTestHelper.createInstalled; @ExtendWith(MockitoExtension.class) class PluginDtoMapperTest { @@ -72,22 +76,16 @@ class PluginDtoMapperTest { @Test void shouldAppendInstalledSelfLink() { - InstalledPlugin plugin = createInstalled(); + InstalledPlugin plugin = createInstalled(createPluginInformation()); - 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"); } - private InstalledPlugin createInstalled(PluginInformation information) { - InstalledPlugin plugin = mock(InstalledPlugin.class, Answers.RETURNS_DEEP_STUBS); - when(plugin.getDescriptor().getInformation()).thenReturn(information); - return plugin; - } - @Test void shouldAppendAvailableSelfLink() { - AvailablePlugin plugin = createAvailable(); + AvailablePlugin plugin = createAvailable(createPluginInformation()); PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getLinks().getLinkBy("self").get().getHref()) @@ -96,7 +94,7 @@ class PluginDtoMapperTest { @Test void shouldNotAppendInstallLinkWithoutPermissions() { - AvailablePlugin plugin = createAvailable(); + AvailablePlugin plugin = createAvailable(createPluginInformation()); PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getLinks().getLinkBy("install")).isEmpty(); @@ -105,7 +103,7 @@ class PluginDtoMapperTest { @Test void shouldAppendInstallLink() { when(subject.isPermitted("plugin:manage")).thenReturn(true); - AvailablePlugin plugin = createAvailable(); + AvailablePlugin plugin = createAvailable(createPluginInformation()); PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getLinks().getLinkBy("install").get().getHref()) @@ -123,25 +121,21 @@ class PluginDtoMapperTest { @Test void shouldAppendDependencies() { - AvailablePlugin plugin = createAvailable(); + AvailablePlugin plugin = createAvailable(createPluginInformation()); when(plugin.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("one", "two")); PluginDto dto = mapper.mapAvailable(plugin); assertThat(dto.getDependencies()).containsOnly("one", "two"); } - private InstalledPlugin createInstalled() { - return createInstalled(createPluginInformation()); - } + @Test + void shouldAppendUninstallLink() { + when(subject.isPermitted("plugin:manage")).thenReturn(true); + InstalledPlugin plugin = createInstalled(createPluginInformation()); + when(plugin.isUninstallable()).thenReturn(true); - private AvailablePlugin createAvailable() { - return createAvailable(createPluginInformation()); + PluginDto dto = mapper.mapInstalled(plugin, emptyList()); + assertThat(dto.getLinks().getLinkBy("uninstall").get().getHref()) + .isEqualTo("https://hitchhiker.com/v2/plugins/installed/scm-cas-plugin/uninstall"); } - - private AvailablePlugin createAvailable(PluginInformation information) { - AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); - when(descriptor.getInformation()).thenReturn(information); - return new AvailablePlugin(descriptor); - } - } 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)); 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..057a05eb79 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -10,24 +10,38 @@ 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.Answers; +import org.junitpioneer.jupiter.TempDirectory; import org.mockito.ArgumentCaptor; 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; +import java.nio.file.Path; import java.util.List; import java.util.Optional; +import static java.util.Arrays.asList; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; 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.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doReturn; +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; +import static sonia.scm.plugin.PluginTestHelper.createAvailable; +import static sonia.scm.plugin.PluginTestHelper.createInstalled; @ExtendWith(MockitoExtension.class) +@ExtendWith(TempDirectory.class) class DefaultPluginManagerTest { @Mock @@ -269,14 +283,14 @@ class DefaultPluginManagerTest { when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); - manager.installPendingAndRestart(); + manager.executePendingAndRestart(); verify(eventBus).post(any(RestartEvent.class)); } @Test void shouldNotSendRestartEventWithoutPendingPlugins() { - manager.installPendingAndRestart(); + manager.executePendingAndRestart(); verify(eventBus, never()).post(any()); } @@ -303,6 +317,116 @@ class DefaultPluginManagerTest { assertThat(available.get(0).isPending()).isTrue(); } + @Test + void shouldThrowExceptionWhenUninstallingUnknownPlugin() { + assertThrows(NotFoundException.class, () -> manager.uninstall("no-such-plugin", false)); + } + + @Test + void shouldUseDependencyTrackerForUninstall() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(mailPlugin, reviewPlugin)); + manager.computeInstallationDependencies(); + + assertThrows(ScmConstraintViolationException.class, () -> manager.uninstall("scm-mail-plugin", false)); + } + + @Test + void shouldCreateUninstallFile(@TempDirectory.TempDir Path temp) { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.getDirectory()).thenReturn(temp); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + manager.uninstall("scm-mail-plugin", false); + + assertThat(temp.resolve("uninstall")).exists(); + } + + @Test + void shouldMarkPluginForUninstall(@TempDirectory.TempDir Path temp) { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.getDirectory()).thenReturn(temp); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + manager.uninstall("scm-mail-plugin", false); + + verify(mailPlugin).setMarkedForUninstall(true); + } + + @Test + void shouldThrowExceptionWhenUninstallingCorePlugin(@TempDirectory.TempDir Path temp) { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.getDirectory()).thenReturn(temp); + when(mailPlugin.isCore()).thenReturn(true); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + assertThrows(ScmConstraintViolationException.class, () -> manager.uninstall("scm-mail-plugin", false)); + + assertThat(temp.resolve("uninstall")).doesNotExist(); + } + + @Test + void shouldMarkUninstallablePlugins() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(asList(mailPlugin, reviewPlugin)); + + manager.computeInstallationDependencies(); + + verify(reviewPlugin).setUninstallable(true); + verify(mailPlugin).setUninstallable(false); + } + + @Test + void shouldUpdateMayUninstallFlagAfterDependencyIsUninstalled() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(asList(mailPlugin, reviewPlugin)); + + manager.computeInstallationDependencies(); + + manager.uninstall("scm-review-plugin", false); + + verify(mailPlugin).setUninstallable(true); + } + + @Test + void shouldUpdateMayUninstallFlagAfterDependencyIsInstalled() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + AvailablePlugin reviewPlugin = createAvailable("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + when(center.getAvailable()).thenReturn(singleton(reviewPlugin)); + + manager.computeInstallationDependencies(); + + manager.install("scm-review-plugin", false); + + verify(mailPlugin).setUninstallable(false); + } + + @Test + void shouldRestartWithUninstallOnly() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + when(mailPlugin.isMarkedForUninstall()).thenReturn(true); + + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + + manager.executePendingAndRestart(); + + verify(eventBus).post(any(RestartEvent.class)); + } } @Nested @@ -349,38 +473,14 @@ class DefaultPluginManagerTest { } @Test - void shouldThrowAuthorizationExceptionsForInstallPendingAndRestart() { - assertThrows(AuthorizationException.class, () -> manager.installPendingAndRestart()); + void shouldThrowAuthorizationExceptionsForUninstallMethod() { + assertThrows(AuthorizationException.class, () -> manager.uninstall("test", false)); + } + + @Test + void shouldThrowAuthorizationExceptionsForExecutePendingAndRestart() { + assertThrows(AuthorizationException.class, () -> manager.executePendingAndRestart()); } } - - private AvailablePlugin createAvailable(String name) { - PluginInformation information = new PluginInformation(); - information.setName(name); - return createAvailable(information); - } - - private InstalledPlugin createInstalled(String name) { - PluginInformation information = new PluginInformation(); - information.setName(name); - return createInstalled(information); - } - - private InstalledPlugin createInstalled(PluginInformation information) { - InstalledPlugin plugin = mock(InstalledPlugin.class, Answers.RETURNS_DEEP_STUBS); - returnInformation(plugin, information); - return plugin; - } - - private AvailablePlugin createAvailable(PluginInformation information) { - AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); - lenient().when(descriptor.getInformation()).thenReturn(information); - return new AvailablePlugin(descriptor); - } - - private void returnInformation(Plugin mockedPlugin, PluginInformation information) { - when(mockedPlugin.getDescriptor().getInformation()).thenReturn(information); - } - } 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 --------------------------------------------------------------- diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginDependencyTrackerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginDependencyTrackerTest.java new file mode 100644 index 0000000000..150d4356e7 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginDependencyTrackerTest.java @@ -0,0 +1,81 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import sonia.scm.ScmConstraintViolationException; + +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static sonia.scm.plugin.PluginTestHelper.createInstalled; + +class PluginDependencyTrackerTest { + + @Test + void simpleInstalledPluginWithoutDependingPluginsCanBeUninstalled() { + PluginDescriptor mail = createInstalled("scm-mail-plugin").getDescriptor(); + when(mail.getDependencies()).thenReturn(emptySet()); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(mail); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isTrue(); + } + + @Test + void installedPluginWithDependingPluginCannotBeUninstalled() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isFalse(); + } + + @Test + void uninstallOfRequiredPluginShouldThrowException() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + + Assertions.assertThrows( + ScmConstraintViolationException.class, + () -> pluginDependencyTracker.removeInstalled(createInstalled("scm-mail-plugin").getDescriptor()) + ); + } + + @Test + void installedPluginWithDependingPluginCanBeUninstalledAfterDependingPluginIsUninstalled() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + pluginDependencyTracker.removeInstalled(review); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isTrue(); + } + + @Test + void installedPluginWithMultipleDependingPluginCannotBeUninstalledAfterOnlyOneDependingPluginIsUninstalled() { + PluginDescriptor review = createInstalled("scm-review-plugin").getDescriptor(); + when(review.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + PluginDescriptor jira = createInstalled("scm-jira-plugin").getDescriptor(); + when(jira.getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + PluginDependencyTracker pluginDependencyTracker = new PluginDependencyTracker(); + pluginDependencyTracker.addInstalled(review); + pluginDependencyTracker.addInstalled(jira); + pluginDependencyTracker.removeInstalled(review); + + boolean mayUninstall = pluginDependencyTracker.mayUninstall("scm-mail-plugin"); + assertThat(mayUninstall).isFalse(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java new file mode 100644 index 0000000000..2bd3dce1c2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java @@ -0,0 +1,39 @@ +package sonia.scm.plugin; + +import org.mockito.Answers; + +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +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); + } + + public static InstalledPlugin createInstalled(PluginInformation information) { + InstalledPlugin plugin = mock(InstalledPlugin.class, Answers.RETURNS_DEEP_STUBS); + returnInformation(plugin, information); + return plugin; + } + + public static AvailablePlugin createAvailable(PluginInformation information) { + AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); + lenient().when(descriptor.getInformation()).thenReturn(information); + return new AvailablePlugin(descriptor); + } + + private static void returnInformation(Plugin mockedPlugin, PluginInformation information) { + when(mockedPlugin.getDescriptor().getInformation()).thenReturn(information); + } +}