mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-15 09:46:16 +01:00
Add security notifications to inform about vulnerabilities (#1924)
Add security notifications in SCM-Manager to inform running instances about known security issues. These alerts can be core or plugin specific and will be shown to every user in the header. Co-authored-by: Matthias Thieroff <matthias.thieroff@cloudogu.com> Co-authored-by: Philipp Ahrendt <philipp.ahrendt@cloudogu.com> Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
@@ -102,13 +102,22 @@ class RunTask extends DefaultTask {
|
||||
}
|
||||
|
||||
private Closure<Void> createBackend() {
|
||||
Map<String,String> scmProperties = System.getProperties().findAll { e -> {
|
||||
return e.key.startsWith("scm") || e.key.startsWith("sonia")
|
||||
}}
|
||||
|
||||
def runProperties = new HashMap<String, String>(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 {
|
||||
|
||||
BIN
docs/de/user/alerts/assets/alerts-list.png
Normal file
BIN
docs/de/user/alerts/assets/alerts-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/de/user/alerts/assets/alerts.png
Normal file
BIN
docs/de/user/alerts/assets/alerts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
15
docs/de/user/alerts/index.md
Normal file
15
docs/de/user/alerts/index.md
Normal file
@@ -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.
|
||||
|
||||

|
||||
|
||||
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 verschwinden, sobald die Ursache z.B. durch ein Versionsupgrade behoben ist.
|
||||
BIN
docs/en/user/alerts/assets/alerts-list.png
Normal file
BIN
docs/en/user/alerts/assets/alerts-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/en/user/alerts/assets/alerts.png
Normal file
BIN
docs/en/user/alerts/assets/alerts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
17
docs/en/user/alerts/index.md
Normal file
17
docs/en/user/alerts/index.md
Normal file
@@ -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.
|
||||
|
||||

|
||||
|
||||
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 are removed as soon as the issue is resolved in your instance, e.g. by upgrading to a fixed version.
|
||||
|
||||
|
||||
2
gradle/changelog/alerts.yaml
Normal file
2
gradle/changelog/alerts.yaml
Normal file
@@ -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))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
107
scm-ui/ui-api/src/alerts.ts
Normal file
107
scm-ui/ui-api/src/alerts.ts
Normal file
@@ -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<AlertsResponse> => {
|
||||
const storedAlerts = alertsFromStorage();
|
||||
if (!storedAlerts || storedAlerts.checksum !== request.checksum) {
|
||||
return fetchAlerts(request);
|
||||
}
|
||||
return Promise.resolve(storedAlerts);
|
||||
};
|
||||
|
||||
export const useAlerts = (): ApiResult<AlertsResponse> => {
|
||||
const link = useIndexLink("alerts");
|
||||
const { data, error, isLoading } = useQuery<AlertsResponse, Error>(
|
||||
"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
|
||||
};
|
||||
};
|
||||
@@ -57,6 +57,7 @@ describe("Test config hooks", () => {
|
||||
proxyServer: "",
|
||||
proxyUser: null,
|
||||
realmDescription: "",
|
||||
alertsUrl: "",
|
||||
releaseFeedUrl: "",
|
||||
skipFailedAuthenticators: false,
|
||||
_links: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
41
scm-ui/ui-types/src/Alerts.ts
Normal file
41
scm-ui/ui-types/src/Alerts.ts
Normal file
@@ -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[];
|
||||
};
|
||||
@@ -49,6 +49,7 @@ export type Config = HalRepresentation & {
|
||||
enabledUserConverter: boolean;
|
||||
namespaceStrategy: string;
|
||||
loginInfoUrl: string;
|
||||
alertsUrl: string;
|
||||
releaseFeedUrl: string;
|
||||
mailDomainName: string;
|
||||
emergencyContacts: string[];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -164,6 +164,9 @@
|
||||
"dismiss": "Löschen",
|
||||
"dismissAll": "Alle löschen"
|
||||
},
|
||||
"alerts": {
|
||||
"shieldTitle": "Alerts"
|
||||
},
|
||||
"cardColumnGroup": {
|
||||
"showContent": "Inhalt einblenden",
|
||||
"hideContent": "Inhalt ausblenden"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -165,6 +165,9 @@
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAll": "Dismiss all"
|
||||
},
|
||||
"alerts": {
|
||||
"shieldTitle": "Alerts"
|
||||
},
|
||||
"cardColumnGroup": {
|
||||
"showContent": "Show content",
|
||||
"hideContent": "Hide content"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<Props> = ({
|
||||
enabledUserConverter: false,
|
||||
namespaceStrategy: "",
|
||||
loginInfoUrl: "",
|
||||
alertsUrl: "",
|
||||
releaseFeedUrl: "",
|
||||
mailDomainName: "",
|
||||
emergencyContacts: [],
|
||||
@@ -144,6 +145,7 @@ const ConfigForm: FC<Props> = ({
|
||||
dateFormat={innerConfig.dateFormat}
|
||||
anonymousMode={innerConfig.anonymousMode}
|
||||
skipFailedAuthenticators={innerConfig.skipFailedAuthenticators}
|
||||
alertsUrl={innerConfig.alertsUrl}
|
||||
releaseFeedUrl={innerConfig.releaseFeedUrl}
|
||||
mailDomainName={innerConfig.mailDomainName}
|
||||
enabledXsrfProtection={innerConfig.enabledXsrfProtection}
|
||||
|
||||
@@ -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<Props> = ({
|
||||
realmDescription,
|
||||
loginInfoUrl,
|
||||
anonymousMode,
|
||||
alertsUrl,
|
||||
releaseFeedUrl,
|
||||
mailDomainName,
|
||||
enabledXsrfProtection,
|
||||
@@ -89,6 +91,9 @@ const GeneralSettings: FC<Props> = ({
|
||||
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<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<div className="column is-half">
|
||||
<InputField
|
||||
label={t("general-settings.alerts-url")}
|
||||
onChange={handleAlertsUrlChange}
|
||||
value={alertsUrl}
|
||||
disabled={!hasUpdatePermission}
|
||||
helpText={t("help.alertsUrlHelpText")}
|
||||
/>
|
||||
</div>
|
||||
<div className="column is-half">
|
||||
<InputField
|
||||
label={t("general-settings.release-feed-url")}
|
||||
onChange={handleReleaseFeedUrlChange}
|
||||
|
||||
206
scm-ui/ui-webapp/src/components/HeaderDropDown.tsx
Normal file
206
scm-ui/ui-webapp/src/components/HeaderDropDown.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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, useEffect, useState } from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import { devices, ErrorNotification, Loading } from "@scm-manager/ui-components";
|
||||
import classNames from "classnames";
|
||||
|
||||
type DropDownMenuProps = {
|
||||
mobilePosition: "left" | "right";
|
||||
};
|
||||
|
||||
const DropDownMenu = styled.div<DropDownMenuProps>`
|
||||
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 }) => (
|
||||
<div className={classNames("dropdown-content", "p-4")}>{children}</div>
|
||||
);
|
||||
|
||||
const ErrorBox: FC<{ error?: Error | null }> = ({ error }) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DropdownMenuContainer>
|
||||
<ErrorNotification error={error} />
|
||||
</DropdownMenuContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingBox: FC = () => (
|
||||
<div className="box">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
|
||||
const IconContainer = styled.div`
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
`;
|
||||
|
||||
type CounterProps = {
|
||||
count: string;
|
||||
};
|
||||
|
||||
const Counter = styled.span<CounterProps>`
|
||||
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<IconWrapperProps> = ({ icon, count }) => (
|
||||
<IconContainer className={classNames("is-relative", "is-flex", "is-justify-content-center", "is-align-items-center")}>
|
||||
{icon}
|
||||
{count ? <Counter count={count}>{count}</Counter> : null}
|
||||
</IconContainer>
|
||||
);
|
||||
|
||||
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<Props> = ({ 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 (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"notifications",
|
||||
"dropdown",
|
||||
"is-hoverable",
|
||||
"p-0",
|
||||
{
|
||||
"is-active": open
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DropDownTrigger
|
||||
className={classNames("is-flex", "dropdown-trigger", "is-clickable")}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
<IconWrapper icon={icon} count={count} />
|
||||
</DropDownTrigger>
|
||||
<DropDownMenu mobilePosition={mobilePosition} className="dropdown-menu pt-0" id="dropdown-menu" role="menu">
|
||||
<ErrorBox error={error} />
|
||||
{isLoading ? <LoadingBox /> : null}
|
||||
{children}
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderDropDown;
|
||||
142
scm-ui/ui-webapp/src/containers/Alerts.tsx
Normal file
142
scm-ui/ui-webapp/src/containers/Alerts.tsx
Normal file
@@ -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<EntryProps> = ({ alert }) => {
|
||||
const navigateTo = () => {
|
||||
if (alert.link) {
|
||||
window.open(alert.link)?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr onClick={navigateTo} className={classNames("is-danger", { "is-clickable": !!alert.link })}>
|
||||
<Column>
|
||||
<p className="has-text-weight-bold">{alert.title}</p>
|
||||
<p>{alert.description}</p>
|
||||
</Column>
|
||||
<RightColumn className="has-text-right">
|
||||
<div className="is-flex is-flex-direction-column is-justify-content-space-between">
|
||||
<p className="has-text-weight-semibold">
|
||||
{alert.component} {alert.affectedVersions}
|
||||
</p>
|
||||
<DateFromNow date={alert.issuedAt} className="is-size-7" />
|
||||
</div>
|
||||
</RightColumn>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: ComponentAlert[];
|
||||
};
|
||||
|
||||
const AlertsList: FC<Props> = ({ data }) => (
|
||||
<div className="dropdown-content p-0">
|
||||
<FullHeightTable className="table card-table mb-0 is-fullheight">
|
||||
<tbody>
|
||||
{data.map((a, i) => (
|
||||
<AlertsEntry key={i} alert={a} />
|
||||
))}
|
||||
</tbody>
|
||||
</FullHeightTable>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ShieldNotificationIcon: FC = () => {
|
||||
const [t] = useTranslation("commons");
|
||||
return <Icon className="is-size-4" name="shield-alt" color="white" alt={t("alerts.shieldTitle")} />;
|
||||
};
|
||||
|
||||
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<AlertsProps> = ({ className }) => {
|
||||
const { data, error } = useFlattenedAlerts();
|
||||
if ((!data || data.length === 0) && !error) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<HeaderDropDown
|
||||
icon={<ShieldNotificationIcon />}
|
||||
count={data ? data.length.toString() : "?"}
|
||||
error={error}
|
||||
className={className}
|
||||
mobilePosition="right"
|
||||
>
|
||||
{data ? <AlertsList data={data} /> : null}
|
||||
</HeaderDropDown>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alerts;
|
||||
@@ -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<Props> = ({ links }) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="is-active navbar-header-actions">
|
||||
<Alerts className="navbar-item" />
|
||||
<OmniSearch links={links} />
|
||||
<Notifications className="navbar-item" />
|
||||
</div>
|
||||
|
||||
@@ -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<EntryProps> = ({ notification, removeToast }) => {
|
||||
}
|
||||
return (
|
||||
<tr className={`is-${color(notification)}`}>
|
||||
<VerticalCenteredTd onClick={() => history.push(notification.link)} className="is-clickable">
|
||||
<Column onClick={() => history.push(notification.link)} className="is-clickable">
|
||||
<NotificationMessage message={notification.message} />
|
||||
</VerticalCenteredTd>
|
||||
<DateColumn className="has-text-right">
|
||||
</Column>
|
||||
<NonWrappingColumn className="has-text-right">
|
||||
<DateFromNow date={notification.createdAt} />
|
||||
</DateColumn>
|
||||
<DismissColumn className="is-darker">
|
||||
</NonWrappingColumn>
|
||||
<DismissColumn className="is-darker is-clickable" onClick={remove}>
|
||||
{isLoading ? (
|
||||
<div className="small-loading-spinner" aria-label={t("notifications.loading")} />
|
||||
) : (
|
||||
<Icon
|
||||
name="trash"
|
||||
color="secondary-most"
|
||||
className="is-clickable"
|
||||
title={t("notifications.dismiss")}
|
||||
onClick={remove}
|
||||
/>
|
||||
<Icon name="trash" color="secondary-most" title={t("notifications.dismiss")} />
|
||||
)}
|
||||
</DismissColumn>
|
||||
</tr>
|
||||
@@ -153,7 +103,7 @@ const ClearEntry: FC<ClearEntryProps> = ({ notifications, clearToasts }) => {
|
||||
return (
|
||||
<div className={classNames("dropdown-item", "has-text-centered")}>
|
||||
<ErrorNotification error={error} />
|
||||
<Button className="is-outlined" color="link" loading={isLoading} action={clear}>
|
||||
<Button className="is-outlined m-3" color="link" loading={isLoading} action={clear}>
|
||||
<Icon color="inherit" name="trash" className="mr-1" alt="" /> {t("notifications.dismissAll")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -164,18 +114,18 @@ const NotificationList: FC<Props> = ({ 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 (
|
||||
<div className={classNames("dropdown-content", "p-0")}>
|
||||
<table className={classNames("table", "card-table", "mb-0")}>
|
||||
<Table className={classNames("table", "card-table", "mb-0")}>
|
||||
<tbody>
|
||||
{top.map((n, i) => (
|
||||
<NotificationEntry key={i} notification={n} removeToast={remove} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Table>
|
||||
{all.length > 6 ? (
|
||||
<p className={classNames("has-text-centered", "has-text-secondary")}>
|
||||
{t("notifications.xMore", { count: all.length - 6 })}
|
||||
@@ -207,7 +157,7 @@ type Props = {
|
||||
|
||||
const NotificationDropDown: FC<Props> = ({ data, remove, clear }) => (
|
||||
<>
|
||||
{data._embedded.notifications.length > 0 ? (
|
||||
{(data._embedded?.notifications.length ?? 0) > 0 ? (
|
||||
<NotificationList data={data} remove={remove} clear={clear} />
|
||||
) : (
|
||||
<NoNotifications />
|
||||
@@ -260,61 +210,29 @@ const NotificationSubscription: FC<SubscriptionProps> = ({ notifications, remove
|
||||
);
|
||||
};
|
||||
|
||||
const BellNotificationContainer = styled.div`
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
`;
|
||||
|
||||
type NotificationCounterProps = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const NotificationCounter = styled.span<NotificationCounterProps>`
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
right: ${props => (props.count < 10 ? "0" : "-0.25")}rem;
|
||||
`;
|
||||
|
||||
type BellNotificationIconProps = {
|
||||
data?: NotificationCollection;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const BellNotificationIcon: FC<BellNotificationIconProps> = ({ data, onClick }) => {
|
||||
const BellNotificationIcon: FC<BellNotificationIconProps> = ({ data }) => {
|
||||
const [t] = useTranslation("commons");
|
||||
const counter = data?._embedded.notifications.length || 0;
|
||||
const counter = data?._embedded?.notifications.length || 0;
|
||||
return (
|
||||
<BellNotificationContainer
|
||||
className={classNames("is-relative", "is-flex", "is-justify-content-center", "is-align-items-center")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon
|
||||
className="is-size-4"
|
||||
iconStyle={counter === 0 ? "far" : "fas"}
|
||||
name="bell"
|
||||
color="white"
|
||||
alt={t("notifications.bellTitle")}
|
||||
/>
|
||||
{counter > 0 ? <NotificationCounter count={counter}>{counter < 100 ? counter : "∞"}</NotificationCounter> : null}
|
||||
</BellNotificationContainer>
|
||||
<Icon
|
||||
className="is-size-4"
|
||||
iconStyle={counter === 0 ? "far" : "fas"}
|
||||
name="bell"
|
||||
color="white"
|
||||
alt={t("notifications.bellTitle")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingBox: FC = () => (
|
||||
<div className="box">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<DropdownMenuContainer>
|
||||
<ErrorNotification error={error} />
|
||||
</DropdownMenuContainer>
|
||||
);
|
||||
};
|
||||
|
||||
type NotificationProps = {
|
||||
@@ -325,37 +243,19 @@ const Notifications: FC<NotificationProps> = ({ 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 (
|
||||
<>
|
||||
<NotificationSubscription notifications={notifications} remove={remove} />
|
||||
<div
|
||||
className={classNames(
|
||||
"notifications",
|
||||
"dropdown",
|
||||
"is-hoverable",
|
||||
{
|
||||
"is-active": open
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
<HeaderDropDown
|
||||
className={className}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
icon={<BellNotificationIcon data={data} />}
|
||||
count={count(data)}
|
||||
mobilePosition="left"
|
||||
>
|
||||
<div className={classNames("is-flex", "dropdown-trigger", "is-clickable")}>
|
||||
<BellNotificationIcon data={data} onClick={() => setOpen(o => !o)} />
|
||||
</div>
|
||||
<DropDownMenu className="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<ErrorBox error={error} />
|
||||
{isLoading ? <LoadingBox /> : null}
|
||||
{data ? <NotificationDropDown data={data} remove={remove} clear={clear} /> : null}
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
{data ? <NotificationDropDown data={data} remove={remove} clear={clear} /> : null}
|
||||
</HeaderDropDown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<HitProps> = ({ hit }) => {
|
||||
const namespace = useStringHitFieldValue(hit, "namespace");
|
||||
const name = useStringHitFieldValue(hit, "name");
|
||||
@@ -366,7 +379,7 @@ const OmniSearch: FC = () => {
|
||||
>
|
||||
<div className={classNames("dropdown", { "is-active": (!!data || error) && showResults })}>
|
||||
<div className="dropdown-trigger">
|
||||
<Input
|
||||
<SearchInput
|
||||
className="input is-small"
|
||||
type="text"
|
||||
placeholder={t("search.placeholder")}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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.cronutils.utils.VisibleForTesting;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.hash.Hasher;
|
||||
import com.google.common.hash.Hashing;
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import de.otto.edison.hal.Link;
|
||||
import de.otto.edison.hal.Links;
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.Value;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.plugin.PluginLoader;
|
||||
import sonia.scm.util.SystemUtil;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.UriInfo;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Path("v2/alerts")
|
||||
@OpenAPIDefinition(tags = {
|
||||
@Tag(name = "Alerts", description = "Alert related endpoints")
|
||||
})
|
||||
public class AlertsResource {
|
||||
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
private final SCMContextProvider scmContextProvider;
|
||||
private final ScmConfiguration scmConfiguration;
|
||||
private final PluginLoader pluginLoader;
|
||||
private final Supplier<String> 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<String> 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<Plugin> 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<Plugin> plugins;
|
||||
|
||||
}
|
||||
|
||||
@Value
|
||||
public static class Plugin implements Serializable {
|
||||
|
||||
String name;
|
||||
String version;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String> emergencyContacts;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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<ExplodedSmp> 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");
|
||||
}
|
||||
|
||||
@@ -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<PluginNode> 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<ExplodedSmp> smps)
|
||||
{
|
||||
|
||||
public PluginTree(Stage stage, Collection<ExplodedSmp> 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<PluginNode> getLeafLastNodes()
|
||||
{
|
||||
public List<PluginNode> getLeafLastNodes() {
|
||||
LinkedHashSet<PluginNode> leafFirst = new LinkedHashSet<>();
|
||||
|
||||
rootNodes.forEach(node -> appendLeafFirst(leafFirst, node));
|
||||
|
||||
LinkedList<PluginNode> 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<PluginNode> rootNodes;
|
||||
}
|
||||
|
||||
@@ -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<AlertsResource.Plugin> 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<AlertsResource.Plugin> plugins, String name) {
|
||||
return plugins.stream()
|
||||
.filter(p -> name.equals(p.getName()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("plugin " + name + " not found in request"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>(), "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<PluginNode> 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<ExplodedSmp> smps = createSmps("a", "b", "c");
|
||||
List<String> nodes = unwrapIds(new PluginTree(smps).getLeafLastNodes());
|
||||
List<String> 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<PluginNode> 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<PluginNode> 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<PluginNode> 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<PluginNode> 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<PluginNode> 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<PluginNode> 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<String> 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<ExplodedSmp> createSmps(String... names) throws IOException
|
||||
{
|
||||
private List<ExplodedSmp> createSmps(String... names) throws IOException {
|
||||
List<ExplodedSmp> smps = Lists.newArrayList();
|
||||
|
||||
for (String name : names)
|
||||
{
|
||||
for (String name : names) {
|
||||
smps.add(createSmp(name));
|
||||
}
|
||||
|
||||
return smps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param nodes
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private List<String> unwrapIds(List<PluginNode> nodes)
|
||||
{
|
||||
return Lists.transform(nodes, new Function<PluginNode, String>()
|
||||
{
|
||||
|
||||
@Override
|
||||
public String apply(PluginNode input)
|
||||
{
|
||||
return input.getId();
|
||||
}
|
||||
});
|
||||
private List<String> unwrapIds(List<PluginNode> nodes) {
|
||||
return nodes.stream().map(PluginNode::getId).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
//~--- fields ---------------------------------------------------------------
|
||||
|
||||
/** Field description */
|
||||
@Rule
|
||||
public TemporaryFolder tempFolder = new TemporaryFolder();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user