Feature/global notifications (#1646)

Add global notifications
This commit is contained in:
Sebastian Sdorra
2021-05-05 14:43:16 +02:00
committed by GitHub
parent de28cac4ab
commit b975fb655d
81 changed files with 3450 additions and 30 deletions

View File

@@ -55,6 +55,12 @@ class JavaModulePlugin implements Plugin<Project> {
failOnError false
}
project.sonarqube {
properties {
property "sonar.java.source", "8"
}
}
project.afterEvaluate {
if (project.isCI) {
project.plugins.apply("jacoco")

View File

@@ -5,3 +5,4 @@
- /user/group/
- /user/admin/
- /user/profile/
- /user/notification/

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,36 @@
---
title: Benachrichtigungen
partiallyActive: true
---
Benachrichtigungen werden in SCM-Manager verwendet, um die Fertigstellung von langlaufenden Prozessen anzuzeigen
oder um den Benutzer auf Fehler hinzuweisen.
Aktuelle Benachrichtigungen tauchen in Form einer Toast-Benachrichtigung am unteren rechten Rand auf.
![Toast-Benachrichtigung](assets/toast.png)
Die Farbe der Benachrichtigung gibt den Typ der Nachricht an:
* Fehler (Rot): Ein Fehler ist aufgetreten
* Warnung (Gelb): Es ist ein Problem aufgetreten
* Erfolgreich (Grün): Eine Aktion wurde erfolgreich beendet
* Information (Blau): Zur Information
Die Nachrichten verweisen auf eine Unterseite des SCM-Managers, die sich mit einem Klick auf den Text aufrufen lässt.
Nach dem Lesen der Nachricht, kann sie mit einem Klick auf das X in der oberen rechten Ecke geschlossen werden.
Nach dem Schließen der Nachricht ist sie immer noch über die Glocke am oberen rechten Rand zu finden.
![Glockensymbol](assets/bell.png)
Die Zahl neben der Glock gibt an wie viele Nachrichten eingegangen sind.
Mit einem Klick öffnet sich die Liste der Nachrichten. Neben den Informationen wie Typ der Nachricht (farbiger Rand) und Eingangsdatum, kann über einen Klick auf den Text zu einer SCM-Manager Unterseite navigiert werden.
![Benachrichtigungen](assets/notifications.png)
Über das Mülleimer-Symbol lässt sich eine einzelne Nachricht löschen.
Alle Nachrichten lassen sich über den "Alle löschen" Button löschen.
Wenn keine Nachrichten mehr vorhanden sind, zeigt das Glocken-Symbol keinen Zähler mehr an.
![Glockensymbol ohne Zähler](assets/emptybell.png)

View File

@@ -13,6 +13,7 @@
- /user/group/
- /user/admin/
- /user/profile/
- /user/notification/
- section: Administration
entries:

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,38 @@
---
title: Notifications
partiallyActive: true
---
Notifications are used in SCM Manager to indicate the completion of long-running processes
or to alert the user about errors and warnings.
Current notifications appear in the form of a toast notification at the bottom right.
![toast-notification](assets/toast.png)
The color of the notification indicates the type of message:
* Error (red): An error has occurred.
* Warning (yellow): A problem has occurred
* Successful (green): An action has been successfully completed
* Information (blue): For informational notifications
The messages refer to a page of SCM Manager, which can be accessed by clicking on the text of the notification.
When you have read the message, you can close it by clicking on the X in the upper right corner.
The messages can also be reviewed by clicking on the bell icon in the upper right corner.
![bell icon](assets/bell.png)
The number next to the bell icon indicates how many messages have been received.
If you move the mouse over the icon, you can read the messages.
The colored border indicates the type of the message, the date indicates when the message was received
and by clicking on the text you can open the page of the message.
![Notifications](assets/notifications.png)
The trash icon can be used to delete a single message.
All messages can be deleted by clicking the "Dismiss all" button.
If there are no messages left, the bell icon will not show a counter anymore.
![Bell icon without counter](assets/emptybell.png)

View File

@@ -0,0 +1,2 @@
- type: added
description: Add global notifications ([#1646](https://github.com/scm-manager/scm-manager/pull/1646))

View File

@@ -100,6 +100,7 @@ ext {
ssp: "com.github.sdorra:ssp-lib:${sspVersion}",
sspProcessor: "com.github.sdorra:ssp-processor:${sspVersion}",
shiroUnit: 'com.github.sdorra:shiro-unit:1.0.1',
shiroExtension: 'com.github.sdorra:junit-shiro-extension:1.0.1',

View File

@@ -109,6 +109,9 @@ dependencies {
testImplementation libraries.junitJupiterParams
testRuntimeOnly libraries.junitJupiterEngine
// shiro
testImplementation libraries.shiroExtension
// junit 4 support
testRuntimeOnly libraries.junitVintageEngine
testImplementation libraries.junit

View File

@@ -0,0 +1,55 @@
/*
* 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.notifications;
import lombok.Value;
import java.time.Instant;
/**
* Notifications can be used to send a message to specific user.
*
* @since 2.18.0
*/
@Value
public class Notification {
Type type;
String link;
String message;
Instant createdAt;
public Notification(Type type, String link, String message) {
this(type, link, message, Instant.now());
}
Notification(Type type, String link, String message, Instant createdAt) {
this.type = type;
this.link = link;
this.message = message;
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.notifications;
import org.apache.shiro.SecurityUtils;
/**
* Service for sending notifications.
*
* @since 2.18.0
*/
public interface NotificationSender {
/**
* Sends the notification to a specific user.
* @param notification notification to send
* @param recipient username of the receiving user
*/
void send(Notification notification, String recipient);
/**
* Sends the notification to the current user.
* @param notification notification to send
*/
default void send(Notification notification) {
send(notification, SecurityUtils.getSubject().getPrincipal().toString());
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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.notifications;
/**
* Type of notification.
* @since 2.18.0
*/
public enum Type {
/**
* Notifications with an informative character e.g.: update available
*/
INFO,
/**
* Success should be used if a long running action is finished successfully e.g.: export is ready to download
*/
SUCCESS,
/**
* Notifications with a warning character e.g.: disk space is filled up to 80 percent.
*/
WARNING,
/**
* Error should be used in the case of an failure e.g.: export failed
*/
ERROR
}

View File

@@ -21,10 +21,9 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.security;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import lombok.EqualsAndHashCode;
@@ -40,7 +39,6 @@ import java.util.Optional;
@EqualsAndHashCode
public final class SessionId implements Serializable {
@VisibleForTesting
public static final String PARAMETER = "X-SCM-Session-ID";
private final String value;

View File

@@ -0,0 +1,120 @@
/*
* 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.sse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.security.SessionId;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Function;
import java.util.function.Predicate;
public class Channel {
private static final Logger LOG = LoggerFactory.getLogger(Channel.class);
private final List<Client> clients = new ArrayList<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Object channelId;
private final Function<Registration, Client> clientFactory;
Channel(Object channelId) {
this(channelId, Client::new);
}
Channel(Object channelId, Function<Registration, Client> clientFactory) {
this.channelId = channelId;
this.clientFactory = clientFactory;
}
public void register(Registration registration) {
registration.validate();
Client client = clientFactory.apply(registration);
LOG.trace("registered new client {} to channel {}", client.getSessionId(), channelId);
lock.writeLock().lock();
try {
clients.add(client);
} finally {
lock.writeLock().unlock();
}
}
public void broadcast(Message message) {
LOG.trace("broadcast message {} to clients of channel {}", message, channelId);
lock.readLock().lock();
try {
clients.stream()
.filter(isNotSender(message))
.forEach(client -> client.send(message));
} finally {
lock.readLock().unlock();
}
}
private Predicate<? super Client> isNotSender(Message message) {
return client -> {
Optional<SessionId> senderSessionId = message.getSender();
return senderSessionId
.map(sessionId -> !sessionId.equals(client.getSessionId()))
.orElse(true);
};
}
void removeClosedOrTimeoutClients() {
Instant timeoutLimit = Instant.now().minus(30, ChronoUnit.SECONDS);
lock.writeLock().lock();
try {
int removeCounter = 0;
Iterator<Client> it = clients.iterator();
while (it.hasNext()) {
Client client = it.next();
if (client.isClosed()) {
LOG.trace("remove closed client with session {}", client.getSessionId());
it.remove();
removeCounter++;
} else if (client.getLastUsed().isBefore(timeoutLimit)) {
client.close();
LOG.trace("remove client with session {}, because it has reached the timeout", client.getSessionId());
it.remove();
removeCounter++;
}
}
LOG.trace("removed {} closed clients from channel", removeCounter);
} finally {
lock.writeLock().unlock();
}
}
}

View File

@@ -0,0 +1,43 @@
/*
* 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.sse;
import javax.inject.Inject;
public class ChannelCleanupTask implements Runnable {
private final ChannelRegistry registry;
@Inject
public ChannelCleanupTask(ChannelRegistry registry) {
this.registry = registry;
}
@Override
public void run() {
registry.removeClosedClients();
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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.sse;
import com.google.common.annotations.VisibleForTesting;
import javax.inject.Singleton;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
@Singleton
public class ChannelRegistry {
private final Map<Object, Channel> channels = new ConcurrentHashMap<>();
private final Function<Object, Channel> channelFactory;
public ChannelRegistry() {
this(Channel::new);
}
@VisibleForTesting
ChannelRegistry(Function<Object, Channel> channelFactory) {
this.channelFactory = channelFactory;
}
public Channel channel(Object channelId) {
return channels.computeIfAbsent(channelId, channelFactory);
}
void removeClosedClients() {
channels.values().forEach(Channel::removeClosedOrTimeoutClients);
}
}

View File

@@ -0,0 +1,100 @@
/*
* 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.sse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.security.SessionId;
import javax.ws.rs.sse.OutboundSseEvent;
import javax.ws.rs.sse.SseEventSink;
import java.io.Closeable;
import java.time.Instant;
import java.util.function.Function;
class Client implements Closeable {
private static final Logger LOG = LoggerFactory.getLogger(Client.class);
private final SessionId sessionId;
private final SseEventAdapter adapter;
private final SseEventSink eventSink;
private Instant lastUsed;
private boolean exceptionallyClosed = false;
Client(Registration registration) {
this(registration, reg -> new SseEventAdapter(reg.getSse()));
}
Client(Registration registration, Function<Registration, SseEventAdapter> adapterFactory) {
sessionId = registration.getSessionId();
adapter = adapterFactory.apply(registration);
eventSink = registration.getEventSink();
lastUsed = Instant.now();
}
Instant getLastUsed() {
return lastUsed;
}
SessionId getSessionId() {
return sessionId;
}
boolean isExceptionallyClosed() {
return exceptionallyClosed;
}
void send(Message message) {
if (!isClosed()) {
OutboundSseEvent event = adapter.create(message);
LOG.debug("send message to client with session id {}", sessionId);
lastUsed = Instant.now();
eventSink.send(event).exceptionally(e -> {
if (LOG.isTraceEnabled()) {
LOG.trace("failed to send event to client with session id {}:", sessionId, e);
} else {
LOG.debug("failed to send event to client with session id {}: {}", sessionId, e.getMessage());
}
exceptionallyClosed = true;
close();
return null;
});
} else {
LOG.debug("client has closed the connection, before we could send the message");
}
}
boolean isClosed() {
return exceptionallyClosed || eventSink.isClosed();
}
@Override
public void close() {
LOG.trace("close sse session of client with session id: {}", sessionId);
eventSink.close();
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.sse;
import lombok.Getter;
import sonia.scm.security.SessionId;
import java.util.Optional;
@Getter
public class Message {
private final String name;
private final Class<?> type;
private final Object data;
private final SessionId sender;
public Message(String name, Class<?> type, Object data) {
this(name, type, data, null);
}
public Message(String name, Class<?> type, Object data, SessionId sender) {
this.name = name;
this.type = type;
this.data = data;
this.sender = sender;
}
public Optional<SessionId> getSender() {
return Optional.ofNullable(sender);
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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.sse;
import com.google.common.base.Preconditions;
import lombok.Value;
import sonia.scm.security.SessionId;
import javax.ws.rs.sse.Sse;
import javax.ws.rs.sse.SseEventSink;
@Value
public class Registration {
SessionId sessionId;
Sse sse;
SseEventSink eventSink;
void validate() {
Preconditions.checkNotNull(sessionId, "sessionId is required");
Preconditions.checkNotNull(sse, "sse is required");
Preconditions.checkNotNull(eventSink, "eventSink is required");
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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.sse;
import sonia.scm.plugin.Extension;
import sonia.scm.schedule.Scheduler;
import javax.inject.Inject;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
@Extension
public class SseContextListener implements ServletContextListener {
private final Scheduler scheduler;
@Inject
public SseContextListener(Scheduler scheduler) {
this.scheduler = scheduler;
}
@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {
this.scheduler.schedule("0/30 * * * * ?", ChannelCleanupTask.class);
}
@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
// we have nothing to destroy
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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.sse;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.sse.OutboundSseEvent;
import javax.ws.rs.sse.Sse;
class SseEventAdapter {
private final Sse sse;
SseEventAdapter(Sse sse) {
this.sse = sse;
}
OutboundSseEvent create(Message message) {
return sse.newEventBuilder()
.name(message.getName())
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(message.getType(), message.getData())
.build();
}
}

View File

@@ -93,6 +93,8 @@ public class VndMediaType {
public static final String REPOSITORY_EXPORT = PREFIX + "repositoryExport" + SUFFIX;
public static final String REPOSITORY_EXPORT_INFO = PREFIX + "repositoryExportInfo" + SUFFIX;
public static final String NOTIFICATION_COLLECTION = PREFIX + "notificationCollection" + SUFFIX;
private VndMediaType() {
}

View File

@@ -0,0 +1,61 @@
/*
* 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.notifications;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(ShiroExtension.class)
class NotificationSenderTest {
@Test
@SubjectAware("trillian")
void shouldUsePrincipal() {
CapturingNotificationSender sender = new CapturingNotificationSender();
Notification notification = new Notification(Type.INFO, "/tricia", "Hello trillian");
sender.send(notification);
assertThat(sender.notification).isSameAs(notification);
assertThat(sender.recipient).isEqualTo("trillian");
}
private static class CapturingNotificationSender implements NotificationSender {
private Notification notification;
private String recipient;
@Override
public void send(Notification notification, String recipient) {
this.notification = notification;
this.recipient = recipient;
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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.sse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class ChannelCleanupTaskTest {
@Mock
private ChannelRegistry registry;
@InjectMocks
private ChannelCleanupTask task;
@Test
void shouldRunCleanupFromRegistry() {
task.run();
verify(registry).removeClosedClients();
}
}

View File

@@ -0,0 +1,97 @@
/*
* 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.sse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ChannelRegistryTest {
private ChannelRegistry registry;
@Nested
class ChannelTests {
@BeforeEach
void setUp() {
registry = new ChannelRegistry();
}
@Test
void shouldCreateNewChannel() {
Channel one = registry.channel("one");
assertThat(one).isNotNull();
}
@Test
void shouldReturnSameChannelForSameId() {
Channel two = registry.channel("two");
assertThat(two).isSameAs(registry.channel("two"));
}
}
@Nested
@ExtendWith(MockitoExtension.class)
class RemoveClosedClientsTests {
private List<Channel> channels;
@BeforeEach
void setUp() {
channels = new ArrayList<>();
registry = new ChannelRegistry(objectId -> {
Channel channel = mock(Channel.class);
channels.add(channel);
return channel;
});
}
@Test
void shouldCallRemoveClosedOrTimeoutClientsOnEachChannel() {
registry.channel("one");
registry.channel("two");
registry.channel("three");
registry.removeClosedClients();
assertThat(channels)
.hasSize(3)
.allSatisfy(channel -> verify(channel).removeClosedOrTimeoutClients());
}
}
}

View File

@@ -0,0 +1,130 @@
/*
* 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.sse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.SessionId;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ChannelTest {
private Map<Registration,Client> clients;
private Channel channel;
@BeforeEach
void setUp() {
this.clients = new HashMap<>();
channel = new Channel("one", registration -> clients.get(registration));
}
@Test
void shouldRegisterAndSend() {
Client client = register();
Message message = broadcast("Hello World");
verify(client).send(message);
}
@Test
void shouldNotSendToSender() {
Client clientOne = register();
SessionId sessionOne = SessionId.valueOf("one");
when(clientOne.getSessionId()).thenReturn(sessionOne);
Client clientTwo = register();
when(clientTwo.getSessionId()).thenReturn(SessionId.valueOf("two"));
Message message = broadcast("Hello Two", sessionOne);
verify(clientOne, never()).send(message);
verify(clientTwo).send(message);
}
@Test
void shouldRemoveClosedClients() {
Client closedClient = register();
when(closedClient.isClosed()).thenReturn(true);
Client activeClient = register();
when(activeClient.getLastUsed()).thenReturn(Instant.now());
channel.removeClosedOrTimeoutClients();
Message message = broadcast("Hello active ones");
verify(closedClient, never()).send(message);
verify(activeClient).send(message);
}
@Test
void shouldRemoveClientsWhichAreNotUsedWithin30Seconds() {
Client timedOutClient = register();
when(timedOutClient.getLastUsed()).thenReturn(Instant.now().minus(31L, ChronoUnit.SECONDS));
Client activeClient = register();
when(activeClient.getLastUsed()).thenReturn(Instant.now().minus(29L, ChronoUnit.SECONDS));
channel.removeClosedOrTimeoutClients();
Message message = broadcast("Hello active ones");
verify(timedOutClient, never()).send(message);
verify(timedOutClient).close();
verify(activeClient).send(message);
verify(activeClient, never()).close();
}
private Client register() {
Registration registration = mock(Registration.class);
Client client = mock(Client.class);
clients.put(registration, client);
channel.register(registration);
return client;
}
private Message broadcast(String data) {
return broadcast(data, null);
}
private Message broadcast(String data, SessionId sessionId) {
Message message = new Message("hello", String.class, data, sessionId);
channel.broadcast(message);
return message;
}
}

View File

@@ -0,0 +1,138 @@
/*
* 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.sse;
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.security.SessionId;
import javax.ws.rs.sse.OutboundSseEvent;
import javax.ws.rs.sse.Sse;
import javax.ws.rs.sse.SseEventSink;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ClientTest {
@Mock
private SseEventSink eventSink;
@Mock
private SseEventAdapter adapter;
@Mock
private Sse sse;
@Mock
private OutboundSseEvent sseEvent;
@Mock
@SuppressWarnings("rawtypes")
private CompletionStage completionStage;
@Test
void shouldSetInitialLastUsed() {
Client client = client("one");
assertThat(client.getLastUsed()).isNotNull();
}
@Test
void shouldReturnSessionId() {
Client client = client("two");
assertThat(client.getSessionId()).isEqualTo(SessionId.valueOf("two"));
}
@Test
void shouldNotSendToClosedEventSink() {
Client client = client("three");
when(eventSink.isClosed()).thenReturn(true);
client.send(message(42));
verify(eventSink, never()).send(any(OutboundSseEvent.class));
}
@Test
void shouldCloseEventSink() {
Client client = client("one");
client.close();
verify(eventSink).close();
}
@Test
@SuppressWarnings("unchecked")
void shouldSendMessage() {
Message message = message(21);
when(adapter.create(message)).thenReturn(sseEvent);
when(eventSink.send(sseEvent)).thenReturn(completionStage);
Client client = client("one");
client.send(message);
verify(eventSink).send(sseEvent);
}
@Test
@SuppressWarnings("unchecked")
void shouldMarkAsClosedOnException() {
Message message = message(21);
when(adapter.create(message)).thenReturn(sseEvent);
when(completionStage.exceptionally(any())).then(ic -> {
Function<Exception,?> function = ic.getArgument(0);
function.apply(new IllegalStateException("failed"));
return null;
});
when(eventSink.send(sseEvent)).thenReturn(completionStage);
Client client = client("one");
client.send(message);
verify(eventSink).close();
assertThat(client.isExceptionallyClosed()).isTrue();
}
private Message message(int i) {
return new Message("count", Integer.class, i);
}
private Client client(String sessionId) {
Registration registration = new Registration(SessionId.valueOf(sessionId), sse, eventSink);
return new Client(registration, reg -> adapter);
}
}

View File

@@ -35,8 +35,6 @@ dependencies {
api libraries.junitJupiterApi
api libraries.junitJupiterParams
api libraries.junitJupiterEngine
api libraries.shiroUnit
api libraries.shiroExtension
// junit 4 support
api libraries.junitVintageEngine
@@ -51,6 +49,10 @@ dependencies {
api libraries.mockitoCore
api libraries.mockitoJunitJupiter
// shiro
api libraries.shiroExtension
api libraries.shiroUnit
// test rest api's
api libraries.resteasyCore
api libraries.resteasyValidatorProvider

View File

@@ -0,0 +1,88 @@
/*
* 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.store;
import sonia.scm.security.KeyGenerator;
import sonia.scm.security.UUIDKeyGenerator;
import javax.xml.bind.JAXB;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class InMemoryByteDataStore<T> implements DataStore<T> {
private final Class<T> type;
private final KeyGenerator generator = new UUIDKeyGenerator();
private final Map<String, byte[]> store = new HashMap<>();
InMemoryByteDataStore(Class<T> type) {
this.type = type;
}
@Override
public String put(T item) {
String id = generator.createKey();
put(id, item);
return id;
}
@Override
public void put(String id, T item) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
JAXB.marshal(item, baos);
store.put(id, baos.toByteArray());
}
@Override
public Map<String, T> getAll() {
Map<String, T> all = new HashMap<>();
for (String id : store.keySet()) {
all.put(id, get(id));
}
return Collections.unmodifiableMap(all);
}
@Override
public void clear() {
store.clear();
}
@Override
public void remove(String id) {
store.remove(id);
}
@Override
public T get(String id) {
byte[] bytes = store.get(id);
if (bytes != null) {
return JAXB.unmarshal(new ByteArrayInputStream(bytes), type);
}
return null;
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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.store;
import java.util.HashMap;
import java.util.Map;
/**
* Stores data in memory but in contrast to {@link InMemoryDataStoreFactory}
* it uses jaxb to marshal and unmarshall the objects.
*
* @since 2.18.0
*/
public class InMemoryByteDataStoreFactory implements DataStoreFactory {
@SuppressWarnings("rawtypes")
private final Map<String, InMemoryByteDataStore> stores = new HashMap<>();
@Override
@SuppressWarnings("unchecked")
public <T> DataStore<T> getStore(TypedStoreParameters<T> storeParameters) {
String name = storeParameters.getName();
return stores.computeIfAbsent(name, n -> new InMemoryByteDataStore<T>(storeParameters.getType()));
}
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.store;
import sonia.scm.security.KeyGenerator;
@@ -37,7 +37,9 @@ import java.util.Map;
* @author Sebastian Sdorra
*
* @param <T> type of stored object
* @deprecated use {@link InMemoryByteDataStore} instead.
*/
@Deprecated
public class InMemoryDataStore<T> implements DataStore<T> {
private final Map<String, T> store = new HashMap<>();

View File

@@ -21,14 +21,17 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.store;
/**
* In memory configuration store factory for testing purposes.
*
* @author Sebastian Sdorra
* @deprecated use {@link InMemoryByteDataStoreFactory} instead.
*/
@Deprecated
@SuppressWarnings("java:S3740")
public class InMemoryDataStoreFactory implements DataStoreFactory {
private InMemoryDataStore store;

View File

@@ -45,6 +45,7 @@ export * from "./permissions";
export * from "./sources";
export * from "./import";
export * from "./diff";
export * from "./notifications";
export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider";

View File

@@ -0,0 +1,183 @@
/*
* 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 { useMe } from "./login";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link, Notification, NotificationCollection } from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { useCallback, useEffect, useState } from "react";
import { requiredLink } from "./links";
export const useNotifications = () => {
const { data: me } = useMe();
const link = (me?._links["notifications"] as Link)?.href;
const { data, error, isLoading, refetch } = useQuery<NotificationCollection, Error>(
"notifications",
() => apiClient.get(link).then(response => response.json()),
{
enabled: !!link
}
);
const memoizedRefetch = useCallback(() => {
return refetch().then(r => r.data);
}, [refetch]);
return {
data,
error,
isLoading,
refetch: memoizedRefetch
};
};
export const useDismissNotification = (notification: Notification) => {
const queryClient = useQueryClient();
const link = requiredLink(notification, "dismiss");
const { data, isLoading, error, mutate } = useMutation<Response, Error>(() => apiClient.delete(link), {
onSuccess: () => {
queryClient.invalidateQueries("notifications");
}
});
return {
isLoading,
error,
dismiss: () => mutate(),
isCleared: !!data
};
};
export const useClearNotifications = (notificationCollection: NotificationCollection) => {
const queryClient = useQueryClient();
const link = requiredLink(notificationCollection, "clear");
const { data, isLoading, error, mutate } = useMutation<Response, Error>(() => apiClient.delete(link), {
onSuccess: () => {
queryClient.invalidateQueries("notifications");
}
});
return {
isLoading,
error,
clear: () => mutate(),
isCleared: !!data
};
};
const isEqual = (left: Notification, right: Notification) => {
return left === right || (left.message === right.message && left.createdAt === right.createdAt);
};
export const useNotificationSubscription = (
refetch: () => Promise<NotificationCollection | undefined>,
notificationCollection?: NotificationCollection
) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [disconnectedAt, setDisconnectedAt] = useState<Date>();
const link = (notificationCollection?._links.subscribe as Link)?.href;
const onVisible = useCallback(() => {
// we don't need to catch the error,
// because if the refetch throws an error the parent useNotifications should catch it
refetch().then(collection => {
if (collection) {
const newNotifications = collection._embedded.notifications.filter(n => {
return disconnectedAt && disconnectedAt < new Date(n.createdAt);
});
if (newNotifications.length > 0) {
setNotifications(previous => [...previous, ...newNotifications]);
}
setDisconnectedAt(undefined);
}
});
}, [disconnectedAt, refetch]);
const onHide = useCallback(() => {
setDisconnectedAt(new Date());
}, []);
const received = useCallback(
(notification: Notification) => {
setNotifications(previous => [...previous, notification]);
refetch();
},
[refetch]
);
useEffect(() => {
if (link) {
let cancel: () => void;
const disconnect = () => {
if (cancel) {
cancel();
}
};
const connect = () => {
disconnect();
cancel = apiClient.subscribe(link, {
notification: event => {
received(JSON.parse(event.data));
}
});
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
onVisible();
} else {
onHide();
}
};
if (document.visibilityState === "visible") {
connect();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
disconnect();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}
}, [link, onVisible, onHide, received]);
const remove = useCallback(
(notification: Notification) => {
setNotifications(oldNotifications => [...oldNotifications.filter(n => !isEqual(n, notification))]);
},
[setNotifications]
);
const clear = useCallback(() => {
setNotifications([]);
}, [setNotifications]);
return {
notifications,
remove,
clear
};
};

View File

@@ -77782,6 +77782,8 @@ exports[`Storyshots Toast Danger 1`] = `null`;
exports[`Storyshots Toast Info 1`] = `null`;
exports[`Storyshots Toast Multiple 1`] = `null`;
exports[`Storyshots Toast Open/Close 1`] = `
<div
style={

View File

@@ -0,0 +1,49 @@
/*
* 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.
*/
// https://bulma.io/documentation/overview/responsiveness/
export type Device = {
width: number;
};
export const devices = {
mobile: {
width: 768
},
tablet: {
width: 769
},
desktop: {
width: 1024
},
widescreen: {
width: 1216
},
fullhd: {
width: 1408
}
};
export type DeviceType = keyof typeof devices;

View File

@@ -79,11 +79,11 @@ export { default as CommaSeparatedList } from "./CommaSeparatedList";
export { default as SplitAndReplace, Replacement } from "./SplitAndReplace";
export { regExpPattern as changesetShortLinkRegex } from "./markdown/remarkChangesetShortLinkParser";
export * from "./markdown/PluginApi";
export * from "./devices";
export { default as comparators } from "./comparators";
export { isDevBuild, createAttributesForTesting } from "./devBuild";
export * from "./avatar";
export * from "./buttons";
export * from "./config";

View File

@@ -129,7 +129,7 @@ class PrimaryNavigation extends React.Component<Props> {
const navigationItems = this.createNavigationItems();
return (
<nav className="tabs is-boxed">
<nav className="tabs is-boxed mb-0">
<ul>{navigationItems}</ul>
</nav>
);

View File

@@ -0,0 +1,58 @@
/*
* 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 usePortalRootElement from "../usePortalRootElement";
import { createPortal } from "react-dom";
import styled from "styled-components";
const Container = styled.div`
z-index: 99999;
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
animation: 0.5s slide-up;
@keyframes slide-up {
from {
bottom: -20rem;
}
to {
bottom: 1.5rem;
}
}
`;
const ToastArea: FC = ({ children }) => {
const rootElement = usePortalRootElement("toastRoot");
if (!rootElement) {
// portal not yet ready
return null;
}
return createPortal(<Container>{children}</Container>, rootElement);
};
export default ToastArea;

View File

@@ -0,0 +1,66 @@
/*
* 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 { getTheme, Themeable, ToastThemeContext, Type } from "./themes";
import styled from "styled-components";
import React, { FC } from "react";
type Props = {
type: Type;
title: string;
close?: () => void;
};
const Container = styled.div<Themeable>`
color: ${props => props.theme.primary};
background-color: ${props => props.theme.secondary};
max-width: 18rem;
font-size: 0.75rem;
border-radius: 5px;
padding: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0 !important;
& > p {
margin-bottom: 0.5rem;
}
`;
const Title = styled.h1<Themeable>`
margin-bottom: 0.25rem;
font-weight: bold;
`;
const ToastNotification: FC<Props> = ({ children, title, type, close }) => {
const theme = getTheme(type);
return (
<Container className="notification" theme={theme}>
{ close ? <button className="delete" onClick={close} /> : null }
<Title theme={theme}>{title}</Title>
<ToastThemeContext.Provider value={theme}>{children}</ToastThemeContext.Provider>
</Container>
);
};
export default ToastNotification;

View File

@@ -27,6 +27,8 @@ import Toast from "./Toast";
import ToastButtons from "./ToastButtons";
import ToastButton from "./ToastButton";
import { types } from "./themes";
import ToastArea from "./ToastArea";
import ToastNotification from "./ToastNotification";
const toastStories = storiesOf("Toast", module);
@@ -87,3 +89,13 @@ types.forEach(type => {
</Toast>
));
});
toastStories.add("Multiple", () => (
<ToastArea>
{types.map(type => (
<ToastNotification key={type} type={type} title="New notification">
<p>The notification received.</p>
</ToastNotification>
))}
</ToastArea>
));

View File

@@ -23,5 +23,8 @@
*/
export { default as Toast } from "./Toast";
export { default as ToastArea } from "./ToastArea";
export { default as ToastNotification } from "./ToastNotification";
export { Type as ToastType } from "./themes";
export { default as ToastButton } from "./ToastButton";
export { default as ToastButtons } from "./ToastButtons";

View File

@@ -34,9 +34,9 @@ export type Themeable = {
theme: ToastTheme;
};
export type Type = "info" | "primary" | "success" | "warning" | "danger";
export const types = ["info", "primary", "success", "warning", "danger"] as const;
export const types: Type[] = ["info", "primary", "success", "warning", "danger"];
export type Type = typeof types[number];
const themes: { [name in Type]: ToastTheme } = {
info: {

View File

@@ -572,6 +572,26 @@ ul.is-separated {
&.border-is-yellow td:first-child {
border-left-color: $yellow;
}
&.is-primary td:first-child {
border-left-color: $primary;
}
&.is-success td:first-child {
border-left-color: $success;
}
&.is-warning td:first-child {
border-left-color: $warning;
}
&.is-danger td:first-child {
border-left-color: $danger;
}
&.is-info td:first-child {
border-left-color: $info;
}
&.is-link td:first-child {
border-left-color: $link;
}
&:hover {
td {
background-color: whitesmoke;
@@ -866,4 +886,12 @@ form .field:not(.is-grouped) {
background: $warning-25;
}
.small-loading-spinner {
position: relative;
pointer-events: none;
&:after {
@include loader;
}
}
@import "bulma-popover/css/bulma-popover";

View File

@@ -0,0 +1,38 @@
/*
* 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 { Embedded, HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
export type Notification = HalRepresentation & {
createdAt: string;
type: "INFO" | "SUCCESS" | "WARNING" | "ERROR";
link: string;
message: string;
};
type EmbeddedNotifications = {
notifications: Notification[];
} & Embedded;
export type NotificationCollection = HalRepresentationWithEmbedded<EmbeddedNotifications>;

View File

@@ -66,3 +66,4 @@ export * from "./LoginInfo";
export * from "./Admin";
export * from "./Diff";
export * from "./Notifications";

View File

@@ -124,5 +124,13 @@
},
"importLog": {
"title": "Importprotokoll"
},
"notifications": {
"toastTitle": "Benachrichtigung",
"xMore": "+{{ count }} Benachrichtigung",
"xMore_plural": "+{{ count }} Benachrichtigungen",
"empty": "Keine Benachrichtigungen",
"dismiss": "Löschen",
"dismissAll": "Alle löschen"
}
}

View File

@@ -125,5 +125,13 @@
},
"importLog": {
"title": "Import Log"
},
"notifications": {
"toastTitle": "Notification",
"xMore": "+{{ count }} Notification",
"xMore_plural": "+{{ count }} Notifications",
"empty": "No notifications",
"dismiss": "Dismiss",
"dismissAll": "Dismiss all"
}
}

View File

@@ -27,6 +27,7 @@ import { useTranslation } from "react-i18next";
import { ErrorPage, Footer, Header, Loading, PrimaryNavigation } from "@scm-manager/ui-components";
import Login from "./Login";
import { useSubject, useIndex } from "@scm-manager/ui-api";
import Notifications from "./Notifications";
const App: FC = () => {
const { data: index } = useIndex();
@@ -41,7 +42,6 @@ const App: FC = () => {
// authenticated means authorized, we stick on authenticated for compatibility reasons
const authenticated = isAuthenticated || isAnonymous;
const navigation = authenticated ? <PrimaryNavigation links={index._links} /> : "";
if (!authenticated && !isLoading) {
content = <Login />;
@@ -55,7 +55,14 @@ const App: FC = () => {
return (
<div className="App">
<Header>{navigation}</Header>
<Header>
{authenticated ? (
<div className="is-flex is-justify-content-space-between is-flex-wrap-nowrap ">
<PrimaryNavigation links={index._links} />
<Notifications />
</div>
) : null}
</Header>
{content}
{authenticated ? <Footer me={me} version={index.version} links={index._links} /> : null}
</div>

View File

@@ -0,0 +1,353 @@
/*
* 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 {
Button,
Notification as InfoNotification,
ErrorNotification,
Icon,
ToastArea,
ToastNotification,
ToastType,
Loading,
DateFromNow,
devices
} from "@scm-manager/ui-components";
import styled from "styled-components";
import {
useClearNotifications,
useDismissNotification,
useNotifications,
useNotificationSubscription
} from "@scm-manager/ui-api";
import { Notification, NotificationCollection } from "@scm-manager/ui-types";
import { useHistory, Link } from "react-router-dom";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
const Bell = styled(Icon)`
font-size: 1.5rem;
`;
const Container = styled.div`
display: flex;
cursor: pointer;
@media screen and (max-width: ${devices.desktop.width}px) {
padding-right: 1rem;
}
`;
const DropDownMenu = styled.div`
min-width: 35rem;
@media screen and (max-width: ${devices.mobile.width}px) {
min-width: 25rem;
}
@media screen and (max-width: ${devices.desktop.width}px) {
margin-right: 1rem;
}
&:before {
position: absolute;
content: "";
border-style: solid;
pointer-events: none;
height: 0;
width: 0;
top: 0;
right: 0.9rem;
border-color: transparent;
border-bottom-color: white;
border-left-color: white;
border-width: 0.4rem;
transform-origin: center;
transform: rotate(135deg);
}
`;
const VerticalCenteredTd = styled.td`
vertical-align: middle !important;
`;
const DateColumn = styled(VerticalCenteredTd)`
white-space: nowrap;
`;
const DismissColumn = styled.td`
vertical-align: middle !important;
width: 2rem;
`;
type EntryProps = {
notification: Notification;
removeToast: (notification: Notification) => void;
};
const NotificationEntry: FC<EntryProps> = ({ notification, removeToast }) => {
const history = useHistory();
const { isLoading, error, dismiss } = useDismissNotification(notification);
const [t] = useTranslation("commons");
const remove = () => {
removeToast(notification);
dismiss();
};
if (error) {
return <ErrorNotification error={error} />;
}
return (
<tr className={`is-${color(notification)}`}>
<VerticalCenteredTd onClick={() => history.push(notification.link)} className="has-cursor-pointer">
<NotificationMessage message={notification.message} />
</VerticalCenteredTd>
<DateColumn className="has-text-right">
<DateFromNow date={notification.createdAt} />
</DateColumn>
<DismissColumn className="is-darker">
{isLoading ? (
<div className="small-loading-spinner" />
) : (
<Icon
name="trash"
color="black"
className="has-cursor-pointer"
title={t("notifications.dismiss")}
onClick={remove}
/>
)}
</DismissColumn>
</tr>
);
};
const DismissAllButton = styled(Button)`
&:hover > * {
color: white !important;
}
`;
type ClearEntryProps = {
notifications: NotificationCollection;
clearToasts: () => void;
};
const ClearEntry: FC<ClearEntryProps> = ({ notifications, clearToasts }) => {
const { isLoading, error, clear: clearStore } = useClearNotifications(notifications);
const [t] = useTranslation("commons");
const clear = () => {
clearToasts();
clearStore();
};
return (
<div className="dropdown-item has-text-centered">
<ErrorNotification error={error} />
<DismissAllButton className="is-outlined" color="link" loading={isLoading} action={clear}>
<Icon color="link" name="trash" className="mr-1" /> {t("notifications.dismissAll")}
</DismissAllButton>
</div>
);
};
const NotificationList: FC<Props> = ({ data, clear, remove }) => {
const [t] = useTranslation("commons");
const clearLink = data._links.clear;
const all = [...data._embedded.notifications].reverse();
const top = all.slice(0, 6);
return (
<div className="dropdown-content p-0">
<table className="table mb-0 card-table">
<tbody>
{top.map((n, i) => (
<NotificationEntry key={i} notification={n} removeToast={remove} />
))}
</tbody>
</table>
{all.length > 6 ? (
<p className="has-text-centered has-text-grey">{t("notifications.xMore", { count: all.length - 6 })}</p>
) : null}
{clearLink ? <ClearEntry notifications={data} clearToasts={clear} /> : null}
</div>
);
};
const DropdownMenuContainer: FC = ({ children }) => <div className="dropdown-content p-4">{children}</div>;
const NoNotifications: FC = () => {
const [t] = useTranslation("commons");
return (
<DropdownMenuContainer>
<InfoNotification type="info">{t("notifications.empty")}</InfoNotification>
</DropdownMenuContainer>
);
};
type Props = {
data: NotificationCollection;
remove: (notification: Notification) => void;
clear: () => void;
};
const NotificationDropDown: FC<Props> = ({ data, remove, clear }) => (
<>
{data._embedded.notifications.length > 0 ? (
<NotificationList data={data} remove={remove} clear={clear} />
) : (
<NoNotifications />
)}
</>
);
const color = (notification: Notification) => {
let c: string = notification.type.toLowerCase();
// We use the color danger for an error.
// All other notification types are matching a color, except error which must be mapped to danger.
if (c === "error") {
c = "danger";
}
return c;
};
const NotificationMessage: FC<{ message: string }> = ({ message }) => {
const [t] = useTranslation("plugins");
return t("notifications." + message, message);
};
type SubscriptionProps = {
notifications: Notification[];
remove: (notification: Notification) => void;
};
const NotificationSubscription: FC<SubscriptionProps> = ({ notifications, remove }) => {
const [t] = useTranslation("commons");
const top = [...notifications].slice(-3);
return (
<ToastArea>
{top.map((notification, i) => (
<ToastNotification
key={i}
type={color(notification) as ToastType}
title={t("notifications.toastTitle")}
close={() => remove(notification)}
>
<p>
<Link to={notification.link}>
<NotificationMessage message={notification.message} />
</Link>
</p>
</ToastNotification>
))}
</ToastArea>
);
};
const BellNotificationContainer = styled.div`
position: relative;
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 counter = data?._embedded.notifications.length || 0;
return (
<BellNotificationContainer onClick={onClick}>
<Bell iconStyle={counter === 0 ? "far" : "fas"} name="bell" color="white" />
{counter > 0 ? <NotificationCounter count={counter}>{counter < 100 ? counter : "∞"}</NotificationCounter> : null}
</BellNotificationContainer>
);
};
const LoadingBox: FC = () => (
<div className="box">
<Loading />
</div>
);
const ErrorBox: FC<{ error: Error | null }> = ({ error }) => {
if (!error) {
return null;
}
return (
<DropdownMenuContainer>
<ErrorNotification error={error} />
</DropdownMenuContainer>
);
};
const Notifications: FC = () => {
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("is-align-self-flex-end", "dropdown", "is-right", "is-hoverable", {
"is-active": open
})}
onClick={e => e.stopPropagation()}
>
<Container className="dropdown-trigger">
<BellNotificationIcon data={data} onClick={() => setOpen(o => !o)} />
</Container>
<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>
</>
);
};
export default Notifications;

View File

@@ -84,6 +84,7 @@ public class MeDtoFactory extends HalAppenderMapper {
private MeDto createDto(User user) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
if (UserPermissions.delete(user).isPermitted()) {
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
}
@@ -100,6 +101,8 @@ public class MeDtoFactory extends HalAppenderMapper {
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self(user.getName())));
}
linksBuilder.single(link("notifications", resourceLinks.me().notifications()));
Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);

View File

@@ -65,13 +65,15 @@ public class MeResource {
private final PasswordService passwordService;
private final Provider<ApiKeyResource> apiKeyResourceProvider;
private final Provider<NotificationResource> notificationResourceProvider;
@Inject
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider) {
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider, Provider<NotificationResource> notificationResourceProvider) {
this.meDtoFactory = meDtoFactory;
this.userManager = userManager;
this.passwordService = passwordService;
this.apiKeyResourceProvider = apiKeyResourceProvider;
this.notificationResourceProvider = notificationResourceProvider;
}
/**
@@ -144,4 +146,9 @@ public class MeResource {
public ApiKeyResource apiKeys() {
return apiKeyResourceProvider.get();
}
@Path("notifications")
public NotificationResource notifications() {
return notificationResourceProvider.get();
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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 de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Data;
import sonia.scm.notifications.StoredNotification;
import sonia.scm.notifications.Type;
import java.time.Instant;
@Data
public class NotificationDto extends HalRepresentation {
private Instant createdAt;
private Type type;
private String link;
private String message;
public NotificationDto(StoredNotification notification, Links links) {
super(links);
this.type = notification.getType();
this.link = notification.getLink();
this.message = notification.getMessage();
this.createdAt = notification.getCreatedAt();
}
}

View File

@@ -0,0 +1,213 @@
/*
* 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 de.otto.edison.hal.Embedded;
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 sonia.scm.notifications.NotificationChannelId;
import sonia.scm.notifications.NotificationStore;
import sonia.scm.notifications.StoredNotification;
import sonia.scm.security.SessionId;
import sonia.scm.sse.Channel;
import sonia.scm.sse.ChannelRegistry;
import sonia.scm.sse.Registration;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import javax.ws.rs.sse.Sse;
import javax.ws.rs.sse.SseEventSink;
import java.util.List;
import java.util.stream.Collectors;
@OpenAPIDefinition(tags = {
@Tag(name = "Notifications", description = "Notification related endpoints")
})
public class NotificationResource {
private final NotificationStore store;
private final ChannelRegistry channelRegistry;
@Inject
public NotificationResource(NotificationStore store, ChannelRegistry channelRegistry) {
this.store = store;
this.channelRegistry = channelRegistry;
}
@GET
@Path("")
@Produces(VndMediaType.NOTIFICATION_COLLECTION)
@Operation(
summary = "Notifications",
description = "Returns all notifications for the current user",
tags = "Notifications",
operationId = "notifications_get_all"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.NOTIFICATION_COLLECTION,
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 HalRepresentation getAll(@Context UriInfo uriInfo) {
return new HalRepresentation(
createCollectionLinks(uriInfo),
createEmbeddedNotifications(uriInfo)
);
}
@DELETE
@Path("{id}")
@Operation(
summary = "Dismiss",
description = "Dismiss the notification with the given id",
tags = "Notifications",
operationId = "notifications_dismiss"
)
@ApiResponse(
responseCode = "204",
description = "no content"
)
@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 Response dismiss(@PathParam("id") String id) {
store.remove(id);
return Response.noContent().build();
}
@DELETE
@Path("")
@Operation(
summary = "Dismiss all",
description = "Dismiss all notifications for the current user",
tags = "Notifications",
operationId = "notifications_dismiss_all"
)
@ApiResponse(
responseCode = "204",
description = "no content"
)
@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 Response dismissAll() {
store.clear();
return Response.noContent().build();
}
@GET
@Path("subscribe")
@Produces(MediaType.SERVER_SENT_EVENTS)
@Operation(
summary = "Subscribe",
description = "Subscribe to the sse event stream of notification for the current user",
tags = "Notifications",
operationId = "notifications_subscribe"
)
@ApiResponse(
responseCode = "200"
)
@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 void subscribe(@Context Sse sse, @Context SseEventSink eventSink, @QueryParam(SessionId.PARAMETER) SessionId sessionId) {
Channel channel = channelRegistry.channel(NotificationChannelId.current());
channel.register(new Registration(sessionId, sse, eventSink));
}
private Embedded createEmbeddedNotifications(UriInfo uriInfo) {
List<NotificationDto> notifications = store.getAll()
.stream()
.map(n -> map(uriInfo, n))
.collect(Collectors.toList());
return Embedded.embedded("notifications", notifications);
}
private NotificationDto map(UriInfo uriInfo, StoredNotification storedNotification) {
String href = uriInfo.getAbsolutePathBuilder().path(storedNotification.getId()).build().toASCIIString();
Links links = Links.linkingTo().single(Link.link("dismiss", href)).build();
return new NotificationDto(storedNotification, links);
}
private Links createCollectionLinks(UriInfo uriInfo) {
String self = selfLink(uriInfo);
return Links.linkingTo()
.self(self)
.single(Link.link("clear", self))
.single(Link.link("subscribe", subscribeLink(uriInfo)))
.build();
}
private String selfLink(UriInfo uriInfo) {
return uriInfo.getAbsolutePath().toASCIIString();
}
private String subscribeLink(UriInfo uriInfo) {
return uriInfo.getRequestUriBuilder().path("subscribe").build().toASCIIString();
}
}

View File

@@ -41,6 +41,7 @@ import sonia.scm.ConcurrentModificationException;
import sonia.scm.NotFoundException;
import sonia.scm.Type;
import sonia.scm.importexport.ExportFileExtensionResolver;
import sonia.scm.importexport.ExportNotificationHandler;
import sonia.scm.importexport.ExportService;
import sonia.scm.importexport.FullScmRepositoryExporter;
import sonia.scm.importexport.RepositoryImportExportEncryption;
@@ -98,6 +99,7 @@ public class RepositoryExportResource {
private final RepositoryExportInformationToDtoMapper informationToDtoMapper;
private final ExportFileExtensionResolver fileExtensionResolver;
private final ResourceLinks resourceLinks;
private final ExportNotificationHandler notificationHandler;
@Inject
public RepositoryExportResource(RepositoryManager manager,
@@ -108,8 +110,8 @@ public class RepositoryExportResource {
RepositoryExportInformationToDtoMapper informationToDtoMapper,
ExportFileExtensionResolver fileExtensionResolver,
ResourceLinks resourceLinks,
MeterRegistry registry
) {
MeterRegistry registry,
ExportNotificationHandler notificationHandler) {
this.manager = manager;
this.serviceFactory = serviceFactory;
this.fullScmRepositoryExporter = fullScmRepositoryExporter;
@@ -119,6 +121,7 @@ public class RepositoryExportResource {
this.fileExtensionResolver = fileExtensionResolver;
this.resourceLinks = resourceLinks;
this.repositoryExportHandler = this.createExportHandlerPool(registry);
this.notificationHandler = notificationHandler;
}
/**
@@ -312,11 +315,7 @@ public class RepositoryExportResource {
Repository repository = getVerifiedRepository(namespace, name);
RepositoryPermissions.export(repository).check();
checkRepositoryIsAlreadyExporting(repository);
return exportAsync(repository, request.isAsync(), () -> {
Response response = exportFullRepository(repository, request.getPassword(), request.isAsync());
exportService.setExportFinished(repository);
return response;
});
return exportAsync(repository, request.isAsync(), () -> exportFullRepository(repository, request.getPassword(), request.isAsync()));
}
@DELETE
@@ -458,6 +457,7 @@ public class RepositoryExportResource {
return Response.status(204).build();
} else {
StreamingOutput output = os -> fullScmRepositoryExporter.export(repository, os, password);
exportService.setExportFinished(repository);
return Response
.ok(output, "application/x-gzip")
@@ -485,6 +485,7 @@ public class RepositoryExportResource {
return createResponse(repository, fileExtension, compressed, output);
}
} catch (IOException e) {
notificationHandler.handleFailedExport(repository);
throw new ExportFailedException(entity(repository).build(), "repository export failed", e);
}
}
@@ -533,7 +534,7 @@ public class RepositoryExportResource {
return executorService;
}
@SuppressWarnings("java:S110") // is ok for this type of exceptions
private static class WrongTypeException extends BadRequestException {
private static final String CODE = "4hSNNTBiu1";

View File

@@ -194,10 +194,12 @@ class ResourceLinks {
static class MeLinks {
private final LinkBuilder meLinkBuilder;
private final LinkBuilder notificationLinkBuilder;
private UserLinks userLinks;
MeLinks(ScmPathInfo pathInfo, UserLinks user) {
meLinkBuilder = new LinkBuilder(pathInfo, MeResource.class);
notificationLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, NotificationResource.class);
userLinks = user;
}
@@ -216,6 +218,10 @@ class ResourceLinks {
public String passwordChange() {
return meLinkBuilder.method("changePassword").parameters().href();
}
String notifications() {
return notificationLinkBuilder.method("notifications").parameters().method("getAll").parameters().href();
}
}
public ApiKeyCollectionLinks apiKeyCollection() {

View File

@@ -0,0 +1,59 @@
/*
* 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.importexport;
import sonia.scm.notifications.Notification;
import sonia.scm.notifications.NotificationSender;
import sonia.scm.notifications.Type;
import sonia.scm.repository.Repository;
import javax.inject.Inject;
public class ExportNotificationHandler {
private final NotificationSender notificationSender;
@Inject
public ExportNotificationHandler(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void handleFailedExport(Repository repository) {
notificationSender.send(getExportFailedNotification(repository));
}
public void handleSuccessfulExport(Repository repository) {
notificationSender.send(getExportSuccessfulNotification(repository));
}
private Notification getExportFailedNotification(Repository repository) {
return new Notification(Type.ERROR, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "exportFailed");
}
private Notification getExportSuccessfulNotification(Repository repository) {
return new Notification(Type.SUCCESS, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "exportFinished");
}
}

View File

@@ -55,12 +55,14 @@ public class ExportService {
private final BlobStoreFactory blobStoreFactory;
private final DataStoreFactory dataStoreFactory;
private final ExportFileExtensionResolver fileExtensionResolver;
private final ExportNotificationHandler notificationHandler;
@Inject
public ExportService(BlobStoreFactory blobStoreFactory, DataStoreFactory dataStoreFactory, ExportFileExtensionResolver fileExtensionResolver) {
public ExportService(BlobStoreFactory blobStoreFactory, DataStoreFactory dataStoreFactory, ExportFileExtensionResolver fileExtensionResolver, ExportNotificationHandler notificationHandler) {
this.blobStoreFactory = blobStoreFactory;
this.dataStoreFactory = dataStoreFactory;
this.fileExtensionResolver = fileExtensionResolver;
this.notificationHandler = notificationHandler;
}
public OutputStream store(Repository repository, boolean withMetadata, boolean compressed, boolean encrypted) {
@@ -69,6 +71,7 @@ public class ExportService {
try {
return storeNewBlob(repository.getId()).getOutputStream();
} catch (IOException e) {
notificationHandler.handleFailedExport(repository);
throw new ExportFailedException(
entity(repository).build(),
"Could not store repository export to blob file",
@@ -120,6 +123,7 @@ public class ExportService {
RepositoryExportInformation info = dataStore.get(repository.getId());
info.setStatus(ExportStatus.FINISHED);
dataStore.put(repository.getId(), info);
notificationHandler.handleSuccessfulExport(repository);
}
public boolean isExporting(Repository repository) {

View File

@@ -59,6 +59,7 @@ public class FullScmRepositoryExporter {
private final WorkdirProvider workdirProvider;
private final RepositoryExportingCheck repositoryExportingCheck;
private final RepositoryImportExportEncryption repositoryImportExportEncryption;
private final ExportNotificationHandler notificationHandler;
@Inject
public FullScmRepositoryExporter(EnvironmentInformationXmlGenerator environmentGenerator,
@@ -67,7 +68,7 @@ public class FullScmRepositoryExporter {
TarArchiveRepositoryStoreExporter storeExporter,
WorkdirProvider workdirProvider,
RepositoryExportingCheck repositoryExportingCheck,
RepositoryImportExportEncryption repositoryImportExportEncryption) {
RepositoryImportExportEncryption repositoryImportExportEncryption, ExportNotificationHandler notificationHandler) {
this.environmentGenerator = environmentGenerator;
this.metadataGenerator = metadataGenerator;
this.serviceFactory = serviceFactory;
@@ -75,13 +76,19 @@ public class FullScmRepositoryExporter {
this.workdirProvider = workdirProvider;
this.repositoryExportingCheck = repositoryExportingCheck;
this.repositoryImportExportEncryption = repositoryImportExportEncryption;
this.notificationHandler = notificationHandler;
}
public void export(Repository repository, OutputStream outputStream, String password) {
repositoryExportingCheck.withExportingLock(repository, () -> {
exportInLock(repository, outputStream, password);
return null;
});
try {
repositoryExportingCheck.withExportingLock(repository, () -> {
exportInLock(repository, outputStream, password);
return null;
});
} catch (ExportFailedException ex) {
notificationHandler.handleFailedExport(repository);
throw ex;
}
}
private void exportInLock(Repository repository, OutputStream outputStream, String password) {

View File

@@ -63,6 +63,8 @@ import sonia.scm.net.ahc.ContentTransformer;
import sonia.scm.net.ahc.DefaultAdvancedHttpClient;
import sonia.scm.net.ahc.JsonContentTransformer;
import sonia.scm.net.ahc.XmlContentTransformer;
import sonia.scm.notifications.DefaultNotificationSender;
import sonia.scm.notifications.NotificationSender;
import sonia.scm.plugin.DefaultPluginManager;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.PluginManager;
@@ -263,6 +265,8 @@ class ScmServletModule extends ServletModule {
bind(PermissionProvider.class).to(RepositoryPermissionProvider.class);
bind(HealthCheckService.class).to(DefaultHealthCheckService.class);
bind(NotificationSender.class).to(DefaultNotificationSender.class);
}
private <T> void bind(Class<T> clazz, Class<? extends T> defaultImplementation) {

View File

@@ -0,0 +1,58 @@
/*
* 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.notifications;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.sse.Channel;
import sonia.scm.sse.ChannelRegistry;
import sonia.scm.sse.Message;
import javax.inject.Inject;
public class DefaultNotificationSender implements NotificationSender {
@VisibleForTesting
static final String MESSAGE_NAME = "notification";
private final NotificationStore store;
private final ChannelRegistry channelRegistry;
@Inject
public DefaultNotificationSender(NotificationStore store, ChannelRegistry channelRegistry) {
this.store = store;
this.channelRegistry = channelRegistry;
}
@Override
public void send(Notification notification, String recipient) {
store.add(notification, recipient);
Channel channel = channelRegistry.channel(new NotificationChannelId(recipient));
channel.broadcast(message(notification));
}
private Message message(Notification notification) {
return new Message(MESSAGE_NAME, Notification.class, notification);
}
}

View File

@@ -0,0 +1,44 @@
/*
* 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.notifications;
import lombok.EqualsAndHashCode;
import org.apache.shiro.SecurityUtils;
@EqualsAndHashCode
public class NotificationChannelId {
private final String username;
public NotificationChannelId(String username) {
this.username = username;
}
public static NotificationChannelId current() {
return new NotificationChannelId(
SecurityUtils.getSubject().getPrincipal().toString()
);
}
}

View File

@@ -0,0 +1,162 @@
/*
* 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.notifications;
import com.github.legman.Subscribe;
import com.google.common.util.concurrent.Striped;
import lombok.Data;
import org.apache.shiro.SecurityUtils;
import sonia.scm.HandlerEventType;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.user.UserEvent;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlElementWrapper;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.stream.Collectors;
@Singleton
@SuppressWarnings("UnstableApiUsage") // striped is still marked as beta
public class NotificationStore {
private static final String NAME = "notifications";
private final DataStore<StoredNotifications> store;
private final KeyGenerator keyGenerator;
private final Striped<ReadWriteLock> locks = Striped.readWriteLock(10);
@Inject
public NotificationStore(DataStoreFactory dataStoreFactory, KeyGenerator keyGenerator) {
this.store = dataStoreFactory.withType(StoredNotifications.class)
.withName(NAME)
.build();
this.keyGenerator = keyGenerator;
}
public String add(Notification notification, String username) {
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
String id = keyGenerator.createKey();
notifications.getEntries().add(new StoredNotification(id, notification));
store.put(username, notifications);
return id;
} finally {
lock.unlock();
}
}
private StoredNotifications get(String username) {
return store.getOptional(username).orElse(new StoredNotifications());
}
public List<StoredNotification> getAll() {
String username = getCurrentUsername();
Lock lock = locks.get(username).readLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
return Collections.unmodifiableList(notifications.getEntries());
} finally {
lock.unlock();
}
}
public void remove(String id) {
String username = getCurrentUsername();
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
List<StoredNotification> entries = notifications.getEntries()
.stream()
.filter(n -> !id.equals(n.id))
.collect(Collectors.toList());
notifications.setEntries(entries);
store.put(username, notifications);
} finally {
lock.unlock();
}
}
public void clear() {
String username = getCurrentUsername();
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
notifications.getEntries().clear();
store.put(username, notifications);
} finally {
lock.unlock();
}
}
private String getCurrentUsername() {
return SecurityUtils.getSubject().getPrincipal().toString();
}
@Subscribe
public void handle(UserEvent event) {
if (event.getEventType() == HandlerEventType.DELETE) {
String username = event.getItem().getName();
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
store.remove(username);
} finally {
lock.unlock();
}
}
}
@Data
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
static class StoredNotifications {
@XmlElement(name = "notification")
@XmlElementWrapper(name = "notifications")
private List<StoredNotification> entries;
public List<StoredNotification> getEntries() {
if (entries == null) {
entries = new ArrayList<>();
}
return entries;
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.notifications;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import sonia.scm.xml.XmlInstantAdapter;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
public class StoredNotification {
String id;
Type type;
String link;
String message;
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
Instant createdAt;
StoredNotification(String id, Notification notification) {
this.id = id;
this.createdAt = notification.getCreatedAt();
this.type = notification.getType();
this.link = notification.getLink();
this.message = notification.getMessage();
}
}

View File

@@ -424,5 +424,9 @@
"CustomNamespaceStrategy": "Benutzerdefiniert",
"CurrentYearNamespaceStrategy": "Aktuelles Jahr",
"RepositoryTypeNamespaceStrategy": "Repository Typ"
},
"notifications": {
"exportFinished": "Der Repository Export wurde abgeschlossen.",
"exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator."
}
}

View File

@@ -368,5 +368,9 @@
"CustomNamespaceStrategy": "Custom",
"CurrentYearNamespaceStrategy": "Current year",
"RepositoryTypeNamespaceStrategy": "Repository type"
},
"notifications": {
"exportFinished": "The repository export has been finished.",
"exportFailed": "The repository export has failed. Try it again or contact your administrator."
}
}

View File

@@ -260,4 +260,13 @@ class MeDtoFactoryTest {
MeDto dto = meDtoFactory.create();
assertThat(dto.getLinks().getLinkBy("apiKeys")).isNotPresent();
}
@Test
void shouldAppendNotificationsLink() {
User user = UserTestData.createTrillian();
prepareSubject(user);
MeDto dto = meDtoFactory.create();
assertThat(dto.getLinks().getLinkBy("notifications").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/me/notifications");
}
}

View File

@@ -114,6 +114,8 @@ public class MeResourceTest {
private PasswordService passwordService;
private User originalUser;
@Mock
private NotificationResource notificationResource;
@Before
public void prepareEnvironment() {
@@ -127,7 +129,7 @@ public class MeResourceTest {
when(userManager.getDefaultType()).thenReturn("xml");
ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks);
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource));
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource), of(notificationResource));
when(uriInfo.getApiRestUri()).thenReturn(URI.create("/"));
when(scmPathInfoStore.get()).thenReturn(uriInfo);
dispatcher.addSingletonResource(meResource);

View File

@@ -0,0 +1,183 @@
/*
* 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.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.notifications.Notification;
import sonia.scm.notifications.NotificationStore;
import sonia.scm.notifications.StoredNotification;
import sonia.scm.notifications.Type;
import sonia.scm.sse.ChannelRegistry;
import sonia.scm.web.RestDispatcher;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Path;
import java.io.IOException;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class NotificationResourceTest {
private final ObjectMapper mapper = new ObjectMapper();
@Mock
private NotificationStore store;
@Mock
private ChannelRegistry channelRegistry;
private RestDispatcher dispatcher;
@BeforeEach
void setUp() {
dispatcher = new RestDispatcher();
dispatcher.addSingletonResource(
new TestingRootResource(
new NotificationResource(store, channelRegistry)
)
);
}
@Test
void shouldReturnAllNotifications() throws IOException, URISyntaxException {
notifications("One", "Two", "Three");
MockHttpRequest request = MockHttpRequest.get("/api/v2/notifications");
MockHttpResponse response = invoke(request);
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
JsonNode node = mapper.readTree(response.getContentAsString());
JsonNode notificationNodes = node.get("_embedded").get("notifications");
assertThat(notificationNodes.get(0).get("message").asText()).isEqualTo("One");
assertThat(notificationNodes.get(1).get("message").asText()).isEqualTo("Two");
assertThat(notificationNodes.get(2).get("message").asText()).isEqualTo("Three");
}
@Test
void shouldReturnDismissLink() throws IOException, URISyntaxException {
String id = notifications("One").get(0).getId();
MockHttpRequest request = MockHttpRequest.get("/api/v2/notifications");
MockHttpResponse response = invoke(request);
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
JsonNode node = mapper.readTree(response.getContentAsString());
String dismissHref = node.get("_embedded")
.get("notifications")
.get(0)
.get("_links")
.get("dismiss")
.get("href")
.asText();
assertThat(dismissHref).isEqualTo("/api/v2/notifications/" + id);
}
@Test
void shouldReturnCollectionLinks() throws IOException, URISyntaxException {
notifications();
MockHttpRequest request = MockHttpRequest.get("/api/v2/notifications");
MockHttpResponse response = invoke(request);
JsonNode node = mapper.readTree(response.getContentAsString());
JsonNode links = node.get("_links");
assertThat(links.get("self").get("href").asText()).isEqualTo("/api/v2/notifications");
assertThat(links.get("clear").get("href").asText()).isEqualTo("/api/v2/notifications");
}
@Test
void shouldRemoveNotification() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.delete("/api/v2/notifications/abc42");
MockHttpResponse response = invoke(request);
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NO_CONTENT);
verify(store).remove("abc42");
}
@Test
void shouldClear() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.delete("/api/v2/notifications");
MockHttpResponse response = invoke(request);
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NO_CONTENT);
verify(store).clear();
}
private MockHttpResponse invoke(MockHttpRequest request) {
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
return response;
}
private List<StoredNotification> notifications(String... messages) {
List<StoredNotification> notifications = Arrays.stream(messages)
.map(this::notification)
.collect(Collectors.toList());
when(store.getAll()).thenReturn(notifications);
return notifications;
}
private StoredNotification notification(String m) {
return new StoredNotification(UUID.randomUUID().toString(), Type.INFO, "/notify", m, Instant.now());
}
@Path("/api/v2")
public static class TestingRootResource {
private final NotificationResource resource;
public TestingRootResource(NotificationResource resource) {
this.resource = resource;
}
@Path("notifications")
public NotificationResource notifications() {
return resource;
}
}
}

View File

@@ -46,6 +46,7 @@ import sonia.scm.NotFoundException;
import sonia.scm.PageResult;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.importexport.ExportFileExtensionResolver;
import sonia.scm.importexport.ExportNotificationHandler;
import sonia.scm.importexport.ExportService;
import sonia.scm.importexport.ExportStatus;
import sonia.scm.importexport.FromBundleImporter;
@@ -160,6 +161,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
private ExportService exportService;
@Mock
private HealthCheckService healthCheckService;
@Mock
private ExportNotificationHandler notificationHandler;
@Captor
private ArgumentCaptor<Predicate<Repository>> filterCaptor;
@@ -182,7 +185,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks);
super.repositoryCollectionResource = new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks, repositoryInitializer);
super.repositoryImportResource = new RepositoryImportResource(dtoToRepositoryMapper, resourceLinks, fullScmRepositoryImporter, new RepositoryImportDtoToRepositoryImportParametersMapperImpl(), repositoryImportExportEncryption, fromUrlImporter, fromBundleImporter, importLoggerFactory);
super.repositoryExportResource = new RepositoryExportResource(repositoryManager, serviceFactory, fullScmRepositoryExporter, repositoryImportExportEncryption, exportService, exportInformationToDtoMapper, fileExtensionResolver, resourceLinks, new SimpleMeterRegistry());
super.repositoryExportResource = new RepositoryExportResource(repositoryManager, serviceFactory, fullScmRepositoryExporter, repositoryImportExportEncryption, exportService, exportInformationToDtoMapper, fileExtensionResolver, resourceLinks, new SimpleMeterRegistry(), notificationHandler);
dispatcher.addSingletonResource(getRepositoryRootResource());
when(serviceFactory.create(any(Repository.class))).thenReturn(service);
doReturn(ImmutableSet.of(new CustomNamespaceStrategy()).iterator()).when(strategies).iterator();

View File

@@ -0,0 +1,73 @@
/*
* 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.importexport;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.notifications.NotificationSender;
import sonia.scm.notifications.Type;
import sonia.scm.repository.RepositoryTestData;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class ExportNotificationHandlerTest {
@Mock
private NotificationSender sender;
@InjectMocks
private ExportNotificationHandler handler;
@Test
void shouldSendFailedNotification() {
handler.handleFailedExport(RepositoryTestData.create42Puzzle());
verify(sender).send(argThat(notification -> {
assertThat(notification.getType()).isEqualTo(Type.ERROR);
assertThat(notification.getLink()).isEqualTo("/repo/hitchhiker/42Puzzle/settings/general");
assertThat(notification.getMessage()).isEqualTo("exportFailed");
return true;
}));
}
@Test
void shouldSendSuccessfulNotification() {
handler.handleSuccessfulExport(RepositoryTestData.create42Puzzle());
verify(sender).send(argThat(notification -> {
assertThat(notification.getType()).isEqualTo(Type.SUCCESS);
assertThat(notification.getLink()).isEqualTo("/repo/hitchhiker/42Puzzle/settings/general");
assertThat(notification.getMessage()).isEqualTo("exportFinished");
return true;
}));
}
}

View File

@@ -36,6 +36,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.NotFoundException;
import sonia.scm.notifications.Type;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.store.Blob;
@@ -55,9 +56,11 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static sonia.scm.importexport.ExportService.STORE_NAME;
@@ -75,6 +78,9 @@ class ExportServiceTest {
@Mock
private ExportFileExtensionResolver resolver;
@Mock
private ExportNotificationHandler notificationHandler;
private BlobStore blobStore;
private DataStore<RepositoryExportInformation> dataStore;
@@ -136,6 +142,7 @@ class ExportServiceTest {
exportService.setExportFinished(REPOSITORY);
assertThat(exportService.isExporting(REPOSITORY)).isFalse();
verify(notificationHandler).handleSuccessfulExport(REPOSITORY);
}
@Test

View File

@@ -0,0 +1,84 @@
/*
* 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.notifications;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.sse.Channel;
import sonia.scm.sse.ChannelRegistry;
import sonia.scm.sse.Message;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DefaultNotificationSenderTest {
@Mock
private NotificationStore store;
@Mock
private ChannelRegistry channelRegistry;
@InjectMocks
private DefaultNotificationSender sender;
@Mock
private Channel channel;
@Test
void shouldDelegateToStore() {
when(channelRegistry.channel(any())).thenReturn(channel);
Notification notification = new Notification(Type.ERROR, "/fail", "Everything has failed");
sender.send(notification, "trillian");
verify(store).add(notification, "trillian");
}
@Test
void shouldSendToChannel() {
NotificationChannelId channelId = new NotificationChannelId("trillian");
when(channelRegistry.channel(channelId)).thenReturn(channel);
Notification notification = new Notification(Type.WARNING, "/warn", "Everything looks strange");
sender.send(notification, "trillian");
ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
verify(channel).broadcast(messageCaptor.capture());
Message message = messageCaptor.getValue();
assertThat(message.getName()).isEqualTo(DefaultNotificationSender.MESSAGE_NAME);
assertThat(message.getType()).isEqualTo(Notification.class);
assertThat(message.getData()).isEqualTo(notification);
}
}

View File

@@ -0,0 +1,177 @@
/*
* 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.notifications;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import sonia.scm.HandlerEventType;
import sonia.scm.security.KeyGenerator;
import sonia.scm.security.UUIDKeyGenerator;
import sonia.scm.store.InMemoryByteDataStoreFactory;
import sonia.scm.user.UserEvent;
import sonia.scm.user.UserTestData;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(ShiroExtension.class)
class NotificationStoreTest {
private NotificationStore store;
private final KeyGenerator keyGenerator = new UUIDKeyGenerator();
private AtomicInteger counter;
@BeforeEach
void setUp() {
counter = new AtomicInteger();
store = new NotificationStore(new InMemoryByteDataStoreFactory(), keyGenerator);
}
@Test
@SubjectAware("trillian")
void shouldAddNotification() {
Notification notification = notification();
store.add(notification, "trillian");
containsMessage(notification);
}
@Test
@SubjectAware("trillian")
void shouldReturnId() {
Notification notification = notification();
String id = store.add(notification, "trillian");
assertThat(id).isNotNull().isNotEmpty();
}
@Test
@SubjectAware("trillian")
void shouldAssignId() {
Notification notification = notification();
store.add(notification, "trillian");
StoredNotification storedNotification = store.getAll().get(0);
assertThat(storedNotification.getId()).isNotNull().isNotEmpty();
}
@Test
@SubjectAware("trillian")
void shouldCopyProperties() {
Notification notification = notification();
store.add(notification, "trillian");
StoredNotification storedNotification = store.getAll().get(0);
assertThat(storedNotification)
.usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(notification);
}
private void containsMessage(Notification... notifications) {
String[] messages = Arrays.stream(notifications).map(Notification::getMessage).toArray(String[]::new);
Stream<String> storedMessages = store.getAll().stream().map(StoredNotification::getMessage);
assertThat(storedMessages).containsOnly(messages);
}
@Test
@SubjectAware("trillian")
void shouldOnlyReturnNotificationsForPrincipal() {
Notification one = notification();
Notification two = notification();
Notification three = notification();
store.add(one, "trillian");
store.add(two, "dent");
store.add(three, "trillian");
containsMessage(one, three);
}
@Test
@SubjectAware("slarti")
void shouldClearOnlyPrincipalNotifications() {
Notification one = notification();
Notification two = notification();
Notification three = notification();
store.add(one, "slarti");
store.add(two, "slarti");
store.add(three, "slarti");
store.clear();
assertThat(store.getAll()).isEmpty();
}
@Test
@SubjectAware("slarti")
void shouldRemoveNotificationWithId() {
Notification one = notification();
Notification two = notification();
String id = store.add(one, "slarti");
store.add(two, "slarti");
store.remove(id);
containsMessage(two);
}
@Test
@SubjectAware("slarti")
void shouldRemoveEntryIfUserIsDeleted() {
store.add(notification(), "slarti");
store.handle(new UserEvent(HandlerEventType.DELETE, UserTestData.createSlarti()));
assertThat(store.getAll()).isEmpty();
}
@SubjectAware("slarti")
@ParameterizedTest(name = "shouldIgnoreEvent_{argumentsWithNames}")
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.EXCLUDE, names = "DELETE")
void shouldIgnoreNonDeleteEvents(HandlerEventType event) {
store.add(notification(), "slarti");
store.handle(new UserEvent(event, UserTestData.createSlarti()));
assertThat(store.getAll()).hasSize(1);
}
private Notification notification() {
return new Notification(Type.INFO, "/greeting", "Hello " + counter.incrementAndGet());
}
}