From 4d6e4d35f28858425ed5cc838f9e3d981d9ae1fe Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 14 Apr 2025 13:04:56 -0400 Subject: [PATCH] [PM-18555] Notifications service tests (#5473) * Add RelayPush Notifications Tests * Nullable Test Fixup * Azure Queue Notifications Tests * NotificationsHub Push Tests * Make common base for API based notifications * Register TimeProvider just in case * Format * React to TaskId * Remove completed TODO --- .../NotificationHubPushNotificationService.cs | 8 +- .../AzureQueuePushNotificationService.cs | 8 +- ...NotificationsApiPushNotificationService.cs | 7 +- .../Services/RelayPushNotificationService.cs | 7 +- .../Utilities/ServiceCollectionExtensions.cs | 3 + ...ficationHubPushNotificationServiceTests.cs | 638 ++++++++++++++ .../AzureQueuePushNotificationServiceTests.cs | 775 ++++++++++++++++++ ...icationsApiPushNotificationServiceTests.cs | 400 ++++++++- .../Platform/Push/Services/PushTestBase.cs | 498 +++++++++++ .../RelayPushNotificationServiceTests.cs | 544 +++++++++++- 10 files changed, 2826 insertions(+), 62 deletions(-) create mode 100644 test/Core.Test/Platform/Push/Services/PushTestBase.cs diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs index 55badf80e4..bb3de80977 100644 --- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs @@ -32,20 +32,22 @@ public class NotificationHubPushNotificationService : IPushNotificationService private readonly INotificationHubPool _notificationHubPool; private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; + private readonly TimeProvider _timeProvider; public NotificationHubPushNotificationService( IInstallationDeviceRepository installationDeviceRepository, INotificationHubPool notificationHubPool, IHttpContextAccessor httpContextAccessor, ILogger logger, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + TimeProvider timeProvider) { _installationDeviceRepository = installationDeviceRepository; _httpContextAccessor = httpContextAccessor; _notificationHubPool = notificationHubPool; _logger = logger; _globalSettings = globalSettings; - + _timeProvider = timeProvider; if (globalSettings.Installation.Id == Guid.Empty) { logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); @@ -152,7 +154,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) { - var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; + var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime }; await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); } diff --git a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs index a9140796f2..05d1dd2d1d 100644 --- a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs +++ b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs @@ -22,17 +22,19 @@ public class AzureQueuePushNotificationService : IPushNotificationService private readonly QueueClient _queueClient; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IGlobalSettings _globalSettings; + private readonly TimeProvider _timeProvider; public AzureQueuePushNotificationService( [FromKeyedServices("notifications")] QueueClient queueClient, IHttpContextAccessor httpContextAccessor, IGlobalSettings globalSettings, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) { _queueClient = queueClient; _httpContextAccessor = httpContextAccessor; _globalSettings = globalSettings; - + _timeProvider = timeProvider; if (globalSettings.Installation.Id == Guid.Empty) { logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); @@ -140,7 +142,7 @@ public class AzureQueuePushNotificationService : IPushNotificationService private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) { - var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; + var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime }; await SendMessageAsync(type, message, excludeCurrentContext); } diff --git a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs index d5cc0819cd..bdeefc0363 100644 --- a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs @@ -24,12 +24,14 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService { private readonly IGlobalSettings _globalSettings; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; public NotificationsApiPushNotificationService( IHttpClientFactory httpFactory, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) : base( httpFactory, globalSettings.BaseServiceUri.InternalNotifications, @@ -41,6 +43,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService { _globalSettings = globalSettings; _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; } public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds) @@ -148,7 +151,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService var message = new UserPushNotification { UserId = userId, - Date = DateTime.UtcNow + Date = _timeProvider.GetUtcNow().UtcDateTime, }; await SendMessageAsync(type, message, excludeCurrentContext); diff --git a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs index c9e77bf17f..0ede81e719 100644 --- a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs @@ -27,13 +27,15 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti private readonly IDeviceRepository _deviceRepository; private readonly IGlobalSettings _globalSettings; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; public RelayPushNotificationService( IHttpClientFactory httpFactory, IDeviceRepository deviceRepository, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) : base( httpFactory, globalSettings.PushRelayBaseUri, @@ -46,6 +48,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti _deviceRepository = deviceRepository; _globalSettings = globalSettings; _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; } public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds) @@ -147,7 +150,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) { - var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; + var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime }; await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 144ea1f036..5340a88aef 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -67,6 +67,7 @@ using Microsoft.Extensions.Caching.Cosmos; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -279,6 +280,8 @@ public static class ServiceCollectionExtensions services.AddSingleton(); } + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(); if (globalSettings.SelfHosted) { diff --git a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs index 2ae3d739a8..6f4ea9ca12 100644 --- a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs +++ b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs @@ -1,15 +1,25 @@ #nullable enable using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Core.Auth.Entities; +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; using Bit.Core.NotificationHub; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -19,6 +29,10 @@ namespace Bit.Core.Test.NotificationHub; [NotificationStatusCustomize] public class NotificationHubPushNotificationServiceTests { + private static readonly string _deviceIdentifier = "test_device_identifier"; + private static readonly DateTime _now = DateTime.UtcNow; + private static readonly Guid _installationId = Guid.Parse("da73177b-513f-4444-b582-595c890e1022"); + [Theory] [BitAutoData] [NotificationCustomize] @@ -496,6 +510,630 @@ public class NotificationHubPushNotificationServiceTests .UpsertAsync(Arg.Any()); } + [Fact] + public async Task PushSyncCipherCreateAsync_SendExpectedData() + { + var collectionId = Guid.NewGuid(); + + var userId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherCreateAsync(cipher, [collectionId]), + PushType.SyncCipherCreate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncCipherUpdateAsync_SendExpectedData() + { + var collectionId = Guid.NewGuid(); + + var userId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherUpdateAsync(cipher, [collectionId]), + PushType.SyncCipherUpdate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncCipherDeleteAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherDeleteAsync(cipher), + PushType.SyncLoginDelete, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncFolderCreateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderCreateAsync(folder), + PushType.SyncFolderCreate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncFolderUpdateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderUpdateAsync(folder), + PushType.SyncFolderUpdate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncSendCreateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var send = new Send + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendCreateAsync(send), + PushType.SyncSendCreate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushAuthRequestAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = userId, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestAsync(authRequest), + PushType.AuthRequest, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushAuthRequestResponseAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = userId, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestResponseAsync(authRequest), + PushType.AuthRequestResponse, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncSendUpdateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var send = new Send + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendUpdateAsync(send), + PushType.SyncSendUpdate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncSendDeleteAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var send = new Send + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendDeleteAsync(send), + PushType.SyncSendDelete, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncCiphersAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCiphersAsync(userId), + PushType.SyncCiphers, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncVaultAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncVaultAsync(userId), + PushType.SyncVault, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncOrganizationsAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationsAsync(userId), + PushType.SyncOrganizations, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncOrgKeysAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrgKeysAsync(userId), + PushType.SyncOrgKeys, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncSettingsAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSettingsAsync(userId), + PushType.SyncSettings, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext) + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + var expectedTag = excludeCurrentContext + ? $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + : $"(template:payload_userId:{userId})"; + + await VerifyNotificationAsync( + async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), + PushType.LogOut, + expectedPayload, + expectedTag + ); + } + + [Fact] + public async Task PushSyncFolderDeleteAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderDeleteAsync(folder), + PushType.SyncFolderDelete, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationAsync_SendExpectedData(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + TaskId = Guid.NewGuid(), + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + JsonNode? installationId = global ? _installationId : null; + + var expectedPayload = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = null, + ["DeletedDate"] = null, + }; + + string expectedTag; + + if (global) + { + expectedTag = $"(template:payload && installationId:{_installationId} && !deviceIdentifier:{_deviceIdentifier})"; + } + else if (notification.OrganizationId.HasValue) + { + expectedTag = "(template:payload && organizationId:2f53ee32-edf9-4169-b276-760fe92e03bf && !deviceIdentifier:test_device_identifier)"; + } + else + { + expectedTag = $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})"; + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationAsync(notification), + PushType.Notification, + expectedPayload, + expectedTag + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationStatusAsync_SendExpectedData(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var notificationStatus = new NotificationStatus + { + ReadDate = DateTime.UtcNow.AddDays(-1), + DeletedDate = DateTime.UtcNow, + }; + + JsonNode? installationId = global ? _installationId : null; + + var expectedPayload = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }; + + string expectedTag; + + if (global) + { + expectedTag = $"(template:payload && installationId:{_installationId} && !deviceIdentifier:{_deviceIdentifier})"; + } + else if (notification.OrganizationId.HasValue) + { + expectedTag = "(template:payload && organizationId:2f53ee32-edf9-4169-b276-760fe92e03bf && !deviceIdentifier:test_device_identifier)"; + } + else + { + expectedTag = $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})"; + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationStatusAsync(notification, notificationStatus), + PushType.NotificationStatus, + expectedPayload, + expectedTag + ); + } + + private async Task VerifyNotificationAsync(Func test, + PushType type, JsonNode expectedPayload, string tag) + { + var installationDeviceRepository = Substitute.For(); + + var notificationHubPool = Substitute.For(); + + var notificationHubProxy = Substitute.For(); + + notificationHubPool.AllClients + .Returns(notificationHubProxy); + + var httpContextAccessor = Substitute.For(); + + var httpContext = new DefaultHttpContext(); + + var serviceCollection = new ServiceCollection(); + var currentContext = Substitute.For(); + currentContext.DeviceIdentifier = _deviceIdentifier; + serviceCollection.AddSingleton(currentContext); + + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + httpContextAccessor.HttpContext + .Returns(httpContext); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.Installation.Id = _installationId; + + var fakeTimeProvider = new FakeTimeProvider(); + + fakeTimeProvider.SetUtcNow(_now); + + var sut = new NotificationHubPushNotificationService( + installationDeviceRepository, + notificationHubPool, + httpContextAccessor, + NullLogger.Instance, + globalSettings, + fakeTimeProvider + ); + + // Act + await test(sut); + + // Assert + var calls = notificationHubProxy.ReceivedCalls(); + var methodInfo = typeof(INotificationHubProxy).GetMethod(nameof(INotificationHubProxy.SendTemplateNotificationAsync)); + var call = Assert.Single(calls, c => c.GetMethodInfo() == methodInfo); + + var arguments = call.GetArguments(); + + var dictionaryArg = (Dictionary)arguments[0]!; + var tagArg = (string)arguments[1]!; + + Assert.Equal(2, dictionaryArg.Count); + Assert.True(dictionaryArg.TryGetValue("type", out var typeString)); + Assert.True(byte.TryParse(typeString, out var typeByte)); + Assert.Equal(type, (PushType)typeByte); + + Assert.True(dictionaryArg.TryGetValue("payload", out var payloadString)); + var actualPayloadNode = JsonNode.Parse(payloadString); + + Assert.True(JsonNode.DeepEquals(expectedPayload, actualPayloadNode)); + + Assert.Equal(tag, tagArg); + } + private static NotificationPushNotification ToNotificationPushNotification(Notification notification, NotificationStatus? notificationStatus, Guid? installationId) => new() diff --git a/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs index 3025197c66..e57544b48a 100644 --- a/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs @@ -1,18 +1,27 @@ #nullable enable using System.Text.Json; +using System.Text.Json.Nodes; using Azure.Storage.Queues; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; using Bit.Core.Platform.Push.Internal; using Bit.Core.Settings; using Bit.Core.Test.AutoFixture; using Bit.Core.Test.AutoFixture.CurrentContextFixtures; using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -22,6 +31,17 @@ namespace Bit.Core.Test.Platform.Push.Services; [SutProviderCustomize] public class AzureQueuePushNotificationServiceTests { + private static readonly Guid _deviceId = Guid.Parse("c4730f80-caaa-4772-97bd-5c0d23a2baa3"); + private static readonly string _deviceIdentifier = "test_device_identifier"; + private readonly FakeTimeProvider _fakeTimeProvider; + private readonly Core.Settings.GlobalSettings _globalSettings = new(); + + public AzureQueuePushNotificationServiceTests() + { + _fakeTimeProvider = new(); + _fakeTimeProvider.SetUtcNow(DateTime.UtcNow); + } + [Theory] [BitAutoData] [NotificationCustomize] @@ -112,6 +132,761 @@ public class AzureQueuePushNotificationServiceTests deviceIdentifier.ToString()))); } + [Theory] + [InlineData("6a5bbe1b-cf16-49a6-965f-5c2eac56a531", null)] + [InlineData(null, "b9a3fcb4-2447-45c1-aad2-24de43c88c44")] + public async Task PushSyncCipherCreateAsync_SendsExpectedResponse(string? userId, string? organizationId) + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 1, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!cipher.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!cipher.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + expectedPayload["Payload"]!.AsObject().Remove("CollectionIds"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherCreateAsync(cipher, [collectionId]), + expectedPayload + ); + } + + [Theory] + [InlineData("6a5bbe1b-cf16-49a6-965f-5c2eac56a531", null)] + [InlineData(null, "b9a3fcb4-2447-45c1-aad2-24de43c88c44")] + public async Task PushSyncCipherUpdateAsync_SendsExpectedResponse(string? userId, string? organizationId) + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 0, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!cipher.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!cipher.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + expectedPayload["Payload"]!.AsObject().Remove("CollectionIds"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherUpdateAsync(cipher, [collectionId]), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncCipherDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 2, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherDeleteAsync(cipher), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncFolderCreateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 7, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderCreateAsync(folder), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncFolderUpdateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 8, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderUpdateAsync(folder), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncFolderDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 3, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderDeleteAsync(folder), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncCiphersAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 4, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCiphersAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncVaultAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 5, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncVaultAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrganizationsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 17, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationsAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrgKeysAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 6, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrgKeysAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSettingsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 10, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSettingsAsync(userId), + expectedPayload + ); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext) + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 11, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + if (excludeCurrentContext) + { + expectedPayload["ContextId"] = _deviceIdentifier; + } + + await VerifyNotificationAsync( + async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSendCreateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 12, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendCreateAsync(send), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSendUpdateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 13, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendUpdateAsync(send), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSendDeleteAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 14, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendDeleteAsync(send), + expectedPayload + ); + } + + [Fact] + public async Task PushAuthRequestAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 15, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestAsync(authRequest), + expectedPayload + ); + } + + [Fact] + public async Task PushAuthRequestResponseAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 16, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestResponseAsync(authRequest), + expectedPayload + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 20, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = _globalSettings.Installation.Id, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!global) + { + expectedPayload["Payload"]!.AsObject().Remove("InstallationId"); + } + + if (!notification.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!notification.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationAsync(notification), + expectedPayload + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationStatusAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var notificationStatus = new NotificationStatus + { + ReadDate = DateTime.UtcNow, + DeletedDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 21, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = _globalSettings.Installation.Id, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!global) + { + expectedPayload["Payload"]!.AsObject().Remove("InstallationId"); + } + + if (!notification.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!notification.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationStatusAsync(notification, notificationStatus), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 18, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["Enabled"] = organization.Enabled, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationStatusAsync(organization), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 19, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["LimitCollectionCreation"] = organization.LimitCollectionCreation, + ["LimitCollectionDeletion"] = organization.LimitCollectionDeletion, + ["LimitItemDeletion"] = organization.LimitItemDeletion, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationCollectionManagementSettingsAsync(organization), + expectedPayload + ); + } + + [Fact] + public async Task PushPendingSecurityTasksAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 22, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushPendingSecurityTasksAsync(userId), + expectedPayload + ); + } + + // [Fact] + // public async Task SendPayloadToInstallationAsync_ThrowsNotImplementedException() + // { + // await Assert.ThrowsAsync( + // async () => await sut.SendPayloadToInstallationAsync("installation_id", PushType.AuthRequest, new {}, null) + // ); + // } + + // [Fact] + // public async Task SendPayloadToUserAsync_ThrowsNotImplementedException() + // { + // await Assert.ThrowsAsync( + // async () => await _sut.SendPayloadToUserAsync("user_id", PushType.AuthRequest, new {}, null) + // ); + // } + + // [Fact] + // public async Task SendPayloadToOrganizationAsync_ThrowsNotImplementedException() + // { + // await Assert.ThrowsAsync( + // async () => await _sut.SendPayloadToOrganizationAsync("organization_id", PushType.AuthRequest, new {}, null) + // ); + // } + + private async Task VerifyNotificationAsync(Func test, JsonNode expectedMessage) + { + var queueClient = Substitute.For(); + + var httpContextAccessor = Substitute.For(); + + var httpContext = new DefaultHttpContext(); + + var serviceCollection = new ServiceCollection(); + var currentContext = Substitute.For(); + currentContext.DeviceIdentifier = _deviceIdentifier; + serviceCollection.AddSingleton(currentContext); + + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + httpContextAccessor.HttpContext + .Returns(httpContext); + + var globalSettings = new Core.Settings.GlobalSettings(); + + var sut = new AzureQueuePushNotificationService( + queueClient, + httpContextAccessor, + globalSettings, + NullLogger.Instance, + _fakeTimeProvider + ); + + await test(sut); + + // Hoist equality checker outside the expression so that we + // can more easily place a breakpoint + var checkEquality = (string actual) => + { + var actualNode = JsonNode.Parse(actual); + return JsonNode.DeepEquals(actualNode, expectedMessage); + }; + + await queueClient + .Received(1) + .SendMessageAsync(Arg.Is((actual) => checkEquality(actual))); + } + private static bool MatchMessage(PushType pushType, string message, IEquatable expectedPayloadEquatable, string contextId) { diff --git a/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs index 07f348a5ba..d206d96d44 100644 --- a/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs @@ -1,41 +1,385 @@ -using Bit.Core.Platform.Push; -using Bit.Core.Settings; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Xunit; +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; +using Microsoft.Extensions.Logging.Abstractions; namespace Bit.Core.Test.Platform.Push.Services; -public class NotificationsApiPushNotificationServiceTests +public class NotificationsApiPushNotificationServiceTests : PushTestBase { - private readonly NotificationsApiPushNotificationService _sut; - - private readonly IHttpClientFactory _httpFactory; - private readonly GlobalSettings _globalSettings; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly ILogger _logger; - public NotificationsApiPushNotificationServiceTests() { - _httpFactory = Substitute.For(); - _globalSettings = new GlobalSettings(); - _httpContextAccessor = Substitute.For(); - _logger = Substitute.For>(); + GlobalSettings.BaseServiceUri.InternalNotifications = "https://localhost:7777"; + GlobalSettings.BaseServiceUri.InternalIdentity = "https://localhost:8888"; + } - _sut = new NotificationsApiPushNotificationService( - _httpFactory, - _globalSettings, - _httpContextAccessor, - _logger + protected override string ExpectedClientUrl() => "https://localhost:7777/send"; + + protected override IPushNotificationService CreateService() + { + return new NotificationsApiPushNotificationService( + HttpClientFactory, + GlobalSettings, + HttpContextAccessor, + NullLogger.Instance, + FakeTimeProvider ); } - // Remove this test when we add actual tests. It only proves that - // we've properly constructed the system under test. - [Fact(Skip = "Needs additional work")] - public void ServiceExists() + protected override JsonNode GetPushSyncCipherCreatePayload(Cipher cipher, Guid collectionId) { - Assert.NotNull(_sut); + return new JsonObject + { + ["Type"] = 1, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + protected override JsonNode GetPushSyncCipherUpdatePayload(Cipher cipher, Guid collectionId) + { + return new JsonObject + { + ["Type"] = 0, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + protected override JsonNode GetPushSyncCipherDeletePayload(Cipher cipher) + { + return new JsonObject + { + ["Type"] = 2, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncFolderCreatePayload(Folder folder) + { + return new JsonObject + { + ["Type"] = 7, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncFolderUpdatePayload(Folder folder) + { + return new JsonObject + { + ["Type"] = 8, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncFolderDeletePayload(Folder folder) + { + return new JsonObject + { + ["Type"] = 3, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncCiphersPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 4, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncVaultPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 5, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrganizationsPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 17, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrgKeysPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 6, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncSettingsPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 10, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext) + { + JsonNode? contextId = excludeCurrentContext ? DeviceIdentifier : null; + + return new JsonObject + { + ["Type"] = 11, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = contextId, + }; + } + + protected override JsonNode GetPushSendCreatePayload(Send send) + { + return new JsonObject + { + ["Type"] = 12, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSendUpdatePayload(Send send) + { + return new JsonObject + { + ["Type"] = 13, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSendDeletePayload(Send send) + { + return new JsonObject + { + ["Type"] = 14, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushAuthRequestPayload(AuthRequest authRequest) + { + return new JsonObject + { + ["Type"] = 15, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushAuthRequestResponsePayload(AuthRequest authRequest) + { + return new JsonObject + { + ["Type"] = 16, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushNotificationResponsePayload(Notification notification, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["Type"] = 20, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = null, + ["DeletedDate"] = null, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushNotificationStatusResponsePayload(Notification notification, NotificationStatus notificationStatus, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["Type"] = 21, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = installationId, + ["TaskId"] = notification.TaskId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncOrganizationStatusResponsePayload(Organization organization) + { + return new JsonObject + { + ["Type"] = 18, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["Enabled"] = organization.Enabled, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(Organization organization) + { + return new JsonObject + { + ["Type"] = 19, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["LimitCollectionCreation"] = organization.LimitCollectionCreation, + ["LimitCollectionDeletion"] = organization.LimitCollectionDeletion, + ["LimitItemDeletion"] = organization.LimitItemDeletion, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushPendingSecurityTasksResponsePayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 22, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; } } diff --git a/test/Core.Test/Platform/Push/Services/PushTestBase.cs b/test/Core.Test/Platform/Push/Services/PushTestBase.cs new file mode 100644 index 0000000000..111df7ca26 --- /dev/null +++ b/test/Core.Test/Platform/Push/Services/PushTestBase.cs @@ -0,0 +1,498 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Settings; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using RichardSzalay.MockHttp; +using Xunit; + +public abstract class PushTestBase +{ + protected static readonly string DeviceIdentifier = "test_device_identifier"; + + protected readonly MockHttpMessageHandler MockClient = new(); + protected readonly MockHttpMessageHandler MockIdentityClient = new(); + + protected readonly IHttpClientFactory HttpClientFactory; + protected readonly GlobalSettings GlobalSettings; + protected readonly IHttpContextAccessor HttpContextAccessor; + protected readonly FakeTimeProvider FakeTimeProvider; + + public PushTestBase() + { + HttpClientFactory = Substitute.For(); + + // Mock HttpClient + HttpClientFactory.CreateClient("client") + .Returns(new HttpClient(MockClient)); + + HttpClientFactory.CreateClient("identity") + .Returns(new HttpClient(MockIdentityClient)); + + GlobalSettings = new GlobalSettings(); + HttpContextAccessor = Substitute.For(); + + FakeTimeProvider = new FakeTimeProvider(); + + FakeTimeProvider.SetUtcNow(DateTimeOffset.UtcNow); + } + + protected abstract IPushNotificationService CreateService(); + + protected abstract string ExpectedClientUrl(); + + protected abstract JsonNode GetPushSyncCipherCreatePayload(Cipher cipher, Guid collectionId); + protected abstract JsonNode GetPushSyncCipherUpdatePayload(Cipher cipher, Guid collectionId); + protected abstract JsonNode GetPushSyncCipherDeletePayload(Cipher cipher); + protected abstract JsonNode GetPushSyncFolderCreatePayload(Folder folder); + protected abstract JsonNode GetPushSyncFolderUpdatePayload(Folder folder); + protected abstract JsonNode GetPushSyncFolderDeletePayload(Folder folder); + protected abstract JsonNode GetPushSyncCiphersPayload(Guid userId); + protected abstract JsonNode GetPushSyncVaultPayload(Guid userId); + protected abstract JsonNode GetPushSyncOrganizationsPayload(Guid userId); + protected abstract JsonNode GetPushSyncOrgKeysPayload(Guid userId); + protected abstract JsonNode GetPushSyncSettingsPayload(Guid userId); + protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext); + protected abstract JsonNode GetPushSendCreatePayload(Send send); + protected abstract JsonNode GetPushSendUpdatePayload(Send send); + protected abstract JsonNode GetPushSendDeletePayload(Send send); + protected abstract JsonNode GetPushAuthRequestPayload(AuthRequest authRequest); + protected abstract JsonNode GetPushAuthRequestResponsePayload(AuthRequest authRequest); + protected abstract JsonNode GetPushNotificationResponsePayload(Notification notification, Guid? userId, Guid? organizationId); + protected abstract JsonNode GetPushNotificationStatusResponsePayload(Notification notification, NotificationStatus notificationStatus, Guid? userId, Guid? organizationId); + protected abstract JsonNode GetPushSyncOrganizationStatusResponsePayload(Organization organization); + protected abstract JsonNode GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(Organization organization); + protected abstract JsonNode GetPushPendingSecurityTasksResponsePayload(Guid userId); + + [Fact] + public async Task PushSyncCipherCreateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherCreateAsync(cipher, [collectionId]), + GetPushSyncCipherCreatePayload(cipher, collectionId) + ); + } + + [Fact] + public async Task PushSyncCipherUpdateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherUpdateAsync(cipher, [collectionId]), + GetPushSyncCipherUpdatePayload(cipher, collectionId) + ); + } + + [Fact] + public async Task PushSyncCipherDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherDeleteAsync(cipher), + GetPushSyncCipherDeletePayload(cipher) + ); + } + + [Fact] + public async Task PushSyncFolderCreateAsync_SendsExpectedResponse() + { + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderCreateAsync(folder), + GetPushSyncFolderCreatePayload(folder) + ); + } + + [Fact] + public async Task PushSyncFolderUpdateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderUpdateAsync(folder), + GetPushSyncFolderUpdatePayload(folder) + ); + } + + [Fact] + public async Task PushSyncFolderDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderDeleteAsync(folder), + GetPushSyncFolderDeletePayload(folder) + ); + } + + [Fact] + public async Task PushSyncCiphersAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCiphersAsync(userId), + GetPushSyncCiphersPayload(userId) + ); + } + + [Fact] + public async Task PushSyncVaultAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncVaultAsync(userId), + GetPushSyncVaultPayload(userId) + ); + } + + [Fact] + public async Task PushSyncOrganizationsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationsAsync(userId), + GetPushSyncOrganizationsPayload(userId) + ); + } + + [Fact] + public async Task PushSyncOrgKeysAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrgKeysAsync(userId), + GetPushSyncOrgKeysPayload(userId) + ); + } + + [Fact] + public async Task PushSyncSettingsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSettingsAsync(userId), + GetPushSyncSettingsPayload(userId) + ); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext) + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), + GetPushLogOutPayload(userId, excludeCurrentContext) + ); + } + + [Fact] + public async Task PushSyncSendCreateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendCreateAsync(send), + GetPushSendCreatePayload(send) + ); + } + + [Fact] + public async Task PushSyncSendUpdateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendUpdateAsync(send), + GetPushSendUpdatePayload(send) + ); + } + + [Fact] + public async Task PushSyncSendDeleteAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendDeleteAsync(send), + GetPushSendDeletePayload(send) + ); + } + + [Fact] + public async Task PushAuthRequestAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestAsync(authRequest), + GetPushAuthRequestPayload(authRequest) + ); + } + + [Fact] + public async Task PushAuthRequestResponseAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestResponseAsync(authRequest), + GetPushAuthRequestResponsePayload(authRequest) + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + TaskId = Guid.NewGuid(), + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationAsync(notification), + GetPushNotificationResponsePayload(notification, notification.UserId, notification.OrganizationId) + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationStatusAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + TaskId = Guid.NewGuid(), + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var notificationStatus = new NotificationStatus + { + ReadDate = DateTime.UtcNow, + DeletedDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationStatusAsync(notification, notificationStatus), + GetPushNotificationStatusResponsePayload(notification, notificationStatus, notification.UserId, notification.OrganizationId) + ); + } + + [Fact] + public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationStatusAsync(organization), + GetPushSyncOrganizationStatusResponsePayload(organization) + ); + } + + [Fact] + public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationCollectionManagementSettingsAsync(organization), + GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(organization) + ); + } + + [Fact] + public async Task PushPendingSecurityTasksAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushPendingSecurityTasksAsync(userId), + GetPushPendingSecurityTasksResponsePayload(userId) + ); + } + + private async Task VerifyNotificationAsync( + Func test, + JsonNode expectedRequestBody + ) + { + var httpContext = new DefaultHttpContext(); + + var serviceCollection = new ServiceCollection(); + var currentContext = Substitute.For(); + currentContext.DeviceIdentifier = DeviceIdentifier; + serviceCollection.AddSingleton(currentContext); + + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + HttpContextAccessor.HttpContext + .Returns(httpContext); + + var connectTokenRequest = MockIdentityClient + .Expect(HttpMethod.Post, "https://localhost:8888/connect/token") + .Respond(HttpStatusCode.OK, JsonContent.Create(new + { + access_token = CreateAccessToken(DateTime.UtcNow.AddDays(1)), + })); + + JsonNode actualNode = null; + + var clientRequest = MockClient + .Expect(HttpMethod.Post, ExpectedClientUrl()) + .With(request => + { + if (request.Content is not JsonContent jsonContent) + { + return false; + } + + // TODO: What options? + var actualString = JsonSerializer.Serialize(jsonContent.Value); + actualNode = JsonNode.Parse(actualString); + + return JsonNode.DeepEquals(actualNode, expectedRequestBody); + }) + .Respond(HttpStatusCode.OK); + + await test(CreateService()); + + Assert.NotNull(actualNode); + + Assert.Equal(expectedRequestBody, actualNode, EqualityComparer.Create(JsonNode.DeepEquals)); + + Assert.Equal(1, MockClient.GetMatchCount(clientRequest)); + } + + protected static string CreateAccessToken(DateTime expirationTime) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var token = new JwtSecurityToken(expires: expirationTime); + return tokenHandler.WriteToken(token); + } +} diff --git a/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs index 9ae79f7142..faa6b5dfa7 100644 --- a/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs @@ -1,45 +1,541 @@ -using Bit.Core.Platform.Push.Internal; +#nullable enable + +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.Push.Services; -public class RelayPushNotificationServiceTests +public class RelayPushNotificationServiceTests : PushTestBase { - private readonly RelayPushNotificationService _sut; - - private readonly IHttpClientFactory _httpFactory; + private static readonly Guid _deviceId = Guid.Parse("c4730f80-caaa-4772-97bd-5c0d23a2baa3"); private readonly IDeviceRepository _deviceRepository; - private readonly GlobalSettings _globalSettings; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly ILogger _logger; public RelayPushNotificationServiceTests() { - _httpFactory = Substitute.For(); _deviceRepository = Substitute.For(); - _globalSettings = new GlobalSettings(); - _httpContextAccessor = Substitute.For(); - _logger = Substitute.For>(); - _sut = new RelayPushNotificationService( - _httpFactory, + _deviceRepository.GetByIdentifierAsync(DeviceIdentifier) + .Returns(new Device + { + Id = _deviceId, + }); + + GlobalSettings.PushRelayBaseUri = "https://localhost:7777"; + GlobalSettings.Installation.Id = Guid.Parse("478c608a-99fd-452a-94f0-af271654e6ee"); + GlobalSettings.Installation.IdentityUri = "https://localhost:8888"; + } + + protected override RelayPushNotificationService CreateService() + { + return new RelayPushNotificationService( + HttpClientFactory, _deviceRepository, - _globalSettings, - _httpContextAccessor, - _logger + GlobalSettings, + HttpContextAccessor, + NullLogger.Instance, + FakeTimeProvider ); } - // Remove this test when we add actual tests. It only proves that - // we've properly constructed the system under test. - [Fact(Skip = "Needs additional work")] - public void ServiceExists() + protected override string ExpectedClientUrl() => "https://localhost:7777/push/send"; + + [Fact] + public async Task SendPayloadToInstallationAsync_ThrowsNotImplementedException() { - Assert.NotNull(_sut); + var sut = CreateService(); + await Assert.ThrowsAsync( + async () => await sut.SendPayloadToInstallationAsync("installation_id", PushType.AuthRequest, new { }, null) + ); + } + + [Fact] + public async Task SendPayloadToUserAsync_ThrowsNotImplementedException() + { + var sut = CreateService(); + await Assert.ThrowsAsync( + async () => await sut.SendPayloadToUserAsync("user_id", PushType.AuthRequest, new { }, null) + ); + } + + [Fact] + public async Task SendPayloadToOrganizationAsync_ThrowsNotImplementedException() + { + var sut = CreateService(); + await Assert.ThrowsAsync( + async () => await sut.SendPayloadToOrganizationAsync("organization_id", PushType.AuthRequest, new { }, null) + ); + } + + protected override JsonNode GetPushSyncCipherCreatePayload(Cipher cipher, Guid collectionIds) + { + return new JsonObject + { + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 1, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + // Currently CollectionIds are not passed along from the method signature + // to the request body. + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncCipherUpdatePayload(Cipher cipher, Guid collectionIds) + { + return new JsonObject + { + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 0, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + // Currently CollectionIds are not passed along from the method signature + // to the request body. + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncCipherDeletePayload(Cipher cipher) + { + return new JsonObject + { + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 2, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncFolderCreatePayload(Folder folder) + { + return new JsonObject + { + ["UserId"] = folder.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 7, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncFolderUpdatePayload(Folder folder) + { + return new JsonObject + { + ["UserId"] = folder.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 8, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncFolderDeletePayload(Folder folder) + { + return new JsonObject + { + ["UserId"] = folder.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 3, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncCiphersPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 4, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncVaultPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 5, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrganizationsPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 17, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrgKeysPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 6, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncSettingsPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 10, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext) + { + JsonNode? identifier = excludeCurrentContext ? DeviceIdentifier : null; + + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = identifier, + ["Type"] = 11, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSendCreatePayload(Send send) + { + return new JsonObject + { + ["UserId"] = send.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 12, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSendUpdatePayload(Send send) + { + return new JsonObject + { + ["UserId"] = send.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 13, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSendDeletePayload(Send send) + { + return new JsonObject + { + ["UserId"] = send.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 14, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushAuthRequestPayload(AuthRequest authRequest) + { + return new JsonObject + { + ["UserId"] = authRequest.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 15, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushAuthRequestResponsePayload(AuthRequest authRequest) + { + return new JsonObject + { + ["UserId"] = authRequest.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 16, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushNotificationResponsePayload(Notification notification, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 20, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = userId, + ["OrganizationId"] = organizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = null, + ["DeletedDate"] = null, + }, + ["ClientType"] = 0, + ["InstallationId"] = installationId?.DeepClone(), + }; + } + protected override JsonNode GetPushNotificationStatusResponsePayload(Notification notification, NotificationStatus notificationStatus, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 21, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = installationId, + ["TaskId"] = notification.TaskId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }, + ["ClientType"] = 0, + ["InstallationId"] = installationId?.DeepClone(), + }; + } + protected override JsonNode GetPushSyncOrganizationStatusResponsePayload(Organization organization) + { + return new JsonObject + { + ["UserId"] = null, + ["OrganizationId"] = organization.Id, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 18, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["Enabled"] = organization.Enabled, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(Organization organization) + { + return new JsonObject + { + ["UserId"] = null, + ["OrganizationId"] = organization.Id, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 19, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["LimitCollectionCreation"] = organization.LimitCollectionCreation, + ["LimitCollectionDeletion"] = organization.LimitCollectionDeletion, + ["LimitItemDeletion"] = organization.LimitItemDeletion, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushPendingSecurityTasksResponsePayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 22, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; } }