mirror of
https://github.com/bitwarden/server.git
synced 2025-06-18 01:53:49 -05:00

* More tests * More tests * Add non-guid tests * Introduce slimmer services * Implement IPushEngine on services * Implement IPushEngine * Fix tests * Format * Switch to `Guid` on `PushSendRequestModel` * Remove TODOs
450 lines
17 KiB
C#
450 lines
17 KiB
C#
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.NotificationHub;
|
|
using Bit.Core.Platform.Installations;
|
|
using Bit.Core.Repositories;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
using static Bit.Core.Settings.GlobalSettings;
|
|
|
|
namespace Bit.Api.IntegrationTest.Platform.Controllers;
|
|
|
|
public class PushControllerTests
|
|
{
|
|
private static readonly Guid _userId = Guid.NewGuid();
|
|
private static readonly Guid _organizationId = Guid.NewGuid();
|
|
private static readonly Guid _deviceId = Guid.NewGuid();
|
|
|
|
public static IEnumerable<object[]> SendData()
|
|
{
|
|
static object[] Typed<T>(PushSendRequestModel<T> pushSendRequestModel, string expectedHubTagExpression, bool expectHubCall = true)
|
|
{
|
|
return [pushSendRequestModel, expectedHubTagExpression, expectHubCall];
|
|
}
|
|
|
|
static object[] UserTyped(PushType pushType)
|
|
{
|
|
return Typed(new PushSendRequestModel<UserPushNotification>
|
|
{
|
|
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<SyncCipherPushNotification>
|
|
{
|
|
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<SyncCipherPushNotification>
|
|
{
|
|
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<SyncCipherPushNotification>
|
|
{
|
|
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<SyncCipherPushNotification>
|
|
{
|
|
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<SyncCipherPushNotification>
|
|
{
|
|
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<SyncCipherPushNotification>
|
|
{
|
|
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<SyncFolderPushNotification>
|
|
{
|
|
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<SyncFolderPushNotification>
|
|
{
|
|
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<SyncFolderPushNotification>
|
|
{
|
|
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.PendingSecurityTasks);
|
|
|
|
yield return Typed(new PushSendRequestModel<AuthRequestPushNotification>
|
|
{
|
|
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<AuthRequestPushNotification>
|
|
{
|
|
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<NotificationPushNotification>
|
|
{
|
|
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<NotificationPushNotification>
|
|
{
|
|
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<NotificationPushNotification>
|
|
{
|
|
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<NotificationPushNotification>
|
|
{
|
|
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<T>(PushSendRequestModel<T> pushSendRequestModel, string expectedHubTagExpression, bool expectHubCall)
|
|
{
|
|
var (apiFactory, httpClient, installation, queueClient, notificationHubProxy) = await SetupTest();
|
|
|
|
// 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<string>());
|
|
|
|
// Check that this notification was sent through hubs the expected number of times
|
|
await notificationHubProxy
|
|
.Received(expectHubCall ? 1 : 0)
|
|
.SendTemplateNotificationAsync(
|
|
Arg.Any<Dictionary<string, string>>(),
|
|
Arg.Is(expectedHubTagExpression.Replace("%installation%", installation.Id.ToString()))
|
|
);
|
|
|
|
// TODO: Expect on the dictionary more?
|
|
|
|
// 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<IInstallationDeviceRepository>()
|
|
.Received(1)
|
|
.UpsertAsync(Arg.Is<InstallationDeviceEntity>(
|
|
ide => ide.PartitionKey == installation.Id.ToString() && ide.RowKey == pushSendRequestModel.DeviceId.ToString()
|
|
));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_InstallationNotification_NotAuthenticatedInstallation_Fails()
|
|
{
|
|
var (_, httpClient, _, _, _) = await SetupTest();
|
|
|
|
var response = await httpClient.PostAsJsonAsync("push/send", new PushSendRequestModel<object>
|
|
{
|
|
Type = PushType.NotificationStatus,
|
|
InstallationId = Guid.NewGuid(),
|
|
Payload = new { }
|
|
});
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<JsonNode>();
|
|
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<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_InstallationNotification_Works()
|
|
{
|
|
var (apiFactory, httpClient, installation, _, notificationHubProxy) = await SetupTest();
|
|
|
|
var deviceId = Guid.NewGuid();
|
|
|
|
var response = await httpClient.PostAsJsonAsync("push/send", new PushSendRequestModel<object>
|
|
{
|
|
Type = PushType.NotificationStatus,
|
|
InstallationId = installation.Id,
|
|
Payload = new { },
|
|
DeviceId = deviceId,
|
|
ClientType = ClientType.Web,
|
|
});
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
await notificationHubProxy
|
|
.Received(1)
|
|
.SendTemplateNotificationAsync(
|
|
Arg.Any<Dictionary<string, string>>(),
|
|
Arg.Is($"(template:payload && installationId:{installation.Id} && clientType:Web)")
|
|
);
|
|
|
|
await apiFactory.GetService<IInstallationDeviceRepository>()
|
|
.Received(1)
|
|
.UpsertAsync(Arg.Is<InstallationDeviceEntity>(
|
|
ide => ide.PartitionKey == installation.Id.ToString() && ide.RowKey == deviceId.ToString()
|
|
));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_NoOrganizationNoInstallationNoUser_FailsModelValidation()
|
|
{
|
|
var (_, client, _, _, _) = await SetupTest();
|
|
|
|
var response = await client.PostAsJsonAsync("push/send", new PushSendRequestModel<object>
|
|
{
|
|
Type = PushType.AuthRequest,
|
|
Payload = new { },
|
|
});
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var body = await response.Content.ReadFromJsonAsync<JsonNode>();
|
|
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<string>());
|
|
}
|
|
|
|
private static async Task<(ApiApplicationFactory Factory, HttpClient AuthedClient, Installation Installation, QueueClient MockedQueue, INotificationHubProxy MockedHub)> SetupTest()
|
|
{
|
|
// Arrange
|
|
var apiFactory = new ApiApplicationFactory();
|
|
|
|
var queueClient = Substitute.For<QueueClient>();
|
|
|
|
// 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<INotificationHubProxy>();
|
|
|
|
apiFactory.SubstituteService<INotificationHubPool>(s =>
|
|
{
|
|
s.AllClients
|
|
.Returns(notificationHubProxy);
|
|
});
|
|
|
|
apiFactory.SubstituteService<IInstallationDeviceRepository>(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()
|
|
);
|
|
apiFactory.UpdateConfiguration(
|
|
$"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:RegistrationEndDate",
|
|
notificationHubSettings.RegistrationEndDate?.ToString()
|
|
);
|
|
index++;
|
|
}
|
|
|
|
AddHub(new NotificationHubSettings
|
|
{
|
|
ConnectionString = "some_value",
|
|
RegistrationStartDate = DateTime.UtcNow.AddDays(-2),
|
|
});
|
|
|
|
var httpClient = apiFactory.CreateClient();
|
|
|
|
// Add installation into database
|
|
var installationRepository = apiFactory.GetService<IInstallationRepository>();
|
|
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<string, string>
|
|
{
|
|
{ "grant_type", "client_credentials" },
|
|
{ "scope", "api.push" },
|
|
{ "client_id", $"installation.{installation.Id}" },
|
|
{ "client_secret", installation.Key },
|
|
}));
|
|
|
|
connectTokenResponse.EnsureSuccessStatusCode();
|
|
|
|
var connectTokenResponseModel = await connectTokenResponse.Content.ReadFromJsonAsync<JsonNode>();
|
|
|
|
// Setup authentication
|
|
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
|
connectTokenResponseModel["token_type"].GetValue<string>(),
|
|
connectTokenResponseModel["access_token"].GetValue<string>()
|
|
);
|
|
|
|
return (apiFactory, httpClient, installation, queueClient, notificationHubProxy);
|
|
}
|
|
}
|