diff --git a/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy b/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy index 6d31211cf3..64f3b35d3b 100644 --- a/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy +++ b/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy @@ -102,13 +102,22 @@ class RunTask extends DefaultTask { } private Closure createBackend() { + Map scmProperties = System.getProperties().findAll { e -> { + return e.key.startsWith("scm") || e.key.startsWith("sonia") + }} + + def runProperties = new HashMap(scmProperties) + runProperties.put("user.home", extension.getHome()) + runProperties.put("scm.initialPassword", "scmadmin") + runProperties.put("scm.workingCopyPoolStrategy", "sonia.scm.repository.work.SimpleCachingWorkingCopyPool") + return { project.javaexec { mainClass.set(ScmServer.name) args(new File(project.buildDir, 'server/config.json').toString()) environment 'NODE_ENV', 'development' classpath project.buildscript.configurations.classpath - systemProperties = ["user.home": extension.getHome(), "scm.initialPassword": "scmadmin", "scm.workingCopyPoolStrategy": "sonia.scm.repository.work.SimpleCachingWorkingCopyPool"] + systemProperties = runProperties if (debugJvm) { debug = true debugOptions { diff --git a/docs/de/user/alerts/assets/alerts-list.png b/docs/de/user/alerts/assets/alerts-list.png new file mode 100644 index 0000000000..e4c0340fb2 Binary files /dev/null and b/docs/de/user/alerts/assets/alerts-list.png differ diff --git a/docs/de/user/alerts/assets/alerts.png b/docs/de/user/alerts/assets/alerts.png new file mode 100644 index 0000000000..268b9d3e1e Binary files /dev/null and b/docs/de/user/alerts/assets/alerts.png differ diff --git a/docs/de/user/alerts/index.md b/docs/de/user/alerts/index.md new file mode 100644 index 0000000000..fbd5fe3821 --- /dev/null +++ b/docs/de/user/alerts/index.md @@ -0,0 +1,15 @@ +--- +title: Alerts +--- + +Alerts informieren im SCM-Manager über sicherheitskritische Fehler. + +Aktuelle sicherheitskritische Meldungen werden mit einem Schild links neben dem Suchfeld im Kopf des SCM-Managers angezeigt. Eine hochgestellte Zahl zeigt die Anzahl der Alerts. Wenn keine Alerts für die verwendete Version des SCM-Managers bekannt sind, wird das Icon nicht angezeigt. + +![alerts in head](assets/alerts.png) + +Hovern oder Klicken des Schildes öffnet die Liste der Alerts. Die einzelnen Alerts sind in Regel mit weiterführenden Informationen zur Sicherheitslücke oder Fixes verlinkt. + +![alerts in head](assets/alerts-list.png) + +Alerts verschwinden, sobald die Ursache z.B. durch ein Versionsupgrade behoben ist. diff --git a/docs/en/user/alerts/assets/alerts-list.png b/docs/en/user/alerts/assets/alerts-list.png new file mode 100644 index 0000000000..afd27cbfba Binary files /dev/null and b/docs/en/user/alerts/assets/alerts-list.png differ diff --git a/docs/en/user/alerts/assets/alerts.png b/docs/en/user/alerts/assets/alerts.png new file mode 100644 index 0000000000..229940da60 Binary files /dev/null and b/docs/en/user/alerts/assets/alerts.png differ diff --git a/docs/en/user/alerts/index.md b/docs/en/user/alerts/index.md new file mode 100644 index 0000000000..d548f84f15 --- /dev/null +++ b/docs/en/user/alerts/index.md @@ -0,0 +1,17 @@ +--- +title: Alerts +--- + +Alerts are used in SCM-Manager to alarm users and administrators to vulnerabilities in SCM-Manager. + +Current alerts are indicated by a shield icon with a number to the left of the search box in the header of SCM-Manager. The number indicates the number of issues. If there are no known vulnerabilities for the installed version SCM-Manager the icon will not be displayed. + +![alerts in head](assets/alerts.png) + +Hovering or clicking the shield icon opens a list of issues. Issues are linked to a related resource. This resource usually describes the vulnerability and steps to address the issue. + +![alerts in head](assets/alerts-list.png) + +Alerts are removed as soon as the issue is resolved in your instance, e.g. by upgrading to a fixed version. + + diff --git a/gradle/changelog/alerts.yaml b/gradle/changelog/alerts.yaml new file mode 100644 index 0000000000..7da4d4f88b --- /dev/null +++ b/gradle/changelog/alerts.yaml @@ -0,0 +1,2 @@ +- type: added + description: Security notifications to inform the running instance about known security issues ([#1924](https://github.com/scm-manager/scm-manager/pull/1924)) diff --git a/scm-core/src/main/java/sonia/scm/BasicContextProvider.java b/scm-core/src/main/java/sonia/scm/BasicContextProvider.java index 34deb7e552..e0cccecdfe 100644 --- a/scm-core/src/main/java/sonia/scm/BasicContextProvider.java +++ b/scm-core/src/main/java/sonia/scm/BasicContextProvider.java @@ -28,14 +28,18 @@ package sonia.scm; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.io.Files; +import sonia.scm.util.IOUtil; import sonia.scm.util.Util; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Locale; import java.util.Properties; +import java.util.UUID; //~--- JDK imports ------------------------------------------------------------ @@ -95,6 +99,7 @@ public class BasicContextProvider implements SCMContextProvider baseDirectory = findBaseDirectory(); version = determineVersion(); stage = loadProjectStage(); + instanceId = readOrCreateInstanceId(); } catch (Exception ex) { @@ -166,6 +171,11 @@ public class BasicContextProvider implements SCMContextProvider return version; } + @Override + public String getInstanceId() { + return instanceId; + } + //~--- methods -------------------------------------------------------------- /** @@ -275,6 +285,18 @@ public class BasicContextProvider implements SCMContextProvider return properties.getProperty(MAVEN_PROPERTY_VERSION, VERSION_DEFAULT); } + private String readOrCreateInstanceId() throws IOException { + File configDirectory = new File(baseDirectory, "config"); + IOUtil.mkdirs(configDirectory); + File instanceIdFile = new File(configDirectory, ".instance-id"); + if (instanceIdFile.exists()) { + return Files.asCharSource(instanceIdFile, StandardCharsets.UTF_8).read(); + } + String uuid = UUID.randomUUID().toString(); + Files.asCharSink(instanceIdFile, StandardCharsets.UTF_8).write(uuid); + return uuid; + } + //~--- fields --------------------------------------------------------------- /** The base directory of the SCM-Manager */ @@ -288,4 +310,7 @@ public class BasicContextProvider implements SCMContextProvider /** the version of the SCM-Manager */ private String version; + + /** the instance id of the SCM-Manager */ + private String instanceId; } diff --git a/scm-core/src/main/java/sonia/scm/SCMContextProvider.java b/scm-core/src/main/java/sonia/scm/SCMContextProvider.java index 7723f35a3a..4c0b3a9268 100644 --- a/scm-core/src/main/java/sonia/scm/SCMContextProvider.java +++ b/scm-core/src/main/java/sonia/scm/SCMContextProvider.java @@ -30,6 +30,7 @@ import sonia.scm.version.Version; import java.io.File; import java.nio.file.Path; +import java.util.UUID; import static java.lang.String.format; @@ -99,4 +100,14 @@ public interface SCMContextProvider { Version parsedVersion = Version.parse(getVersion()); return format("%s.%s.x", parsedVersion.getMajor(), parsedVersion.getMinor()); } + + /** + * Returns the instance id of the SCM-Manager used. + * + * @return instance id of the SCM-Manager + * @since 2.30.0 + */ + default String getInstanceId() { + return UUID.randomUUID().toString(); + } } diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 72d1a0e8b1..72a14521da 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -71,11 +71,20 @@ public class ScmConfiguration implements Configuration { /** * Default url for plugin center authentication. + * * @since 2.28.0 */ public static final String DEFAULT_PLUGIN_AUTH_URL = "https://plugin-center-api.scm-manager.org/api/v1/auth/oidc"; + /** + * SCM Manager alerts url. + * + * @since 2.30.0 + */ + public static final String DEFAULT_ALERTS_URL = + "https://alerts.scm-manager.org/api/v1/alerts"; + /** * SCM Manager release feed url */ @@ -164,6 +173,14 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "plugin-auth-url") private String pluginAuthUrl = DEFAULT_PLUGIN_AUTH_URL; + /** + * Url of the alerts api. + * + * @since 2.30.0 + */ + @XmlElement(name = "alerts-url") + private String alertsUrl = DEFAULT_ALERTS_URL; + @XmlElement(name = "release-feed-url") private String releaseFeedUrl = DEFAULT_RELEASE_FEED_URL; @@ -247,7 +264,7 @@ public class ScmConfiguration implements Configuration { /** * Load all properties from another {@link ScmConfiguration} object. * - * @param other + * @param other {@link ScmConfiguration} to load from */ public void load(ScmConfiguration other) { this.realmDescription = other.realmDescription; @@ -270,6 +287,7 @@ public class ScmConfiguration implements Configuration { this.enabledXsrfProtection = other.enabledXsrfProtection; this.namespaceStrategy = other.namespaceStrategy; this.loginInfoUrl = other.loginInfoUrl; + this.alertsUrl = other.alertsUrl; this.releaseFeedUrl = other.releaseFeedUrl; this.mailDomainName = other.mailDomainName; this.emergencyContacts = other.emergencyContacts; @@ -332,6 +350,7 @@ public class ScmConfiguration implements Configuration { /** * Returns the url which is used for plugin center authentication. + * * @return authentication url * @since 2.28.0 */ @@ -341,6 +360,7 @@ public class ScmConfiguration implements Configuration { /** * Returns {@code true} if the default plugin auth url is used. + * * @return {@code true} if the default plugin auth url is used * @since 2.28.0 */ @@ -348,6 +368,16 @@ public class ScmConfiguration implements Configuration { return DEFAULT_PLUGIN_AUTH_URL.equals(pluginAuthUrl); } + /** + * Returns the url of the alerts api. + * + * @return the alerts url. + * @since 2.30.0 + */ + public String getAlertsUrl() { + return alertsUrl; + } + /** * Returns the url of the rss release feed. * @@ -574,6 +604,7 @@ public class ScmConfiguration implements Configuration { /** * Set the url for plugin center authentication. + * * @param pluginAuthUrl authentication url * @since 2.28.0 */ @@ -581,6 +612,16 @@ public class ScmConfiguration implements Configuration { this.pluginAuthUrl = pluginAuthUrl; } + /** + * Set the url for the alerts api. + * + * @param alertsUrl alerts url + * @since 2.30.0 + */ + public void setAlertsUrl(String alertsUrl) { + this.alertsUrl = alertsUrl; + } + public void setReleaseFeedUrl(String releaseFeedUrl) { this.releaseFeedUrl = releaseFeedUrl; } diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 75ff8957c4..a7c94d4e8a 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -98,6 +98,8 @@ public class VndMediaType { public static final String NOTIFICATION_COLLECTION = PREFIX + "notificationCollection" + SUFFIX; + public static final String ALERTS_REQUEST = PREFIX + "alertsRequest" + SUFFIX; + public static final String QUERY_RESULT = PREFIX + "queryResult" + SUFFIX; public static final String SEARCHABLE_TYPE_COLLECTION = PREFIX + "searchableTypeCollection" + SUFFIX; diff --git a/scm-core/src/test/java/sonia/scm/BasicContextProviderTest.java b/scm-core/src/test/java/sonia/scm/BasicContextProviderTest.java index babba7dd3d..4810ddb1d9 100644 --- a/scm-core/src/test/java/sonia/scm/BasicContextProviderTest.java +++ b/scm-core/src/test/java/sonia/scm/BasicContextProviderTest.java @@ -24,6 +24,7 @@ package sonia.scm; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -91,4 +92,43 @@ class BasicContextProviderTest { } + @Nested + class InstanceIdTests { + + private String originalProperty; + + @BeforeEach + void setUp() { + originalProperty = System.getProperty(BasicContextProvider.DIRECTORY_PROPERTY); + } + + @AfterEach + void tearDown() { + if (originalProperty != null) { + System.setProperty(BasicContextProvider.DIRECTORY_PROPERTY, originalProperty); + } + } + + @Test + void shouldReturnInstanceId(@TempDir Path baseDirectory) { + System.setProperty(BasicContextProvider.DIRECTORY_PROPERTY, baseDirectory.toString()); + BasicContextProvider provider = new BasicContextProvider(); + + assertThat(provider.getInstanceId()).isNotBlank(); + } + + @Test + void shouldReturnPersistedInstanceId(@TempDir Path baseDirectory) { + System.setProperty(BasicContextProvider.DIRECTORY_PROPERTY, baseDirectory.toString()); + BasicContextProvider provider = new BasicContextProvider(); + + String firstInstanceId = provider.getInstanceId(); + + provider = new BasicContextProvider(); + + assertThat(provider.getInstanceId()).isEqualTo(firstInstanceId); + } + + } + } diff --git a/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java b/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java index bba3ef8304..fd5bad67e4 100644 --- a/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java +++ b/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java @@ -43,6 +43,7 @@ import sonia.scm.NotFoundException; import sonia.scm.ScmConstraintViolationException; import javax.ws.rs.Produces; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.ext.ContextResolver; @@ -111,13 +112,16 @@ public class RestDispatcher { } private Integer getStatus(Exception ex) { + if (ex instanceof WebApplicationException) { + return ((WebApplicationException) ex).getResponse().getStatus(); + } return statusCodes .entrySet() .stream() .filter(e -> e.getKey().isAssignableFrom(ex.getClass())) .map(Map.Entry::getValue) .findAny() - .orElse(handleUnknownException(ex)); + .orElseGet(() -> handleUnknownException(ex)); } private Integer handleUnknownException(Exception ex) { diff --git a/scm-ui/ui-api/src/alerts.ts b/scm-ui/ui-api/src/alerts.ts new file mode 100644 index 0000000000..f7be8f92c8 --- /dev/null +++ b/scm-ui/ui-api/src/alerts.ts @@ -0,0 +1,107 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { useQuery } from "react-query"; +import { apiClient } from "./apiclient"; +import { ApiResult, useIndexLink } from "./base"; +import { AlertsResponse, HalRepresentation, Link } from "@scm-manager/ui-types"; + +type AlertRequest = HalRepresentation & { + checksum: string; + body: unknown; +}; + +type LocalStorageAlerts = AlertsResponse & { + checksum: string; +}; + +const alertsFromStorage = (): LocalStorageAlerts | undefined => { + const item = localStorage.getItem("alerts"); + if (item) { + return JSON.parse(item); + } +}; + +const fetchAlerts = (request: AlertRequest) => { + const url = (request._links["alerts"] as Link)?.href; + if (!url) { + throw new Error("no alerts link defined"); + } + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(request.body) + }) + .then(response => { + if (!response.ok) { + throw new Error("Failed to fetch alerts"); + } + return response; + }) + .then(response => response.json()) + .then((data: AlertsResponse) => { + const storageItem: LocalStorageAlerts = { + ...data, + checksum: request.checksum + }; + localStorage.setItem("alerts", JSON.stringify(storageItem)); + return data; + }); +}; + +const restoreOrFetch = (request: AlertRequest): Promise => { + const storedAlerts = alertsFromStorage(); + if (!storedAlerts || storedAlerts.checksum !== request.checksum) { + return fetchAlerts(request); + } + return Promise.resolve(storedAlerts); +}; + +export const useAlerts = (): ApiResult => { + const link = useIndexLink("alerts"); + const { data, error, isLoading } = useQuery( + "alerts", + () => { + if (!link) { + throw new Error("Could not find alert link"); + } + return apiClient + .get(link) + .then(response => response.json()) + .then(restoreOrFetch); + }, + { + enabled: !!link, + staleTime: Infinity + } + ); + + return { + data, + error, + isLoading + }; +}; diff --git a/scm-ui/ui-api/src/config.test.ts b/scm-ui/ui-api/src/config.test.ts index 2b1f7311a0..8b3bbb66c4 100644 --- a/scm-ui/ui-api/src/config.test.ts +++ b/scm-ui/ui-api/src/config.test.ts @@ -57,6 +57,7 @@ describe("Test config hooks", () => { proxyServer: "", proxyUser: null, realmDescription: "", + alertsUrl: "", releaseFeedUrl: "", skipFailedAuthenticators: false, _links: { diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 5ca6572913..aa058f1453 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -51,6 +51,7 @@ export * from "./sources"; export * from "./import"; export * from "./diff"; export * from "./notifications"; +export * from "./alerts"; export * from "./configLink"; export * from "./apiKeys"; export * from "./publicKeys"; diff --git a/scm-ui/ui-types/src/Alerts.ts b/scm-ui/ui-types/src/Alerts.ts new file mode 100644 index 0000000000..498ee890a1 --- /dev/null +++ b/scm-ui/ui-types/src/Alerts.ts @@ -0,0 +1,41 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export type Alert = { + title: string; + description: string; + link?: string; + issuedAt: string; + affectedVersions?: string; +}; + +export type PluginAlerts = { + name: string; + alerts: Alert[]; +}; + +export type AlertsResponse = { + alerts?: Alert[]; + plugins?: PluginAlerts[]; +}; diff --git a/scm-ui/ui-types/src/Config.ts b/scm-ui/ui-types/src/Config.ts index dce8616fd1..8d94d83aa2 100644 --- a/scm-ui/ui-types/src/Config.ts +++ b/scm-ui/ui-types/src/Config.ts @@ -49,6 +49,7 @@ export type Config = HalRepresentation & { enabledUserConverter: boolean; namespaceStrategy: string; loginInfoUrl: string; + alertsUrl: string; releaseFeedUrl: string; mailDomainName: string; emergencyContacts: string[]; diff --git a/scm-ui/ui-types/src/index.ts b/scm-ui/ui-types/src/index.ts index 535aaab5a1..5f7ac49caa 100644 --- a/scm-ui/ui-types/src/index.ts +++ b/scm-ui/ui-types/src/index.ts @@ -67,6 +67,7 @@ export * from "./Admin"; export * from "./Diff"; export * from "./Notifications"; +export * from "./Alerts"; export * from "./ApiKeys"; export * from "./PublicKeys"; export * from "./GlobalPermissions"; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index eaba13c63b..1d06c46cbd 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -164,6 +164,9 @@ "dismiss": "Löschen", "dismissAll": "Alle löschen" }, + "alerts": { + "shieldTitle": "Alerts" + }, "cardColumnGroup": { "showContent": "Inhalt einblenden", "hideContent": "Inhalt ausblenden" diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index f3056f2940..394a956e44 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -67,6 +67,7 @@ "off": "Deaktivieren" }, "skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen", + "alerts-url": "Alerts URL", "release-feed-url": "Release Feed URL", "mail-domain-name": "Fallback E-Mail Domain Name", "enabled-xsrf-protection": "XSRF Protection aktivieren", @@ -92,6 +93,7 @@ "dateFormatHelpText": "Moments Datumsformat. Zulässige Formate sind in der MomentJS Dokumentation beschrieben.", "pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", "pluginAuthUrlHelpText": "Die URL der Plugin Center Authentifizierungs API.", + "alertsUrlHelpText": "Die URL der Alerts API. Darüber wird über Alerts die Ihr System betreffen informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.", "releaseFeedUrlHelpText": "Die URL des RSS Release Feed des SCM-Manager. Darüber wird über die neue SCM-Manager Version informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.", "mailDomainNameHelpText": "Dieser Domain Name wird genutzt, wenn für einen User eine E-Mail-Adresse benötigt wird, für den keine hinterlegt ist. Diese Domain wird nicht zum Versenden von E-Mails genutzt und auch keine anderweitige Verbindung aufgebaut.", "enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.", diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index 323344d967..4611c1b0b7 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -165,6 +165,9 @@ "dismiss": "Dismiss", "dismissAll": "Dismiss all" }, + "alerts": { + "shieldTitle": "Alerts" + }, "cardColumnGroup": { "showContent": "Show content", "hideContent": "Hide content" diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index 7a18d41a71..e6d19a6d35 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -67,6 +67,7 @@ "off": "Disabled" }, "skip-failed-authenticators": "Skip Failed Authenticators", + "alerts-url": "Alerts URL", "release-feed-url": "Release Feed URL", "mail-domain-name": "Fallback Mail Domain Name", "enabled-xsrf-protection": "Enabled XSRF Protection", @@ -92,6 +93,7 @@ "dateFormatHelpText": "Moments date format. Please have a look at the MomentJS documentation.", "pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", "pluginAuthUrlHelpText": "The url of the Plugin Center authentication API.", + "alertsUrlHelpText": "The url of the alerts api. This provides up-to-date alerts regarding your system. To disable this feature just leave the url blank.", "releaseFeedUrlHelpText": "The url of the RSS Release Feed for SCM-Manager. This provides up-to-date version information. To disable this feature just leave the url blank.", "mailDomainNameHelpText": "This domain name will be used to create email addresses for users without one when needed. It will not be used to send mails nor will be accessed otherwise.", "enableForwardingHelpText": "Enable mod_proxy port forwarding.", diff --git a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx index 6cef88cd61..63f55a2fa4 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, useState, useEffect, FormEvent } from "react"; +import React, { FC, FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Config, ConfigChangeHandler, NamespaceStrategies } from "@scm-manager/ui-types"; import { Level, Notification, SubmitButton } from "@scm-manager/ui-components"; @@ -72,6 +72,7 @@ const ConfigForm: FC = ({ enabledUserConverter: false, namespaceStrategy: "", loginInfoUrl: "", + alertsUrl: "", releaseFeedUrl: "", mailDomainName: "", emergencyContacts: [], @@ -144,6 +145,7 @@ const ConfigForm: FC = ({ dateFormat={innerConfig.dateFormat} anonymousMode={innerConfig.anonymousMode} skipFailedAuthenticators={innerConfig.skipFailedAuthenticators} + alertsUrl={innerConfig.alertsUrl} releaseFeedUrl={innerConfig.releaseFeedUrl} mailDomainName={innerConfig.mailDomainName} enabledXsrfProtection={innerConfig.enabledXsrfProtection} diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx index 178ef2130b..ad5f2de91b 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx @@ -24,12 +24,12 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import { useUserSuggestions } from "@scm-manager/ui-api"; -import { NamespaceStrategies, AnonymousMode, SelectValue, ConfigChangeHandler } from "@scm-manager/ui-types"; +import { AnonymousMode, ConfigChangeHandler, NamespaceStrategies, SelectValue } from "@scm-manager/ui-types"; import { + AutocompleteAddEntryToTableField, Checkbox, InputField, MemberNameTagGroup, - AutocompleteAddEntryToTableField, Select } from "@scm-manager/ui-components"; import NamespaceStrategySelect from "./NamespaceStrategySelect"; @@ -41,6 +41,7 @@ type Props = { dateFormat: string; anonymousMode: AnonymousMode; skipFailedAuthenticators: boolean; + alertsUrl: string; releaseFeedUrl: string; mailDomainName: string; enabledXsrfProtection: boolean; @@ -57,6 +58,7 @@ const GeneralSettings: FC = ({ realmDescription, loginInfoUrl, anonymousMode, + alertsUrl, releaseFeedUrl, mailDomainName, enabledXsrfProtection, @@ -89,6 +91,9 @@ const GeneralSettings: FC = ({ const handleNamespaceStrategyChange = (value: string) => { onChange(true, value, "namespaceStrategy"); }; + const handleAlertsUrlChange = (value: string) => { + onChange(true, value, "alertsUrl"); + }; const handleReleaseFeedUrlChange = (value: string) => { onChange(true, value, "releaseFeedUrl"); }; @@ -207,7 +212,16 @@ const GeneralSettings: FC = ({
-
+
+ +
+
` + min-width: 35rem; + + @media screen and (max-width: ${devices.desktop.width}px) { + min-width: 30rem; + } + + @media screen and (max-width: ${devices.tablet.width}px) { + min-width: 25rem; + } + + @media screen and (max-width: ${devices.mobile.width}px) { + min-width: 20rem; + ${props => + props.mobilePosition === "right" && + css` + right: -1.5rem; + left: auto; + `}; + + } + + @media screen and (max-width: ${devices.desktop.width - 1}px) { + margin-right: 1rem; + } + + @media screen and (min-width: ${devices.desktop.width}px) { + right: 0; + left: auto; + } + + &:before { + position: absolute; + content: ""; + pointer-events: none; + height: 0; + width: 0; + top: -7px; // top padding of dropdown-menu + border-spacing + transform-origin: center; + transform: rotate(135deg); + + @media screen and (max-width: ${devices.desktop.width - 1}px) { + left: 1.3rem; + } + + @media screen and (min-width: ${devices.desktop.width}px) { + right: 1.3rem; + } + + ${props => + props.mobilePosition === "right" && + css` + @media screen and (max-width: ${devices.mobile.width}px) { + left: auto; + right: 1.75rem; + } + `}; + } +`; + +export const Table = styled.table` + border-collapse: collapse; +`; + +export const Column = styled.td` + vertical-align: middle !important; +`; + +export const NonWrappingColumn = styled(Column)` + white-space: nowrap; +`; + +const DropdownMenuContainer: FC = ({ children }) => ( +
{children}
+); + +const ErrorBox: FC<{ error?: Error | null }> = ({ error }) => { + if (!error) { + return null; + } + return ( + + + + ); +}; + +const LoadingBox: FC = () => ( +
+ +
+); + +const IconContainer = styled.div` + width: 2rem; + height: 2rem; +`; + +type CounterProps = { + count: string; +}; + +const Counter = styled.span` + position: absolute; + top: -0.75rem; + right: ${props => (props.count.length <= 1 ? "-0.25" : "-0.50")}rem; +`; + +type IconWrapperProps = { + icon: React.ReactNode; + count?: string; +}; + +const IconWrapper: FC = ({ icon, count }) => ( + + {icon} + {count ? {count} : null} + +); + +type Props = DropDownMenuProps & { + className?: string; + icon: React.ReactNode; + count?: string; + error?: Error | null; + isLoading?: boolean; +}; + +const DropDownTrigger = styled.div` + padding: 0.65rem 0.75rem; +`; + +const HeaderDropDown: FC = ({ className, icon, count, error, isLoading, mobilePosition, children }) => { + const [open, setOpen] = useState(false); + + useEffect(() => { + const close = () => setOpen(false); + window.addEventListener("click", close); + return () => window.removeEventListener("click", close); + }, []); + + return ( + <> +
e.stopPropagation()} + > + setOpen(o => !o)} + > + + + + + {isLoading ? : null} + {children} + +
+ + ); +}; + +export default HeaderDropDown; diff --git a/scm-ui/ui-webapp/src/containers/Alerts.tsx b/scm-ui/ui-webapp/src/containers/Alerts.tsx new file mode 100644 index 0000000000..7b16ddcc68 --- /dev/null +++ b/scm-ui/ui-webapp/src/containers/Alerts.tsx @@ -0,0 +1,142 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import styled from "styled-components"; +import { Alert } from "@scm-manager/ui-types"; +import { DateFromNow, Icon } from "@scm-manager/ui-components"; +import { useAlerts } from "@scm-manager/ui-api"; +import HeaderDropDown, { Column, NonWrappingColumn, Table } from "../components/HeaderDropDown"; + +const FullHeightTable = styled(Table)` + height: 100%; +`; + +const RightColumn = styled(NonWrappingColumn)` + height: 100%; +`; + +type EntryProps = { + alert: ComponentAlert; +}; + +const AlertsEntry: FC = ({ alert }) => { + const navigateTo = () => { + if (alert.link) { + window.open(alert.link)?.focus(); + } + }; + + return ( + + +

{alert.title}

+

{alert.description}

+
+ +
+

+ {alert.component} {alert.affectedVersions} +

+ +
+
+ + ); +}; + +type Props = { + data: ComponentAlert[]; +}; + +const AlertsList: FC = ({ data }) => ( +
+ + + {data.map((a, i) => ( + + ))} + + +
+); + +const ShieldNotificationIcon: FC = () => { + const [t] = useTranslation("commons"); + return ; +}; + +type ComponentAlert = Alert & { + component: string; +}; + +const useFlattenedAlerts = () => { + const { data, error } = useAlerts(); + + if (data) { + const flattenedAlerts: ComponentAlert[] = data.alerts?.map(a => ({ ...a, component: "core" })) || []; + data.plugins?.forEach(p => flattenedAlerts.push(...(p.alerts || []).map(a => ({ ...a, component: p.name })))); + flattenedAlerts.sort((a, b) => { + if (new Date(a.issuedAt) < new Date(b.issuedAt)) { + return 1; + } + return -1; + }); + return { + data: flattenedAlerts, + error + }; + } + + return { + data, + error + }; +}; + +type AlertsProps = { + className?: string; +}; + +const Alerts: FC = ({ className }) => { + const { data, error } = useFlattenedAlerts(); + if ((!data || data.length === 0) && !error) { + return null; + } + return ( + } + count={data ? data.length.toString() : "?"} + error={error} + className={className} + mobilePosition="right" + > + {data ? : null} + + ); +}; + +export default Alerts; diff --git a/scm-ui/ui-webapp/src/containers/NavigationBar.tsx b/scm-ui/ui-webapp/src/containers/NavigationBar.tsx index d6d528fc6a..9ab844b13f 100644 --- a/scm-ui/ui-webapp/src/containers/NavigationBar.tsx +++ b/scm-ui/ui-webapp/src/containers/NavigationBar.tsx @@ -21,6 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ + import React, { FC, useEffect, useState } from "react"; import { Links } from "@scm-manager/ui-types"; import classNames from "classnames"; @@ -31,6 +32,7 @@ import OmniSearch from "./OmniSearch"; import LogoutButton from "./LogoutButton"; import LoginButton from "./LoginButton"; import { useTranslation } from "react-i18next"; +import Alerts from "./Alerts"; const StyledMenuBar = styled.div` background-color: transparent !important; @@ -147,6 +149,7 @@ const NavigationBar: FC = ({ links }) => {
+
diff --git a/scm-ui/ui-webapp/src/containers/Notifications.tsx b/scm-ui/ui-webapp/src/containers/Notifications.tsx index abdbfa226b..a21e34c5d2 100644 --- a/scm-ui/ui-webapp/src/containers/Notifications.tsx +++ b/scm-ui/ui-webapp/src/containers/Notifications.tsx @@ -21,8 +21,9 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, useEffect, useState } from "react"; -import { useHistory, Link } from "react-router-dom"; + +import React, { FC } from "react"; +import { Link, useHistory } from "react-router-dom"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; @@ -35,60 +36,15 @@ import { import { Notification, NotificationCollection } from "@scm-manager/ui-types"; import { Button, - Notification as InfoNotification, + DateFromNow, ErrorNotification, Icon, + Notification as InfoNotification, ToastArea, ToastNotification, - ToastType, - Loading, - DateFromNow, - devices + ToastType } from "@scm-manager/ui-components"; - -const DropDownMenu = styled.div` - min-width: 35rem; - - @media screen and (max-width: ${devices.mobile.width}px) { - min-width: 20rem; - } - - @media screen and (max-width: ${devices.desktop.width - 1}px) { - margin-right: 1rem; - } - - @media screen and (min-width: ${devices.desktop.width}px) { - right: 0; - left: auto; - } - - &:before { - position: absolute; - content: ""; - pointer-events: none; - height: 0; - width: 0; - top: -7px; // top padding of dropdown-menu + border-spacing - transform-origin: center; - transform: rotate(135deg); - - @media screen and (max-width: ${devices.desktop.width - 1}px) { - left: 1.3rem; - } - - @media screen and (min-width: ${devices.desktop.width}px) { - right: 1.3rem; - } - } -`; - -const VerticalCenteredTd = styled.td` - vertical-align: middle !important; -`; - -const DateColumn = styled(VerticalCenteredTd)` - white-space: nowrap; -`; +import HeaderDropDown, { Column, NonWrappingColumn, Table } from "../components/HeaderDropDown"; const DismissColumn = styled.td` vertical-align: middle !important; @@ -115,23 +71,17 @@ const NotificationEntry: FC = ({ notification, removeToast }) => { } return ( - history.push(notification.link)} className="is-clickable"> + history.push(notification.link)} className="is-clickable"> - - + + - - + + {isLoading ? (
) : ( - + )} @@ -153,7 +103,7 @@ const ClearEntry: FC = ({ notifications, clearToasts }) => { return (
-
@@ -164,18 +114,18 @@ const NotificationList: FC = ({ data, clear, remove }) => { const [t] = useTranslation("commons"); const clearLink = data._links.clear; - const all = [...data._embedded.notifications].reverse(); + const all = [...(data._embedded?.notifications || [])].reverse(); const top = all.slice(0, 6); return (
- +
{top.map((n, i) => ( ))} -
+ {all.length > 6 ? (

{t("notifications.xMore", { count: all.length - 6 })} @@ -207,7 +157,7 @@ type Props = { const NotificationDropDown: FC = ({ data, remove, clear }) => ( <> - {data._embedded.notifications.length > 0 ? ( + {(data._embedded?.notifications.length ?? 0) > 0 ? ( ) : ( @@ -260,61 +210,29 @@ const NotificationSubscription: FC = ({ notifications, remove ); }; -const BellNotificationContainer = styled.div` - width: 2rem; - height: 2rem; -`; - -type NotificationCounterProps = { - count: number; -}; - -const NotificationCounter = styled.span` - position: absolute; - top: -0.5rem; - right: ${props => (props.count < 10 ? "0" : "-0.25")}rem; -`; - type BellNotificationIconProps = { data?: NotificationCollection; - onClick: () => void; }; -const BellNotificationIcon: FC = ({ data, onClick }) => { +const BellNotificationIcon: FC = ({ data }) => { const [t] = useTranslation("commons"); - const counter = data?._embedded.notifications.length || 0; + const counter = data?._embedded?.notifications.length || 0; return ( - - - {counter > 0 ? {counter < 100 ? counter : "∞"} : null} - + ); }; -const LoadingBox: FC = () => ( -

- -
-); - -const ErrorBox: FC<{ error: Error | null }> = ({ error }) => { - if (!error) { - return null; +const count = (data?: NotificationCollection) => { + const counter = data?._embedded?.notifications.length || 0; + if (counter !== 0) { + return counter < 100 ? counter.toString() : "∞"; } - return ( - - - - ); }; type NotificationProps = { @@ -325,37 +243,19 @@ const Notifications: FC = ({ className }) => { const { data, isLoading, error, refetch } = useNotifications(); const { notifications, remove, clear } = useNotificationSubscription(refetch, data); - const [open, setOpen] = useState(false); - useEffect(() => { - const close = () => setOpen(false); - window.addEventListener("click", close); - return () => window.removeEventListener("click", close); - }, []); - return ( <> -
e.stopPropagation()} + } + count={count(data)} + mobilePosition="left" > -
- setOpen(o => !o)} /> -
- - - {isLoading ? : null} - {data ? : null} - -
+ {data ? : null} + ); }; diff --git a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx index 35b8c8f607..a311dfad19 100644 --- a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx +++ b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx @@ -28,7 +28,14 @@ import { useSearch } from "@scm-manager/ui-api"; import classNames from "classnames"; import { Link, useHistory, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Button, HitProps, Notification, RepositoryAvatar, useStringHitFieldValue } from "@scm-manager/ui-components"; +import { + Button, + devices, + HitProps, + Notification, + RepositoryAvatar, + useStringHitFieldValue +} from "@scm-manager/ui-components"; import SyntaxHelp from "../search/SyntaxHelp"; import SyntaxModal from "../search/SyntaxModal"; import SearchErrorNotification from "../search/SearchErrorNotification"; @@ -83,6 +90,12 @@ const ResultFooter = styled.div` border-top: 1px solid lightgray; `; +const SearchInput = styled(Input)` + @media screen and (max-width: ${devices.mobile.width}px) { + width: 9rem; + } +`; + const AvatarSection: FC = ({ hit }) => { const namespace = useStringHitFieldValue(hit, "namespace"); const name = useStringHitFieldValue(hit, "name"); @@ -366,7 +379,7 @@ const OmniSearch: FC = () => { >
- dateSupplier; + + @Inject + public AlertsResource(SCMContextProvider scmContextProvider, ScmConfiguration scmConfiguration, PluginLoader pluginLoader) { + this(scmContextProvider, scmConfiguration, pluginLoader, () -> LocalDateTime.now().format(FORMATTER)); + } + + @VisibleForTesting + AlertsResource(SCMContextProvider scmContextProvider, ScmConfiguration scmConfiguration, PluginLoader pluginLoader, Supplier dateSupplier) { + this.scmContextProvider = scmContextProvider; + this.scmConfiguration = scmConfiguration; + this.pluginLoader = pluginLoader; + this.dateSupplier = dateSupplier; + } + + @GET + @Path("") + @Produces(VndMediaType.ALERTS_REQUEST) + @Operation( + summary = "Alerts", + description = "Returns url and body prepared for the alert service", + tags = "Alerts", + operationId = "alerts_get_request" + ) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.ALERTS_REQUEST, + schema = @Schema(implementation = HalRepresentation.class) + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public AlertsRequest getAlertsRequest(@Context UriInfo uriInfo) throws IOException { + if (Strings.isNullOrEmpty(scmConfiguration.getAlertsUrl())) { + throw new WebApplicationException("Alerts disabled", Response.Status.CONFLICT); + } + + String instanceId = scmContextProvider.getInstanceId(); + String version = scmContextProvider.getVersion(); + String os = SystemUtil.getOS(); + String arch = SystemUtil.getArch(); + String jre = SystemUtil.getJre(); + + List plugins = pluginLoader.getInstalledPlugins().stream() + .map(p -> p.getDescriptor().getInformation()) + .map(i -> new Plugin(i.getName(), i.getVersion())) + .collect(Collectors.toList()); + + String url = scmConfiguration.getAlertsUrl(); + AlertsRequestBody body = new AlertsRequestBody(instanceId, version, os, arch, jre, plugins); + String checksum = createChecksum(url, body); + + Links links = createLinks(uriInfo, url); + return new AlertsRequest(links, checksum, body); + } + + private Links createLinks(UriInfo uriInfo, String alertsUrl) { + return Links.linkingTo() + .self(uriInfo.getAbsolutePath().toASCIIString()) + .single(Link.link("alerts", alertsUrl)) + .build(); + } + + @SuppressWarnings("UnstableApiUsage") + private String createChecksum(String url, AlertsRequestBody body) throws IOException { + Hasher hasher = Hashing.sha256().newHasher(); + hasher.putString(url, StandardCharsets.UTF_8); + hasher.putString(dateSupplier.get(), StandardCharsets.UTF_8); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(baos)) { + out.writeObject(body); + } + + hasher.putBytes(baos.toByteArray()); + return hasher.hash().toString(); + } + + @Getter + @Setter + @NoArgsConstructor + @SuppressWarnings("java:S2160") // we need no equals here + public static class AlertsRequest extends HalRepresentation { + + private String checksum; + private AlertsRequestBody body; + + public AlertsRequest(Links links, String checksum, AlertsRequestBody body) { + super(links); + this.checksum = checksum; + this.body = body; + } + } + + @Value + public static class AlertsRequestBody implements Serializable { + + String instanceId; + String version; + String os; + String arch; + String jre; + @SuppressWarnings("java:S1948") // the field is serializable, but sonar does not get it + List plugins; + + } + + @Value + public static class Plugin implements Serializable { + + String name; + String version; + + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index 9c5f695cb4..d4f2c3c0bc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -61,6 +61,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto { private boolean enabledApiKeys; private String namespaceStrategy; private String loginInfoUrl; + private String alertsUrl; private String releaseFeedUrl; private String mailDomainName; private Set emergencyContacts; 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 0deea54e22..767f99d938 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 @@ -146,6 +146,10 @@ public class IndexDtoGenerator extends HalAppenderMapper { builder.array(searchLinks()); builder.single(link("searchableTypes", resourceLinks.search().searchableTypes())); + + if (!Strings.isNullOrEmpty(configuration.getAlertsUrl())) { + builder.single(link("alerts", resourceLinks.alerts().get())); + } } else { builder.single(link("login", resourceLinks.authentication().jsonLogin())); } 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 5de7e80a00..8fbcfe5132 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 @@ -1222,4 +1222,23 @@ class ResourceLinks { return indexLinkBuilder.method("authResource").parameters().method("authenticationInfo").parameters().href(); } } + + public AlertsLinks alerts() { + return new AlertsLinks(scmPathInfoStore.get().get()); + } + + static class AlertsLinks { + + private final LinkBuilder indexLinkBuilder; + + AlertsLinks(ScmPathInfo pathInfo) { + indexLinkBuilder = new LinkBuilder(pathInfo, AlertsResource.class); + } + + String get() { + return indexLinkBuilder.method("getAlertsRequest").parameters().href(); + } + + } + } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java index f62c51db13..fc413cb0e1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java @@ -71,6 +71,14 @@ interface UpdateConfigDto { String getLoginInfoUrl(); + /** + * Get the url to the alerts api. + * + * @return alerts url + * @since 2.30.0 + */ + String getAlertsUrl(); + String getReleaseFeedUrl(); String getMailDomainName(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java index 777c4a11d9..8758232261 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java @@ -30,6 +30,7 @@ import com.google.common.base.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; @@ -84,6 +85,16 @@ public final class ExplodedSmp //~--- get methods ---------------------------------------------------------- + /** + * Returns {@code true} if the exploded smp contains a core plugin + * @return {@code true} for a core plugin + * @since 2.30.0 + */ + public boolean isCore() { + return Files.exists(path.resolve(PluginConstants.FILE_CORE)); + } + + /** * Returns the path to the plugin directory. * 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 15e12bd6eb..6eabee1c8a 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -34,6 +34,7 @@ import com.google.common.collect.Sets; import com.google.common.hash.Hashing; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.SCMContext; import sonia.scm.lifecycle.classloading.ClassLoaderLifeCycle; import javax.xml.bind.JAXBContext; @@ -175,7 +176,7 @@ public final class PluginProcessor Set plugins = concat(installedPlugins, newlyInstalledPlugins); logger.trace("start building plugin tree"); - PluginTree pluginTree = new PluginTree(plugins); + PluginTree pluginTree = new PluginTree(SCMContext.getContext().getStage(), plugins); logger.info("install plugin tree:\n{}", pluginTree); @@ -468,16 +469,13 @@ 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, core); + plugin = new InstalledPlugin(descriptor, cl, resourceLoader, directory, smp.isCore()); } else { logger.warn("found plugin directory without plugin descriptor"); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java index 9ea241919a..b2def0c447 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java @@ -24,10 +24,10 @@ package sonia.scm.plugin; -//~--- non-JDK imports -------------------------------------------------------- import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.Stage; import java.util.Arrays; import java.util.Collection; @@ -35,76 +35,62 @@ import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; -//~--- JDK imports ------------------------------------------------------------ - /** - * * @author Sebastian Sdorra */ -public final class PluginTree -{ +public final class PluginTree { - /** Field description */ private static final int SCM_VERSION = 2; /** * the logger for PluginTree */ - private static final Logger logger = - LoggerFactory.getLogger(PluginTree.class); + private static final Logger LOG = LoggerFactory.getLogger(PluginTree.class); - //~--- constructors --------------------------------------------------------- + private final Stage stage; + private final List rootNodes; - /** - * Constructs ... - * - * - * @param smps - */ - public PluginTree(ExplodedSmp... smps) - { - this(Arrays.asList(smps)); + public PluginTree(Stage stage, ExplodedSmp... smps) { + this(stage, Arrays.asList(smps)); } - /** - * Constructs ... - * - * - * @param smps - */ - public PluginTree(Collection smps) - { - + public PluginTree(Stage stage, Collection smps) { + this.stage = stage; smps.forEach(s -> { InstalledPluginDescriptor plugin = s.getPlugin(); - logger.trace("plugin: {}", plugin.getInformation().getName()); - logger.trace("dependencies: {}", plugin.getDependencies()); - logger.trace("optional dependencies: {}", plugin.getOptionalDependencies()); + LOG.trace("plugin: {}", plugin.getInformation().getName()); + LOG.trace("dependencies: {}", plugin.getDependencies()); + LOG.trace("optional dependencies: {}", plugin.getOptionalDependencies()); }); rootNodes = new SmpNodeBuilder().buildNodeTree(smps); - for (ExplodedSmp smp : smps) - { - InstalledPluginDescriptor plugin = smp.getPlugin(); + for (ExplodedSmp smp : smps) { + checkIfSupported(smp); + } + } - if (plugin.getScmVersion() != SCM_VERSION) - { - //J- - throw new PluginException( - String.format( - "scm version %s of plugin %s does not match, required is version %s", - plugin.getScmVersion(), plugin.getInformation().getId(), SCM_VERSION - ) - ); - //J+ - } + private void checkIfSupported(ExplodedSmp smp) { + InstalledPluginDescriptor plugin = smp.getPlugin(); - PluginCondition condition = plugin.getCondition(); + if (plugin.getScmVersion() != SCM_VERSION) { + throw new PluginException( + String.format( + "scm version %s of plugin %s does not match, required is version %s", + plugin.getScmVersion(), plugin.getInformation().getId(), SCM_VERSION + ) + ); + } - if (!condition.isSupported()) - { - //J- + checkIfConditionsMatch(smp, plugin); + } + + private void checkIfConditionsMatch(ExplodedSmp smp, InstalledPluginDescriptor plugin) { + PluginCondition condition = plugin.getCondition(); + if (!condition.isSupported()) { + if (smp.isCore() && stage == Stage.DEVELOPMENT) { + LOG.warn("plugin {} does not match conditions {}", plugin.getInformation().getId(), condition); + } else { throw new PluginConditionFailedException( condition, String.format( @@ -112,29 +98,17 @@ public final class PluginTree plugin.getInformation().getId(), condition ) ); - //J+ } } } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public List getLeafLastNodes() - { + public List getLeafLastNodes() { LinkedHashSet leafFirst = new LinkedHashSet<>(); rootNodes.forEach(node -> appendLeafFirst(leafFirst, node)); LinkedList leafLast = new LinkedList<>(); - leafFirst.forEach(leafLast::addFirst); - return leafLast; } @@ -143,9 +117,6 @@ public final class PluginTree leafFirst.add(node); } - - //~--- methods -------------------------------------------------------------- - @Override public String toString() { StringBuilder buffer = new StringBuilder(); @@ -163,8 +134,4 @@ public final class PluginTree } } - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final List rootNodes; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AlertsResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AlertsResourceTest.java new file mode 100644 index 0000000000..6d4206ffbf --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AlertsResourceTest.java @@ -0,0 +1,254 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.api.v2.resources.AlertsResource.AlertsRequest; +import sonia.scm.api.v2.resources.AlertsResource.AlertsRequestBody; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.plugin.InstalledPlugin; +import sonia.scm.plugin.InstalledPluginDescriptor; +import sonia.scm.plugin.PluginInformation; +import sonia.scm.plugin.PluginLoader; +import sonia.scm.util.SystemUtil; +import sonia.scm.web.RestDispatcher; +import sonia.scm.web.VndMediaType; + +import java.io.UnsupportedEncodingException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +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 AlertsResourceTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Mock + private PluginLoader pluginLoader; + + @Mock + private SCMContextProvider scmContextProvider; + + private RestDispatcher restDispatcher; + private ScmConfiguration scmConfiguration; + + @BeforeEach + void setUp() { + restDispatcher = new RestDispatcher(); + scmConfiguration = new ScmConfiguration(); + } + + @Test + void shouldFailWithConflictIfAlertsUrlIsNull() throws Exception { + scmConfiguration.setAlertsUrl(null); + + MockHttpResponse response = invoke(); + + assertThat(response.getStatus()).isEqualTo(409); + } + + @Test + void shouldReturnSelfUrl() throws Exception { + MockHttpResponse response = invoke(); + JsonNode node = mapper.readTree(response.getContentAsString()); + assertThat(node.get("_links").get("self").get("href").asText()).isEqualTo("/v2/alerts"); + } + + @Test + void shouldReturnAlertsUrl() throws Exception { + MockHttpResponse response = invoke(); + JsonNode node = mapper.readTree(response.getContentAsString()); + assertThat(node.get("_links").get("alerts").get("href").asText()).isEqualTo(ScmConfiguration.DEFAULT_ALERTS_URL); + } + + @Test + void shouldReturnVndMediaType() throws Exception { + MockHttpResponse response = invoke(); + assertThat(response.getOutputHeaders().getFirst("Content-Type")).hasToString(VndMediaType.ALERTS_REQUEST); + } + + @Test + void shouldReturnCustomAlertsUrl() throws Exception { + scmConfiguration.setAlertsUrl("https://mycustom.alerts.io"); + + MockHttpResponse response = invoke(); + JsonNode node = mapper.readTree(response.getContentAsString()); + + assertThat(node.get("_links").get("alerts").get("href").asText()).isEqualTo("https://mycustom.alerts.io"); + } + + @Test + void shouldReturnAlertsRequest() throws Exception { + String instanceId = UUID.randomUUID().toString(); + when(scmContextProvider.getInstanceId()).thenReturn(instanceId); + when(scmContextProvider.getVersion()).thenReturn("2.28.0"); + + InstalledPlugin pluginA = createInstalledPlugin("some-scm-plugin", "1.0.0"); + InstalledPlugin pluginB = createInstalledPlugin("other-scm-plugin", "2.1.1"); + when(pluginLoader.getInstalledPlugins()).thenReturn(Arrays.asList(pluginA, pluginB)); + + MockHttpResponse response = invoke(); + + assertThat(response.getStatus()).isEqualTo(200); + + AlertsRequest alertsRequest = unmarshal(response); + AlertsRequestBody body = alertsRequest.getBody(); + assertThat(body.getInstanceId()).isEqualTo(instanceId); + assertThat(body.getVersion()).isEqualTo("2.28.0"); + assertThat(body.getOs()).isEqualTo(SystemUtil.getOS()); + assertThat(body.getArch()).isEqualTo(SystemUtil.getArch()); + assertThat(body.getJre()).isEqualTo(SystemUtil.getJre()); + + List plugins = body.getPlugins(); + assertThat(plugins.size()).isEqualTo(2); + AlertsResource.Plugin somePlugin = findPlugin(plugins, "some-scm-plugin"); + assertThat(somePlugin.getVersion()).isEqualTo("1.0.0"); + AlertsResource.Plugin otherPlugin = findPlugin(plugins, "other-scm-plugin"); + assertThat(otherPlugin.getVersion()).isEqualTo("2.1.1"); + } + + @Test + void shouldReturnSameChecksumIfNothingChanged() throws Exception { + String instanceId = UUID.randomUUID().toString(); + when(scmContextProvider.getInstanceId()).thenReturn(instanceId); + when(scmContextProvider.getVersion()).thenReturn("2.28.0"); + + InstalledPlugin plugin = createInstalledPlugin("some-scm-plugin", "1.0.0"); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin)); + + MockHttpResponse response = invoke(); + String checksum = unmarshal(response).getChecksum(); + + MockHttpResponse secondResponse = invoke(); + + assertThat(unmarshal(secondResponse).getChecksum()).isEqualTo(checksum); + } + + @Test + void shouldReturnDifferentChecksumIfCoreVersionChanges() throws Exception { + when(scmContextProvider.getVersion()).thenReturn("2.28.0"); + + MockHttpResponse response = invoke(); + String checksum = unmarshal(response).getChecksum(); + + when(scmContextProvider.getVersion()).thenReturn("2.28.1"); + + MockHttpResponse secondResponse = invoke(); + + assertThat(unmarshal(secondResponse).getChecksum()).isNotEqualTo(checksum); + } + + @Test + void shouldReturnDifferentChecksumIfPluginVersionChanges() throws Exception { + InstalledPlugin plugin1_0_0 = createInstalledPlugin("some-scm-plugin", "1.0.0"); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin1_0_0)); + + MockHttpResponse response = invoke(); + String checksum = unmarshal(response).getChecksum(); + + InstalledPlugin plugin1_0_1 = createInstalledPlugin("some-scm-plugin", "1.0.1"); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin1_0_1)); + + MockHttpResponse secondResponse = invoke(); + + assertThat(unmarshal(secondResponse).getChecksum()).isNotEqualTo(checksum); + } + + @Test + void shouldReturnDifferentChecksumIfDateChanges() throws Exception { + MockHttpResponse response = invoke(); + String checksum = unmarshal(response).getChecksum(); + + InstalledPlugin plugin1_0_1 = createInstalledPlugin("some-scm-plugin", "1.0.1"); + when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin1_0_1)); + + MockHttpResponse secondResponse = invoke("1979-10-12"); + + assertThat(unmarshal(secondResponse).getChecksum()).isNotEqualTo(checksum); + } + + private InstalledPlugin createInstalledPlugin(String name, String version) { + PluginInformation pluginInformation = new PluginInformation(); + pluginInformation.setName(name); + pluginInformation.setVersion(version); + 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); + } + + private MockHttpResponse invoke() throws Exception { + return invoke(null); + } + + private MockHttpResponse invoke(String date) throws Exception { + AlertsResource alertsResource; + if (date != null) { + alertsResource = new AlertsResource(scmContextProvider, scmConfiguration, pluginLoader, () -> date); + } else { + alertsResource = new AlertsResource(scmContextProvider, scmConfiguration, pluginLoader); + } + + restDispatcher.addSingletonResource(alertsResource); + + MockHttpRequest request = MockHttpRequest.get("/v2/alerts"); + MockHttpResponse response = new MockHttpResponse(); + restDispatcher.invoke(request, response); + + return response; + } + + private AlertsRequest unmarshal(MockHttpResponse response) throws JsonProcessingException, UnsupportedEncodingException { + return mapper.readValue(response.getContentAsString(), AlertsRequest.class); + } + + private AlertsResource.Plugin findPlugin(List plugins, String name) { + return plugins.stream() + .filter(p -> name.equals(p.getName())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("plugin " + name + " not found in request")); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java index f8a8f093ff..9058de2c0c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java @@ -39,12 +39,8 @@ import sonia.scm.config.ScmConfiguration; import sonia.scm.security.AnonymousMode; import java.net.URI; -import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -106,6 +102,7 @@ class ScmConfigurationToConfigDtoMapperTest { assertThat(dto.isEnabledXsrfProtection()).isTrue(); assertThat(dto.getNamespaceStrategy()).isEqualTo("username"); assertThat(dto.getLoginInfoUrl()).isEqualTo("https://scm-manager.org/login-info"); + assertThat(dto.getAlertsUrl()).isEqualTo("https://alerts.scm-manager.org/api/v1/alerts"); assertThat(dto.getReleaseFeedUrl()).isEqualTo("https://www.scm-manager.org/download/rss.xml"); assertThat(dto.getMailDomainName()).isEqualTo("scm-manager.local"); assertThat(dto.getEmergencyContacts()).contains(expectedUsers); @@ -169,6 +166,7 @@ class ScmConfigurationToConfigDtoMapperTest { config.setEnabledXsrfProtection(true); config.setNamespaceStrategy("username"); config.setLoginInfoUrl("https://scm-manager.org/login-info"); + config.setAlertsUrl("https://alerts.scm-manager.org/api/v1/alerts"); config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml"); config.setEmergencyContacts(Sets.newSet(expectedUsers)); return config; diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java index 193cbe9acc..5981ca5896 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTreeTest.java @@ -21,166 +21,143 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.plugin; -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Function; import com.google.common.collect.Lists; - import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; - -import static com.google.common.collect.ImmutableSet.of; -import static org.hamcrest.Matchers.*; - -import static org.junit.Assert.*; - -//~--- JDK imports ------------------------------------------------------------ +import sonia.scm.Stage; import java.io.IOException; - +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import static com.google.common.collect.ImmutableSet.of; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + /** - * * @author Sebastian Sdorra */ -public class PluginTreeTest -{ +public class PluginTreeTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); - /** - * Method description - * - * - * @throws IOException - */ @Test(expected = PluginConditionFailedException.class) - public void testPluginConditionFailed() throws IOException - { - PluginCondition condition = new PluginCondition("999", - new ArrayList(), "hit"); - InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition, - false, null, null); + public void testPluginConditionFailed() throws IOException { + PluginCondition condition = new PluginCondition("999", new ArrayList<>(), "hit"); + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition, + false, null, null); ExplodedSmp smp = createSmp(plugin); - new PluginTree(smp).getLeafLastNodes(); + new PluginTree(Stage.PRODUCTION, smp).getLeafLastNodes(); } - /** - * Method description - * - * - * @throws IOException - */ - @Test(expected = PluginNotInstalledException.class) - public void testPluginNotInstalled() throws IOException - { - new PluginTree(createSmpWithDependency("b", "a")).getLeafLastNodes(); + @Test(expected = PluginConditionFailedException.class) + public void testPluginConditionFailedInDevelopmentStage() throws IOException { + PluginCondition condition = new PluginCondition("999", new ArrayList<>(), "hit"); + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition, + false, null, null); + ExplodedSmp smp = createSmp(plugin); + + new PluginTree(Stage.DEVELOPMENT, smp).getLeafLastNodes(); } - /** - * Method description - * - * - * @throws IOException - */ + @Test - public void testNodes() throws IOException - { + public void testSkipCorePluginValidationOnDevelopment() throws IOException { + PluginCondition condition = new PluginCondition("999", new ArrayList<>(), "hit"); + InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition, + false, null, null); + + // make it core + ExplodedSmp smp = createSmp(plugin); + Path path = smp.getPath(); + Files.createFile(path.resolve(PluginConstants.FILE_CORE)); + + List nodes = new PluginTree(Stage.DEVELOPMENT, smp).getLeafLastNodes(); + assertFalse(nodes.isEmpty()); + } + + @Test(expected = PluginNotInstalledException.class) + public void testPluginNotInstalled() throws IOException { + new PluginTree(Stage.PRODUCTION, createSmpWithDependency("b", "a")).getLeafLastNodes(); + } + + @Test + public void testNodes() throws IOException { List smps = createSmps("a", "b", "c"); - List nodes = unwrapIds(new PluginTree(smps).getLeafLastNodes()); + List nodes = unwrapIds(new PluginTree(Stage.PRODUCTION, smps).getLeafLastNodes()); assertThat(nodes, containsInAnyOrder("a", "b", "c")); } - /** - * Method description - * - * - * @throws IOException - */ @Test(expected = PluginException.class) - public void testScmVersion() throws IOException - { + public void testScmVersion() throws IOException { InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(1, createInfo("a", "1"), null, null, false, - null, null); + null, null); ExplodedSmp smp = createSmp(plugin); - new PluginTree(smp).getLeafLastNodes(); + new PluginTree(Stage.PRODUCTION, smp).getLeafLastNodes(); } - /** - * Method description - * - * - * @throws IOException - */ @Test - public void testSimpleDependencies() throws IOException - { - //J- - ExplodedSmp[] smps = new ExplodedSmp[] { + public void testSimpleDependencies() throws IOException { + ExplodedSmp[] smps = new ExplodedSmp[]{ createSmpWithDependency("a"), createSmpWithDependency("b", "a"), createSmpWithDependency("c", "a", "b") }; - //J+ - PluginTree tree = new PluginTree(smps); + PluginTree tree = new PluginTree(Stage.PRODUCTION, smps); List nodes = tree.getLeafLastNodes(); - System.out.println(tree); - assertThat(unwrapIds(nodes), contains("a", "b", "c")); } @Test - public void testComplexDependencies() throws IOException - { - //J- + public void testComplexDependencies() throws IOException { ExplodedSmp[] smps = new ExplodedSmp[]{ createSmpWithDependency("a", "b", "c", "d"), createSmpWithDependency("b", "c"), createSmpWithDependency("c"), createSmpWithDependency("d") }; - //J+ - PluginTree tree = new PluginTree(smps); + PluginTree tree = new PluginTree(Stage.PRODUCTION, smps); List nodes = tree.getLeafLastNodes(); - System.out.println(tree); - assertThat(unwrapIds(nodes), contains("d", "c", "b", "a")); } @Test public void testWithOptionalDependency() throws IOException { - ExplodedSmp[] smps = new ExplodedSmp[] { + ExplodedSmp[] smps = new ExplodedSmp[]{ createSmpWithDependency("a"), createSmpWithDependency("b", null, of("a")), createSmpWithDependency("c", null, of("a", "b")) }; - PluginTree tree = new PluginTree(smps); + PluginTree tree = new PluginTree(Stage.PRODUCTION, smps); List nodes = tree.getLeafLastNodes(); - System.out.println(tree); - assertThat(unwrapIds(nodes), contains("a", "b", "c")); } @Test public void testRealWorldDependencies() throws IOException { - //J- ExplodedSmp[] smps = new ExplodedSmp[]{ createSmpWithDependency("scm-editor-plugin"), createSmpWithDependency("scm-ci-plugin"), @@ -206,7 +183,7 @@ public class PluginTreeTest createSmpWithDependency("scm-script-plugin"), createSmpWithDependency("scm-activity-plugin"), createSmpWithDependency("scm-mail-plugin"), - createSmpWithDependency("scm-branchwp-plugin", of(), of("scm-editor-plugin", "scm-review-plugin", "scm-mail-plugin" )), + createSmpWithDependency("scm-branchwp-plugin", of(), of("scm-editor-plugin", "scm-review-plugin", "scm-mail-plugin")), createSmpWithDependency("scm-notify-plugin", "scm-mail-plugin"), createSmpWithDependency("scm-redmine-plugin", "scm-issuetracker-plugin"), createSmpWithDependency("scm-jira-plugin", "scm-mail-plugin", "scm-issuetracker-plugin"), @@ -214,17 +191,10 @@ public class PluginTreeTest createSmpWithDependency("scm-pathwp-plugin", of(), of("scm-editor-plugin")), createSmpWithDependency("scm-cockpit-legacy-plugin", "scm-statistic-plugin", "scm-rest-legacy-plugin", "scm-activity-plugin") }; - //J+ - Arrays.stream(smps) - .forEach(smp -> System.out.println(smp.getPlugin())); - - - PluginTree tree = new PluginTree(smps); + PluginTree tree = new PluginTree(Stage.PRODUCTION, smps); List nodes = tree.getLeafLastNodes(); - System.out.println(tree); - assertEachParentHasChild(nodes, "scm-review-plugin", "scm-branchwp-plugin"); } @@ -239,18 +209,15 @@ public class PluginTreeTest assertEachParentHasChild(pluginNode.getChildren(), parentName, childName); } - @Test public void testWithDeepOptionalDependency() throws IOException { - ExplodedSmp[] smps = new ExplodedSmp[] { + ExplodedSmp[] smps = new ExplodedSmp[]{ createSmpWithDependency("a"), createSmpWithDependency("b", "a"), createSmpWithDependency("c", null, of("b")) }; - PluginTree tree = new PluginTree(smps); - - System.out.println(tree); + PluginTree tree = new PluginTree(Stage.PRODUCTION, smps); List nodes = tree.getLeafLastNodes(); @@ -259,28 +226,18 @@ public class PluginTreeTest @Test public void testWithNonExistentOptionalDependency() throws IOException { - ExplodedSmp[] smps = new ExplodedSmp[] { + ExplodedSmp[] smps = new ExplodedSmp[]{ createSmpWithDependency("a", null, of("b")) }; - PluginTree tree = new PluginTree(smps); + PluginTree tree = new PluginTree(Stage.PRODUCTION, smps); List nodes = tree.getLeafLastNodes(); assertThat(unwrapIds(nodes), containsInAnyOrder("a")); } - /** - * Method description - * - * - * @param name - * @param version - * - * @return - */ private PluginInformation createInfo(String name, - String version) - { + String version) { PluginInformation info = new PluginInformation(); info.setName(name); @@ -289,58 +246,21 @@ public class PluginTreeTest return info; } - /** - * Method description - * - * - * @param plugin - * - * @return - * - * @throws IOException - */ - private ExplodedSmp createSmp(InstalledPluginDescriptor plugin) throws IOException - { - return new ExplodedSmp(tempFolder.newFile().toPath(), plugin); + private ExplodedSmp createSmp(InstalledPluginDescriptor plugin) throws IOException { + return new ExplodedSmp(tempFolder.newFolder().toPath(), plugin); } - /** - * Method description - * - * - * @param name - * - * @return - * - * @throws IOException - */ - private ExplodedSmp createSmp(String name) throws IOException - { + private ExplodedSmp createSmp(String name) throws IOException { return createSmp(new InstalledPluginDescriptor(2, createInfo(name, "1.0.0"), null, null, false, null, null)); } - /** - * Method description - * - * - * @param name - * @param dependencies - * - * @return - * - * @throws IOException - */ private ExplodedSmp createSmpWithDependency(String name, - String... dependencies) - throws IOException - { + String... dependencies) + throws IOException { Set dependencySet = new HashSet<>(); - for (String d : dependencies) - { - dependencySet.add(d); - } + Collections.addAll(dependencySet, dependencies); return createSmpWithDependency(name, dependencySet, null); } @@ -357,52 +277,18 @@ public class PluginTreeTest return createSmp(plugin); } - /** - * Method description - * - * - * @param names - * - * @return - * - * @throws IOException - */ - private List createSmps(String... names) throws IOException - { + private List createSmps(String... names) throws IOException { List smps = Lists.newArrayList(); - for (String name : names) - { + for (String name : names) { smps.add(createSmp(name)); } return smps; } - /** - * Method description - * - * - * @param nodes - * - * @return - */ - private List unwrapIds(List nodes) - { - return Lists.transform(nodes, new Function() - { - - @Override - public String apply(PluginNode input) - { - return input.getId(); - } - }); + private List unwrapIds(List nodes) { + return nodes.stream().map(PluginNode::getId).collect(Collectors.toList()); } - //~--- fields --------------------------------------------------------------- - - /** Field description */ - @Rule - public TemporaryFolder tempFolder = new TemporaryFolder(); }