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:
Eduard Heimbuch
2022-01-19 11:58:55 +01:00
committed by GitHub
parent 07fa753f80
commit 63ec4e6172
42 changed files with 1379 additions and 420 deletions

View File

@@ -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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View 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.
![alerts in head](assets/alerts.png)
Hovern oder Klicken des Schildes öffnet die Liste der Alerts. Die einzelnen Alerts sind in Regel mit weiterführenden Informationen zur Sicherheitslücke oder Fixes verlinkt.
![alerts in head](assets/alerts-list.png)
Alerts verschwinden, sobald die Ursache z.B. durch ein Versionsupgrade behoben ist.

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View 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.
![alerts in head](assets/alerts.png)
Hovering or clicking the shield icon opens a list of issues. Issues are linked to a related resource. This resource usually describes the vulnerability and steps to address the issue.
![alerts in head](assets/alerts-list.png)
Alerts are removed as soon as the issue is resolved in your instance, e.g. by upgrading to a fixed version.

View 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))

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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
View 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
};
};

View File

@@ -57,6 +57,7 @@ describe("Test config hooks", () => {
proxyServer: "",
proxyUser: null,
realmDescription: "",
alertsUrl: "",
releaseFeedUrl: "",
skipFailedAuthenticators: false,
_links: {

View File

@@ -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";

View 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[];
};

View File

@@ -49,6 +49,7 @@ export type Config = HalRepresentation & {
enabledUserConverter: boolean;
namespaceStrategy: string;
loginInfoUrl: string;
alertsUrl: string;
releaseFeedUrl: string;
mailDomainName: string;
emergencyContacts: string[];

View File

@@ -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";

View File

@@ -164,6 +164,9 @@
"dismiss": "Löschen",
"dismissAll": "Alle löschen"
},
"alerts": {
"shieldTitle": "Alerts"
},
"cardColumnGroup": {
"showContent": "Inhalt einblenden",
"hideContent": "Inhalt ausblenden"

View File

@@ -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.",

View File

@@ -165,6 +165,9 @@
"dismiss": "Dismiss",
"dismissAll": "Dismiss all"
},
"alerts": {
"shieldTitle": "Alerts"
},
"cardColumnGroup": {
"showContent": "Show content",
"hideContent": "Hide content"

View File

@@ -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.",

View File

@@ -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}

View File

@@ -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}

View 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;

View 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;

View File

@@ -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>

View File

@@ -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>
</>
);
};

View File

@@ -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")}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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()));
}

View File

@@ -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();
}
}
}

View File

@@ -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();

View File

@@ -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.
*

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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"));
}
}

View File

@@ -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;

View File

@@ -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();
}