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

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