using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Nodes; using Azure.Storage.Queues; using Bit.Api.IntegrationTest.Factories; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Data; using Bit.Core.Platform.Installations; using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using NSubstitute; using Xunit; using static Bit.Core.Settings.GlobalSettings; namespace Bit.Api.IntegrationTest.Platform.Controllers; public class PushControllerFixture : IAsyncLifetime { private ApiApplicationFactory _factory; private HttpClient _authedClient; private Installation _installation; private QueueClient _mockedQueue; private INotificationHubProxy _mockedHub; public async Task InitializeAsync() { // Arrange var apiFactory = new ApiApplicationFactory(); var queueClient = Substitute.For(); // Substitute the underlying queue messages will go to. apiFactory.ConfigureServices(services => { var queueClientService = services.FirstOrDefault( sd => sd.ServiceKey == (object)"notifications" && sd.ServiceType == typeof(QueueClient) ) ?? throw new InvalidOperationException("Expected service was not found."); services.Remove(queueClientService); services.AddKeyedSingleton("notifications", queueClient); }); var notificationHubProxy = Substitute.For(); apiFactory.SubstituteService(s => { s.AllClients .Returns(notificationHubProxy); }); apiFactory.SubstituteService(s => { }); // Setup as cloud with NotificationHub setup and Azure Queue apiFactory.UpdateConfiguration("GlobalSettings:Notifications:ConnectionString", "any_value"); // Configure hubs var index = 0; void AddHub(NotificationHubSettings notificationHubSettings) { apiFactory.UpdateConfiguration( $"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:ConnectionString", notificationHubSettings.ConnectionString ); apiFactory.UpdateConfiguration( $"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:HubName", notificationHubSettings.HubName ); apiFactory.UpdateConfiguration( $"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:RegistrationStartDate", notificationHubSettings.RegistrationStartDate?.ToString(CultureInfo.InvariantCulture) ); apiFactory.UpdateConfiguration( $"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:RegistrationEndDate", notificationHubSettings.RegistrationEndDate?.ToString(CultureInfo.InvariantCulture) ); index++; } AddHub(new NotificationHubSettings { ConnectionString = "some_value", RegistrationStartDate = DateTime.UtcNow.AddDays(-2), }); var httpClient = apiFactory.CreateClient(); // Add installation into database var installationRepository = apiFactory.GetService(); var installation = await installationRepository.CreateAsync(new Installation { Key = "my_test_key", Email = "test@example.com", Enabled = true, }); var identityClient = apiFactory.Identity.CreateDefaultClient(); var connectTokenResponse = await identityClient.PostAsync("connect/token", new FormUrlEncodedContent(new Dictionary { { "grant_type", "client_credentials" }, { "scope", "api.push" }, { "client_id", $"installation.{installation.Id}" }, { "client_secret", installation.Key }, })); connectTokenResponse.EnsureSuccessStatusCode(); var connectTokenResponseModel = await connectTokenResponse.Content.ReadFromJsonAsync(); // Setup authentication httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( connectTokenResponseModel["token_type"].GetValue(), connectTokenResponseModel["access_token"].GetValue() ); _factory = apiFactory; _authedClient = httpClient; _installation = installation; _mockedQueue = queueClient; _mockedHub = notificationHubProxy; } public async Task DisposeAsync() { await _factory.DisposeAsync(); _authedClient.Dispose(); } public void Deconstruct( out ApiApplicationFactory apiApplicationFactory, out HttpClient authedClient, out Installation installation, out QueueClient mockedQueue, out INotificationHubProxy mockedHub) { apiApplicationFactory = _factory; authedClient = _authedClient; installation = _installation; mockedQueue = _mockedQueue; mockedHub = _mockedHub; } } public class PushControllerTests : IClassFixture { private static readonly Guid _userId = Guid.NewGuid(); private static readonly Guid _organizationId = Guid.NewGuid(); private static readonly Guid _deviceId = Guid.NewGuid(); private readonly PushControllerFixture _fixture; public PushControllerTests(PushControllerFixture fixture) { _fixture = fixture; } public static IEnumerable SendData() { static object[] Typed(PushSendRequestModel pushSendRequestModel, string expectedHubTagExpression, bool expectHubCall = true) { return [pushSendRequestModel, expectedHubTagExpression, expectHubCall]; } static object[] UserTyped(PushType pushType) { return Typed(new PushSendRequestModel { Type = pushType, UserId = _userId, DeviceId = _deviceId, Payload = new UserPushNotification { Date = DateTime.UtcNow, UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); } // User cipher yield return Typed(new PushSendRequestModel { Type = PushType.SyncCipherUpdate, UserId = _userId, DeviceId = _deviceId, Payload = new SyncCipherPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); // Organization cipher, an org cipher would not naturally be synced from our // code but it is technically possible to be submitted to the endpoint. yield return Typed(new PushSendRequestModel { Type = PushType.SyncCipherUpdate, OrganizationId = _organizationId, DeviceId = _deviceId, Payload = new SyncCipherPushNotification { Id = Guid.NewGuid(), OrganizationId = _organizationId, }, }, $"(template:payload && organizationId:%installation%_{_organizationId})"); yield return Typed(new PushSendRequestModel { Type = PushType.SyncCipherCreate, UserId = _userId, DeviceId = _deviceId, Payload = new SyncCipherPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); // Organization cipher, an org cipher would not naturally be synced from our // code but it is technically possible to be submitted to the endpoint. yield return Typed(new PushSendRequestModel { Type = PushType.SyncCipherCreate, OrganizationId = _organizationId, DeviceId = _deviceId, Payload = new SyncCipherPushNotification { Id = Guid.NewGuid(), OrganizationId = _organizationId, }, }, $"(template:payload && organizationId:%installation%_{_organizationId})"); yield return Typed(new PushSendRequestModel { Type = PushType.SyncCipherDelete, UserId = _userId, DeviceId = _deviceId, Payload = new SyncCipherPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); // Organization cipher, an org cipher would not naturally be synced from our // code but it is technically possible to be submitted to the endpoint. yield return Typed(new PushSendRequestModel { Type = PushType.SyncCipherDelete, OrganizationId = _organizationId, DeviceId = _deviceId, Payload = new SyncCipherPushNotification { Id = Guid.NewGuid(), OrganizationId = _organizationId, }, }, $"(template:payload && organizationId:%installation%_{_organizationId})"); yield return Typed(new PushSendRequestModel { Type = PushType.SyncFolderDelete, UserId = _userId, DeviceId = _deviceId, Payload = new SyncFolderPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); yield return Typed(new PushSendRequestModel { Type = PushType.SyncFolderCreate, UserId = _userId, DeviceId = _deviceId, Payload = new SyncFolderPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); yield return Typed(new PushSendRequestModel { Type = PushType.SyncFolderCreate, UserId = _userId, DeviceId = _deviceId, Payload = new SyncFolderPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); yield return UserTyped(PushType.SyncCiphers); yield return UserTyped(PushType.SyncVault); yield return UserTyped(PushType.SyncOrganizations); yield return UserTyped(PushType.SyncOrgKeys); yield return UserTyped(PushType.SyncSettings); yield return UserTyped(PushType.LogOut); yield return UserTyped(PushType.RefreshSecurityTasks); yield return Typed(new PushSendRequestModel { Type = PushType.AuthRequest, UserId = _userId, DeviceId = _deviceId, Payload = new AuthRequestPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); yield return Typed(new PushSendRequestModel { Type = PushType.AuthRequestResponse, UserId = _userId, DeviceId = _deviceId, Payload = new AuthRequestPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); yield return Typed(new PushSendRequestModel { Type = PushType.Notification, UserId = _userId, DeviceId = _deviceId, Payload = new NotificationPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload_userId:%installation%_{_userId})"); yield return Typed(new PushSendRequestModel { Type = PushType.Notification, UserId = _userId, DeviceId = _deviceId, ClientType = ClientType.All, Payload = new NotificationPushNotification { Id = Guid.NewGuid(), Global = true, }, }, $"(template:payload_userId:%installation%_{_userId})"); yield return Typed(new PushSendRequestModel { Type = PushType.NotificationStatus, OrganizationId = _organizationId, DeviceId = _deviceId, Payload = new NotificationPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload && organizationId:%installation%_{_organizationId})"); yield return Typed(new PushSendRequestModel { Type = PushType.NotificationStatus, OrganizationId = _organizationId, DeviceId = _deviceId, Payload = new NotificationPushNotification { Id = Guid.NewGuid(), UserId = _userId, }, }, $"(template:payload && organizationId:%installation%_{_organizationId})"); } [Theory] [MemberData(nameof(SendData))] public async Task Send_Works(PushSendRequestModel pushSendRequestModel, string expectedHubTagExpression, bool expectHubCall) { var (apiFactory, httpClient, installation, queueClient, notificationHubProxy) = _fixture; // Act var pushSendResponse = await httpClient.PostAsJsonAsync("push/send", pushSendRequestModel); // Assert pushSendResponse.EnsureSuccessStatusCode(); // Relayed notifications, the ones coming to this endpoint should // not make their way into our Azure Queue and instead should only be sent to Azure Notifications // hub. await queueClient .Received(0) .SendMessageAsync(Arg.Any()); if (expectHubCall) { // Check that this notification was sent through hubs the expected number of times await notificationHubProxy .Received() .SendTemplateNotificationAsync( Arg.Is>(props => props["type"] == ((byte)pushSendRequestModel.Type).ToString(CultureInfo.InvariantCulture) && props.ContainsKey("payload") ), Arg.Is(expectedHubTagExpression.Replace("%installation%", installation.Id.ToString())) ); } // Notifications being relayed from SH should have the device id // tracked so that we can later send the notification to that device. await apiFactory.GetService() .Received() .UpsertAsync(Arg.Is( ide => ide.PartitionKey == installation.Id.ToString() && ide.RowKey == pushSendRequestModel.DeviceId.ToString() )); } [Fact] public async Task Send_InstallationNotification_NotAuthenticatedInstallation_Fails() { var (_, httpClient, _, _, _) = _fixture; var response = await httpClient.PostAsJsonAsync("push/send", new PushSendRequestModel { Type = PushType.NotificationStatus, InstallationId = Guid.NewGuid(), Payload = new { } }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(JsonValueKind.Object, body.GetValueKind()); Assert.True(body.AsObject().TryGetPropertyValue("message", out var message)); Assert.Equal(JsonValueKind.String, message.GetValueKind()); Assert.Equal("InstallationId does not match current context.", message.GetValue()); } [Fact] public async Task Send_InstallationNotification_Works() { var (apiFactory, httpClient, installation, _, notificationHubProxy) = _fixture; var deviceId = Guid.NewGuid(); var response = await httpClient.PostAsJsonAsync("push/send", new PushSendRequestModel { Type = PushType.NotificationStatus, InstallationId = installation.Id, Payload = new { }, DeviceId = deviceId, ClientType = ClientType.Web, }); response.EnsureSuccessStatusCode(); await notificationHubProxy .Received(1) .SendTemplateNotificationAsync( Arg.Is>( props => props["type"] == ((byte)PushType.NotificationStatus).ToString(CultureInfo.InvariantCulture) && props.ContainsKey("payload") ), Arg.Is($"(template:payload && installationId:{installation.Id} && clientType:Web)") ); await apiFactory.GetService() .Received(1) .UpsertAsync(Arg.Is( ide => ide.PartitionKey == installation.Id.ToString() && ide.RowKey == deviceId.ToString() )); } [Fact] public async Task Send_NoOrganizationNoInstallationNoUser_FailsModelValidation() { var (_, client, _, _, _) = _fixture; var response = await client.PostAsJsonAsync("push/send", new PushSendRequestModel { Type = PushType.AuthRequest, Payload = new { }, }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(JsonValueKind.Object, body.GetValueKind()); Assert.True(body.AsObject().TryGetPropertyValue("message", out var message)); Assert.Equal(JsonValueKind.String, message.GetValueKind()); Assert.Equal("The model state is invalid.", message.GetValue()); } }