From f8e89f174776a1212d48f994feb8f98f0dbe09fa Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Wed, 9 Apr 2025 07:53:43 +0200 Subject: [PATCH 01/17] [PM-18170] Remove PM-15814-alert-owners-of-reseller-managed-orgs (#5412) --- .../Implementations/SubscriptionUpdatedHandler.cs | 10 ---------- src/Core/Constants.cs | 1 - .../Services/SubscriptionUpdatedHandlerTests.cs | 7 ------- 3 files changed, 18 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index d2ca7fa9bf..fe5021c827 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,6 +1,5 @@ using Bit.Billing.Constants; using Bit.Billing.Jobs; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -24,7 +23,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly ISchedulerFactory _schedulerFactory; - private readonly IFeatureService _featureService; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; @@ -39,7 +37,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IPushNotificationService pushNotificationService, IOrganizationRepository organizationRepository, ISchedulerFactory schedulerFactory, - IFeatureService featureService, IOrganizationEnableCommand organizationEnableCommand, IOrganizationDisableCommand organizationDisableCommand, IPricingClient pricingClient) @@ -53,7 +50,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; _schedulerFactory = schedulerFactory; - _featureService = featureService; _organizationEnableCommand = organizationEnableCommand; _organizationDisableCommand = organizationDisableCommand; _pricingClient = pricingClient; @@ -227,12 +223,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId) { - var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert); - if (!isResellerManagedOrgAlertEnabled) - { - return; - } - var scheduler = await _schedulerFactory.GetScheduler(); var job = JobBuilder.Create() diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0eaa6cd85f..328be72251 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -141,7 +141,6 @@ public static class FeatureFlagKeys /* Billing Team */ public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string TrialPayment = "PM-8163-trial-payment"; - public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; public const string UsePricingService = "use-pricing-service"; public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index a6ac7e9512..9c58bbdbf7 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1,7 +1,6 @@ using Bit.Billing.Constants; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Enums; @@ -31,7 +30,6 @@ public class SubscriptionUpdatedHandlerTests private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly ISchedulerFactory _schedulerFactory; - private readonly IFeatureService _featureService; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; @@ -49,7 +47,6 @@ public class SubscriptionUpdatedHandlerTests _pushNotificationService = Substitute.For(); _organizationRepository = Substitute.For(); _schedulerFactory = Substitute.For(); - _featureService = Substitute.For(); _organizationEnableCommand = Substitute.For(); _organizationDisableCommand = Substitute.For(); _pricingClient = Substitute.For(); @@ -67,7 +64,6 @@ public class SubscriptionUpdatedHandlerTests _pushNotificationService, _organizationRepository, _schedulerFactory, - _featureService, _organizationEnableCommand, _organizationDisableCommand, _pricingClient); @@ -97,9 +93,6 @@ public class SubscriptionUpdatedHandlerTests _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(organizationId, null, null)); - _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert) - .Returns(true); - // Act await _sut.HandleAsync(parsedEvent); From 19b54311779aea83b7c46158cc41e45915a0c198 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Wed, 9 Apr 2025 09:14:57 +0200 Subject: [PATCH 02/17] [PM-18040] Add new feature flag (#5498) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 328be72251..42d5d06450 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -176,6 +176,7 @@ public static class FeatureFlagKeys public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string WebPush = "web-push"; public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; + public const string IpcChannelFramework = "ipc-channel-framework"; /* Tools Team */ public const string ItemShare = "item-share"; From 0a4f97b50ebf48d0901b6b59c596cfb4e832d72c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 9 Apr 2025 14:26:06 +0200 Subject: [PATCH 03/17] [PM-19883] Add untrust devices endpoint (#5619) * Add untrust devices endpoint * Fix tests * Update src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> * Fix whitespace --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> --- .../Models/Request/UntrustDevicesModel.cs | 11 ++++ src/Api/Controllers/DevicesController.cs | 17 ++++++ .../Interfaces/IUntrustDevicesCommand.cs | 8 +++ .../DeviceTrust/UntrustDevicesCommand.cs | 39 +++++++++++++ .../UserServiceCollectionExtensions.cs | 7 +++ .../Controllers/DevicesControllerTests.cs | 4 ++ .../DeviceTrust/UntrustDevicesCommandTests.cs | 55 +++++++++++++++++++ 7 files changed, 141 insertions(+) create mode 100644 src/Api/Auth/Models/Request/UntrustDevicesModel.cs create mode 100644 src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs create mode 100644 src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs create mode 100644 test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs diff --git a/src/Api/Auth/Models/Request/UntrustDevicesModel.cs b/src/Api/Auth/Models/Request/UntrustDevicesModel.cs new file mode 100644 index 0000000000..ca4f0ad2e7 --- /dev/null +++ b/src/Api/Auth/Models/Request/UntrustDevicesModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +#nullable enable + +namespace Bit.Api.Auth.Models.Request; + +public class UntrustDevicesRequestModel +{ + [Required] + public IEnumerable Devices { get; set; } = null!; +} diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 4e21b5e9dc..6851aed5de 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -4,6 +4,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Response; +using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -21,6 +22,7 @@ public class DevicesController : Controller private readonly IDeviceRepository _deviceRepository; private readonly IDeviceService _deviceService; private readonly IUserService _userService; + private readonly IUntrustDevicesCommand _untrustDevicesCommand; private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; @@ -29,6 +31,7 @@ public class DevicesController : Controller IDeviceRepository deviceRepository, IDeviceService deviceService, IUserService userService, + IUntrustDevicesCommand untrustDevicesCommand, IUserRepository userRepository, ICurrentContext currentContext, ILogger logger) @@ -36,6 +39,7 @@ public class DevicesController : Controller _deviceRepository = deviceRepository; _deviceService = deviceService; _userService = userService; + _untrustDevicesCommand = untrustDevicesCommand; _userRepository = userRepository; _currentContext = currentContext; _logger = logger; @@ -165,6 +169,19 @@ public class DevicesController : Controller model.OtherDevices ?? Enumerable.Empty()); } + [HttpPost("untrust")] + public async Task PostUntrust([FromBody] UntrustDevicesRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + await _untrustDevicesCommand.UntrustDevices(user, model.Devices); + } + [HttpPut("identifier/{identifier}/token")] [HttpPost("identifier/{identifier}/token")] public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model) diff --git a/src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs b/src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs new file mode 100644 index 0000000000..860490ce1a --- /dev/null +++ b/src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.UserFeatures.DeviceTrust; + +public interface IUntrustDevicesCommand +{ + public Task UntrustDevices(User user, IEnumerable devicesToUntrust); +} diff --git a/src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs b/src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs new file mode 100644 index 0000000000..1f6f49753a --- /dev/null +++ b/src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs @@ -0,0 +1,39 @@ +using Bit.Core.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.UserFeatures.DeviceTrust; + +public class UntrustDevicesCommand : IUntrustDevicesCommand +{ + private readonly IDeviceRepository _deviceRepository; + + public UntrustDevicesCommand( + IDeviceRepository deviceRepository) + { + _deviceRepository = deviceRepository; + } + + public async Task UntrustDevices(User user, IEnumerable devicesToUntrust) + { + var userDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id); + var deviceIdDict = userDevices.ToDictionary(device => device.Id); + + // Validate that the user owns all devices that they passed in + foreach (var deviceId in devicesToUntrust) + { + if (!deviceIdDict.ContainsKey(deviceId)) + { + throw new UnauthorizedAccessException($"User {user.Id} does not have access to device {deviceId}"); + } + } + + foreach (var deviceId in devicesToUntrust) + { + var device = deviceIdDict[deviceId]; + device.EncryptedPrivateKey = null; + device.EncryptedPublicKey = null; + device.EncryptedUserKey = null; + await _deviceRepository.UpsertAsync(device); + } + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 16a0ef9805..7731e04af2 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,6 @@  +using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -22,6 +23,7 @@ public static class UserServiceCollectionExtensions public static void AddUserServices(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); + services.AddDeviceTrustCommands(); services.AddUserPasswordCommands(); services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); @@ -29,6 +31,11 @@ public static class UserServiceCollectionExtensions services.AddTwoFactorQueries(); } + public static void AddDeviceTrustCommands(this IServiceCollection services) + { + services.AddScoped(); + } + public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); diff --git a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs index 3dcf2016c4..74f00be866 100644 --- a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs @@ -2,6 +2,7 @@ using Bit.Api.Models.Response; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -19,6 +20,7 @@ public class DevicesControllerTest private readonly IDeviceRepository _deviceRepositoryMock; private readonly IDeviceService _deviceServiceMock; private readonly IUserService _userServiceMock; + private readonly IUntrustDevicesCommand _untrustDevicesCommand; private readonly IUserRepository _userRepositoryMock; private readonly ICurrentContext _currentContextMock; private readonly IGlobalSettings _globalSettingsMock; @@ -30,6 +32,7 @@ public class DevicesControllerTest _deviceRepositoryMock = Substitute.For(); _deviceServiceMock = Substitute.For(); _userServiceMock = Substitute.For(); + _untrustDevicesCommand = Substitute.For(); _userRepositoryMock = Substitute.For(); _currentContextMock = Substitute.For(); _loggerMock = Substitute.For>(); @@ -38,6 +41,7 @@ public class DevicesControllerTest _deviceRepositoryMock, _deviceServiceMock, _userServiceMock, + _untrustDevicesCommand, _userRepositoryMock, _currentContextMock, _loggerMock); diff --git a/test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs b/test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs new file mode 100644 index 0000000000..c4714be63b --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs @@ -0,0 +1,55 @@ +using Bit.Core.Auth.UserFeatures.DeviceTrust; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin; + +[SutProviderCustomize] +public class UntrustDevicesCommandTests +{ + [Theory, BitAutoData] + public async Task SetsKeysToNull(SutProvider sutProvider, User user) + { + var deviceId = Guid.NewGuid(); + // Arrange + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns([new Device + { + Id = deviceId, + EncryptedPrivateKey = "encryptedPrivateKey", + EncryptedPublicKey = "encryptedPublicKey", + EncryptedUserKey = "encryptedUserKey" + }]); + + // Act + await sutProvider.Sut.UntrustDevices(user, new List { deviceId }); + + // Assert + await sutProvider.GetDependency() + .Received() + .UpsertAsync(Arg.Is(d => + d.Id == deviceId && + d.EncryptedPrivateKey == null && + d.EncryptedPublicKey == null && + d.EncryptedUserKey == null)); + } + + [Theory, BitAutoData] + public async Task RejectsWrongUser(SutProvider sutProvider, User user) + { + var deviceId = Guid.NewGuid(); + // Arrange + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns([]); + + // Act + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UntrustDevices(user, new List { deviceId })); + } +} From f1a4829e5ecedbb4bbc8dfc31a741ce09ad31d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:23:29 +0100 Subject: [PATCH 04/17] [PM-12485] Create OrganizationUpdateKeys command (#5600) * Add OrganizationUpdateKeysCommand * Add unit tests for OrganizationUpdateKeysCommand to validate permission checks and key updates * Register OrganizationUpdateKeysCommand for dependency injection * Refactor OrganizationsController to use IOrganizationUpdateKeysCommand for updating organization keys * Remove outdated unit tests for UpdateOrganizationKeysAsync in OrganizationServiceTests * Remove UpdateOrganizationKeysAsync method from IOrganizationService and OrganizationService implementations * Add IOrganizationUpdateKeysCommand dependency mock to OrganizationsControllerTests --- .../Controllers/OrganizationsController.cs | 9 ++- .../IOrganizationUpdateKeysCommand.cs | 13 ++++ .../OrganizationUpdateKeysCommand.cs | 47 ++++++++++++ .../Services/IOrganizationService.cs | 1 - .../Implementations/OrganizationService.cs | 22 ------ ...OrganizationServiceCollectionExtensions.cs | 6 ++ .../OrganizationsControllerTests.cs | 5 +- .../OrganizationUpdateKeysCommandTests.cs | 75 +++++++++++++++++++ .../Services/OrganizationServiceTests.cs | 42 ----------- 9 files changed, 151 insertions(+), 69 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 6b7d031a00..c856c8ab91 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -65,6 +65,7 @@ public class OrganizationsController : Controller private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; + private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -88,7 +89,8 @@ public class OrganizationsController : Controller ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, IOrganizationDeleteCommand organizationDeleteCommand, IPolicyRequirementQuery policyRequirementQuery, - IPricingClient pricingClient) + IPricingClient pricingClient, + IOrganizationUpdateKeysCommand organizationUpdateKeysCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -112,6 +114,7 @@ public class OrganizationsController : Controller _organizationDeleteCommand = organizationDeleteCommand; _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; + _organizationUpdateKeysCommand = organizationUpdateKeysCommand; } [HttpGet("{id}")] @@ -490,7 +493,7 @@ public class OrganizationsController : Controller } [HttpPost("{id}/keys")] - public async Task PostKeys(string id, [FromBody] OrganizationKeysRequestModel model) + public async Task PostKeys(Guid id, [FromBody] OrganizationKeysRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -498,7 +501,7 @@ public class OrganizationsController : Controller throw new UnauthorizedAccessException(); } - var org = await _organizationService.UpdateOrganizationKeysAsync(new Guid(id), model.PublicKey, + var org = await _organizationUpdateKeysCommand.UpdateOrganizationKeysAsync(id, model.PublicKey, model.EncryptedPrivateKey); return new OrganizationKeysResponseModel(org); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs new file mode 100644 index 0000000000..2d01a5e4e3 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Entities; + +public interface IOrganizationUpdateKeysCommand +{ + /// + /// Update the keys for an organization. + /// + /// The ID of the organization to update. + /// The public key for the organization. + /// The private key for the organization. + /// The updated organization. + Task UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs new file mode 100644 index 0000000000..aa85c7e2a4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs @@ -0,0 +1,47 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; + +public class OrganizationUpdateKeysCommand : IOrganizationUpdateKeysCommand +{ + private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationService _organizationService; + + public const string OrganizationKeysAlreadyExistErrorMessage = "Organization Keys already exist."; + + public OrganizationUpdateKeysCommand( + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IOrganizationService organizationService) + { + _currentContext = currentContext; + _organizationRepository = organizationRepository; + _organizationService = organizationService; + } + + public async Task UpdateOrganizationKeysAsync(Guid organizationId, string publicKey, string privateKey) + { + if (!await _currentContext.ManageResetPassword(organizationId)) + { + throw new UnauthorizedAccessException(); + } + + // If the keys already exist, error out + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if (organization.PublicKey != null && organization.PrivateKey != null) + { + throw new BadRequestException(OrganizationKeysAlreadyExistErrorMessage); + } + + // Update org with generated public/private key + organization.PublicKey = publicKey; + organization.PrivateKey = privateKey; + + await _organizationService.UpdateAsync(organization); + + return organization; + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index e0088f1f74..8d2997bbc6 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -43,7 +43,6 @@ public interface IOrganizationService IEnumerable newUsers, IEnumerable removeUserExternalIds, bool overwriteExisting, EventSystemUser eventSystemUser); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); - Task UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey); Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task>> RevokeUsersAsync(Guid organizationId, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index c9027b8030..c9b38b3e30 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1418,28 +1418,6 @@ public class OrganizationService : IOrganizationService } } - public async Task UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey) - { - if (!await _currentContext.ManageResetPassword(orgId)) - { - throw new UnauthorizedAccessException(); - } - - // If the keys already exist, error out - var org = await _organizationRepository.GetByIdAsync(orgId); - if (org.PublicKey != null && org.PrivateKey != null) - { - throw new BadRequestException("Organization Keys already exist"); - } - - // Update org with generated public/private key - org.PublicKey = publicKey; - org.PrivateKey = privateKey; - await UpdateAsync(org); - - return org; - } - private async Task UpdateUsersAsync(Group group, HashSet groupUsers, Dictionary existingUsersIdDict, HashSet existingUsers = null) { diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 96d9095c1a..164710d522 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationSignUpCommands(); services.AddOrganizationDeleteCommands(); + services.AddOrganizationUpdateCommands(); services.AddOrganizationEnableCommands(); services.AddOrganizationDisableCommands(); services.AddOrganizationAuthCommands(); @@ -77,6 +78,11 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); } + private static void AddOrganizationUpdateCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddOrganizationEnableCommands(this IServiceCollection services) => services.AddScoped(); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 3c06c78392..867f8f8ec6 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -60,6 +60,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; + private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; private readonly OrganizationsController _sut; public OrganizationsControllerTests() @@ -86,6 +87,7 @@ public class OrganizationsControllerTests : IDisposable _organizationDeleteCommand = Substitute.For(); _policyRequirementQuery = Substitute.For(); _pricingClient = Substitute.For(); + _organizationUpdateKeysCommand = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -109,7 +111,8 @@ public class OrganizationsControllerTests : IDisposable _cloudOrganizationSignUpCommand, _organizationDeleteCommand, _policyRequirementQuery, - _pricingClient); + _pricingClient, + _organizationUpdateKeysCommand); } public void Dispose() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs new file mode 100644 index 0000000000..91ab9214e1 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs @@ -0,0 +1,75 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class OrganizationUpdateKeysCommandTests +{ + [Theory, BitAutoData] + public async Task UpdateOrganizationKeysAsync_WithoutManageResetPasswordPermission_ThrowsUnauthorizedException( + Guid orgId, string publicKey, string privateKey, SutProvider sutProvider) + { + sutProvider.GetDependency() + .ManageResetPassword(orgId) + .Returns(false); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateOrganizationKeysAsync(orgId, publicKey, privateKey)); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationKeysAsync_WhenKeysAlreadyExist_ThrowsBadRequestException( + Organization organization, string publicKey, string privateKey, + SutProvider sutProvider) + { + organization.PublicKey = "existingPublicKey"; + organization.PrivateKey = "existingPrivateKey"; + + sutProvider.GetDependency() + .ManageResetPassword(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateOrganizationKeysAsync(organization.Id, publicKey, privateKey)); + + Assert.Equal(OrganizationUpdateKeysCommand.OrganizationKeysAlreadyExistErrorMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationKeysAsync_WhenKeysDoNotExist_UpdatesOrganization( + Organization organization, string publicKey, string privateKey, + SutProvider sutProvider) + { + organization.PublicKey = null; + organization.PrivateKey = null; + + sutProvider.GetDependency() + .ManageResetPassword(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var result = await sutProvider.Sut.UpdateOrganizationKeysAsync(organization.Id, publicKey, privateKey); + + Assert.Equal(publicKey, result.PublicKey); + Assert.Equal(privateKey, result.PrivateKey); + + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync(organization); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index e45643435d..c138cfac2e 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -814,48 +814,6 @@ public class OrganizationServiceTests sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); } - [Theory, BitAutoData] - public async Task UpdateOrganizationKeysAsync_WithoutManageResetPassword_Throws(Guid orgId, string publicKey, - string privateKey, SutProvider sutProvider) - { - var currentContext = Substitute.For(); - currentContext.ManageResetPassword(orgId).Returns(false); - - await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateOrganizationKeysAsync(orgId, publicKey, privateKey)); - } - - [Theory, BitAutoData] - public async Task UpdateOrganizationKeysAsync_KeysAlreadySet_Throws(Organization org, string publicKey, - string privateKey, SutProvider sutProvider) - { - var currentContext = sutProvider.GetDependency(); - currentContext.ManageResetPassword(org.Id).Returns(true); - - var organizationRepository = sutProvider.GetDependency(); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateOrganizationKeysAsync(org.Id, publicKey, privateKey)); - Assert.Contains("Organization Keys already exist", exception.Message); - } - - [Theory, BitAutoData] - public async Task UpdateOrganizationKeysAsync_KeysAlreadySet_Success(Organization org, string publicKey, - string privateKey, SutProvider sutProvider) - { - org.PublicKey = null; - org.PrivateKey = null; - - var currentContext = sutProvider.GetDependency(); - currentContext.ManageResetPassword(org.Id).Returns(true); - - var organizationRepository = sutProvider.GetDependency(); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - - await sutProvider.Sut.UpdateOrganizationKeysAsync(org.Id, publicKey, privateKey); - } - [Theory] [PaidOrganizationCustomize(CheckedPlanType = PlanType.EnterpriseAnnually)] [BitAutoData("Cannot set max seat autoscaling below seat count", 1, 0, 2, 2)] From 4b6eac3a4604d282863fc900e2976ebc584c3526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:33:21 +0100 Subject: [PATCH 05/17] [PM-16091] Add SsoExternalId to OrganizationUserDetailsResponseModel (#5606) --- .../Response/Organizations/OrganizationUserResponseModel.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index f9e5193045..4e869f59b1 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -67,10 +67,12 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode public OrganizationUserDetailsResponseModel( OrganizationUser organizationUser, bool claimedByOrganization, + string ssoExternalId, IEnumerable collections) : base(organizationUser, "organizationUserDetails") { ClaimedByOrganization = claimedByOrganization; + SsoExternalId = ssoExternalId; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } @@ -80,6 +82,7 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode : base(organizationUser, "organizationUserDetails") { ClaimedByOrganization = claimedByOrganization; + SsoExternalId = organizationUser.SsoExternalId; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } @@ -90,6 +93,7 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode set => ClaimedByOrganization = value; } public bool ClaimedByOrganization { get; set; } + public string SsoExternalId { get; set; } public IEnumerable Collections { get; set; } From 8cd14d55dd7982ab07fde74f3193a82514b1c2f4 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:44:42 -0600 Subject: [PATCH 06/17] EE sync improvements (#5620) * Leverage new workflow changes * Refactor ephemeral-environment workflow * Add .has_secrets check back into build --- .github/workflows/build.yml | 46 ++------------------- .github/workflows/ephemeral-environment.yml | 39 ++++------------- 2 files changed, 13 insertions(+), 72 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0df238b34..35c2070df3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -627,56 +627,18 @@ jobs: } }) - trigger-ee-updates: - name: Trigger Ephemeral Environment updates - if: | - needs.build-artifacts.outputs.has_secrets == 'true' - && github.event_name == 'pull_request' - && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') - runs-on: ubuntu-24.04 - needs: - - build-docker - steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - - name: Trigger Ephemeral Environment update - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: 'bitwarden', - repo: 'devops', - workflow_id: '_update_ephemeral_tags.yml', - ref: 'main', - inputs: { - ephemeral_env_branch: process.env.GITHUB_HEAD_REF - } - }) - - trigger-ephemeral-environment-sync: - name: Trigger Ephemeral Environment Sync - needs: trigger-ee-updates + setup-ephemeral-environment: + name: Setup Ephemeral Environment + needs: build-docker if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main with: - ephemeral_env_branch: process.env.GITHUB_HEAD_REF project: server - sync_environment: true pull_request_number: ${{ github.event.number }} + create_branch: true secrets: inherit check-failures: diff --git a/.github/workflows/ephemeral-environment.yml b/.github/workflows/ephemeral-environment.yml index c784d48354..699a28c6fb 100644 --- a/.github/workflows/ephemeral-environment.yml +++ b/.github/workflows/ephemeral-environment.yml @@ -5,34 +5,13 @@ on: types: [labeled] jobs: - trigger-ee-updates: - name: Trigger Ephemeral Environment updates - runs-on: ubuntu-24.04 + setup-ephemeral-environment: + name: Setup Ephemeral Environment if: github.event.label.name == 'ephemeral-environment' - steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - - name: Trigger Ephemeral Environment update - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: 'bitwarden', - repo: 'devops', - workflow_id: '_update_ephemeral_tags.yml', - ref: 'main', - inputs: { - ephemeral_env_branch: process.env.GITHUB_HEAD_REF - } - }) + uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main + with: + project: server + pull_request_number: ${{ github.event.number }} + sync_environment: true + create_branch: true + secrets: inherit From d85807e94f82c316e49772026f63b3df4d51e7c1 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Wed, 9 Apr 2025 12:17:04 -0400 Subject: [PATCH 07/17] Add mobile feature flags (#5629) * Add mobile feature flags * Update Constants.cs --- src/Core/Constants.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 42d5d06450..33dec32e34 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,8 +168,9 @@ public static class FeatureFlagKeys public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync"; public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias"; - public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; + public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; + public const string MobileErrorReporting = "mobile-error-reporting"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; From 54e7fac4d99b7a8e3fc83221c561d60c68194494 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:06:16 -0400 Subject: [PATCH 08/17] [PM-18770] Convert Organization to Business Unit (#5610) * [NO LOGIC] Rename MultiOrganizationEnterprise to BusinessUnit * [Core] Add IMailService.SendBusinessUnitConversionInviteAsync * [Core] Add BusinessUnitConverter * [Admin] Add new permission * [Admin] Add BusinessUnitConverterController * [Admin] Add Convert to Business Unit button to Organization edit page * [Api] Add OrganizationBillingController.SetupBusinessUnitAsync action * [Multi] Propagate provider type to sync response * [Multi] Put updates behind feature flag * [Tests] BusinessUnitConverterTests * Run dotnet format * Fixing post-main merge compilation failure --- .../Providers/CreateProviderCommand.cs | 2 +- .../AdminConsole/Services/ProviderService.cs | 4 +- .../Billing/BusinessUnitConverter.cs | 462 ++++++++++++++++ .../Billing/ProviderBillingService.cs | 2 +- .../Billing/ProviderPriceAdapter.cs | 8 +- .../Utilities/ServiceCollectionExtensions.cs | 1 + .../CreateProviderCommandTests.cs | 10 +- .../Billing/BusinessUnitConverterTests.cs | 501 ++++++++++++++++++ .../Billing/ProviderBillingServiceTests.cs | 2 +- .../Billing/ProviderPriceAdapterTests.cs | 6 +- src/Admin/Admin.csproj | 3 - .../Controllers/ProvidersController.cs | 16 +- ....cs => CreateBusinessUnitProviderModel.cs} | 10 +- .../AdminConsole/Models/ProviderEditModel.cs | 4 +- .../AdminConsole/Models/ProviderViewModel.cs | 2 +- .../Views/Organizations/Edit.cshtml | 23 +- ...prise.cshtml => CreateBusinessUnit.cshtml} | 12 +- .../AdminConsole/Views/Providers/Edit.cshtml | 6 +- .../BusinessUnitConversionController.cs | 185 +++++++ .../Models/BusinessUnitConversionModel.cs | 25 + .../Views/BusinessUnitConversion/Index.cshtml | 75 +++ src/Admin/Enums/Permissions.cs | 1 + src/Admin/Utilities/RolePermissionMapping.cs | 3 + .../Providers/ProfileProviderResponseModel.cs | 2 + .../OrganizationBillingController.cs | 39 ++ .../Requests/SetupBusinessUnitRequestBody.cs | 18 + .../Enums/Provider/ProviderType.cs | 4 +- .../Provider/ProviderUserProviderDetails.cs | 1 + .../Interfaces/ICreateProviderCommand.cs | 2 +- .../Billing/Extensions/BillingExtensions.cs | 6 +- .../Services/IBusinessUnitConverter.cs | 58 ++ src/Core/Constants.cs | 1 + .../BusinessUnitConversionInvite.html.hbs | 19 + .../BusinessUnitConversionInvite.text.hbs | 5 + .../BusinessUnitConversionInviteModel.cs | 11 + src/Core/Services/IMailService.cs | 1 + .../Implementations/HandlebarsMailService.cs | 17 + .../NoopImplementations/NoopMailService.cs | 5 + .../Repositories/OrganizationRepository.cs | 2 +- ...rProviderDetailsReadByUserIdStatusQuery.cs | 1 + .../Controllers/ProvidersControllerTests.cs | 22 +- 41 files changed, 1513 insertions(+), 64 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs rename src/Admin/AdminConsole/Models/{CreateMultiOrganizationEnterpriseProviderModel.cs => CreateBusinessUnitProviderModel.cs} (76%) rename src/Admin/AdminConsole/Views/Providers/{CreateMultiOrganizationEnterprise.cshtml => CreateBusinessUnit.cshtml} (77%) create mode 100644 src/Admin/Billing/Controllers/BusinessUnitConversionController.cs create mode 100644 src/Admin/Billing/Models/BusinessUnitConversionModel.cs create mode 100644 src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml create mode 100644 src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs create mode 100644 src/Core/Billing/Services/IBusinessUnitConverter.cs create mode 100644 src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs create mode 100644 src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 69b7e67ec1..36a5f2c0a9 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created); } - public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) + public async Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) { var providerId = await CreateProviderAsync(provider, ownerEmail); diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 799b57dc5a..fff6b5271d 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -692,10 +692,10 @@ public class ProviderService : IProviderService throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); } break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually)) { - throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); + throw new BadRequestException($"Business Unit Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); } break; case ProviderType.Reseller: diff --git a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs new file mode 100644 index 0000000000..97d9377cd6 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs @@ -0,0 +1,462 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Commercial.Core.Billing; + +[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] +public class BusinessUnitConverter( + IDataProtectionProvider dataProtectionProvider, + GlobalSettings globalSettings, + ILogger logger, + IMailService mailService, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPricingClient pricingClient, + IProviderOrganizationRepository providerOrganizationRepository, + IProviderPlanRepository providerPlanRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserRepository userRepository) : IBusinessUnitConverter +{ + private readonly IDataProtector _dataProtector = + dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector"); + + public async Task FinalizeConversion( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + var user = await userRepository.GetByIdAsync(userId); + + var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token); + + var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly); + + // Bring organization under management. + organization.Plan = updatedPlan.Name; + organization.PlanType = updatedPlan.Type; + organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections; + organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; + organization.UsePolicies = updatedPlan.HasPolicies; + organization.UseSso = updatedPlan.HasSso; + organization.UseGroups = updatedPlan.HasGroups; + organization.UseEvents = updatedPlan.HasEvents; + organization.UseDirectory = updatedPlan.HasDirectory; + organization.UseTotp = updatedPlan.HasTotp; + organization.Use2fa = updatedPlan.Has2fa; + organization.UseApi = updatedPlan.HasApi; + organization.UseResetPassword = updatedPlan.HasResetPassword; + organization.SelfHost = updatedPlan.HasSelfHost; + organization.UsersGetPremium = updatedPlan.UsersGetPremium; + organization.UseCustomPermissions = updatedPlan.HasCustomPermissions; + organization.UseScim = updatedPlan.HasScim; + organization.UseKeyConnector = updatedPlan.HasKeyConnector; + organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; + organization.BillingEmail = provider.BillingEmail!; + organization.GatewayCustomerId = null; + organization.GatewaySubscriptionId = null; + organization.ExpirationDate = null; + organization.MaxAutoscaleSeats = null; + organization.Status = OrganizationStatusType.Managed; + + // Enable organization access via key exchange. + providerOrganization.Key = organizationKey; + + // Complete provider setup. + provider.Gateway = GatewayType.Stripe; + provider.GatewayCustomerId = subscription.CustomerId; + provider.GatewaySubscriptionId = subscription.Id; + provider.Status = ProviderStatusType.Billable; + + // Enable provider access via key exchange. + providerUser.Key = providerKey; + providerUser.Status = ProviderUserStatusType.Confirmed; + + // Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them. + await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [] + } + }); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.OrganizationId] = string.Empty, + [StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(), + ["convertedFrom"] = organization.Id.ToString() + }; + + var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = provider.SubscriberType(), + Value = provider.DisplayName()?.Length <= 30 + ? provider.DisplayName() + : provider.DisplayName()?[..30] + } + ] + }, + Metadata = metadata + }); + + // Find the existing password manager price on the subscription. + var passwordManagerItem = subscription.Items.First(item => + { + var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan() + ? existingPlan.PasswordManager.StripePlanId + : existingPlan.PasswordManager.StripeSeatPlanId; + + return item.Price.Id == priceId; + }); + + // Get the new business unit price. + var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type); + + // Replace the existing password manager price with the new business unit price. + var updateSubscription = + stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + new SubscriptionUpdateOptions + { + Items = [ + new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, + Deleted = true + }, + new SubscriptionItemOptions + { + Price = updatedPriceId, + Quantity = organization.Seats + } + ], + Metadata = metadata + }); + + await Task.WhenAll(updateCustomer, updateSubscription); + + // Complete database updates for provider setup. + await Task.WhenAll( + organizationRepository.ReplaceAsync(organization), + providerOrganizationRepository.ReplaceAsync(providerOrganization), + providerRepository.ReplaceAsync(provider), + providerUserRepository.ReplaceAsync(providerUser)); + + return provider.Id; + } + + public async Task>> InitiateConversion( + Organization organization, + string providerAdminEmail) + { + var user = await userRepository.GetByEmailAsync(providerAdminEmail); + + var problems = await ValidateInitiationAsync(organization, user); + + if (problems is { Count: > 0 }) + { + return problems; + } + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = organization.Name, + BillingEmail = organization.BillingEmail, + Status = ProviderStatusType.Pending, + UseEvents = true, + Type = ProviderType.BusinessUnit + }); + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var managedPlanType = plan.IsAnnual + ? PlanType.EnterpriseAnnually + : PlanType.EnterpriseMonthly; + + var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organization.Id + }); + + var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan + { + ProviderId = provider.Id, + PlanType = managedPlanType, + SeatMinimum = 0, + PurchasedSeats = organization.Seats, + AllocatedSeats = organization.Seats + }); + + var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user!.Id, + Email = user.Email, + Status = ProviderUserStatusType.Invited, + Type = ProviderUserType.ProviderAdmin + }); + + await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser); + + await SendInviteAsync(organization, user.Email); + + return provider.Id; + } + + public Task ResendConversionInvite( + Organization organization, + string providerAdminEmail) => + IfConversionInProgressAsync(organization, providerAdminEmail, + async (_, _, providerUser) => + { + if (!string.IsNullOrEmpty(providerUser.Email)) + { + await SendInviteAsync(organization, providerUser.Email); + } + }); + + public Task ResetConversion( + Organization organization, + string providerAdminEmail) => + IfConversionInProgressAsync(organization, providerAdminEmail, + async (provider, providerOrganization, providerUser) => + { + var tasks = new List + { + providerOrganizationRepository.DeleteAsync(providerOrganization), + providerUserRepository.DeleteAsync(providerUser) + }; + + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + + if (providerPlans is { Count: > 0 }) + { + tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync)); + } + + await Task.WhenAll(tasks); + + await providerRepository.DeleteAsync(provider); + }); + + #region Utilities + + private async Task IfConversionInProgressAsync( + Organization organization, + string providerAdminEmail, + Func callback) + { + var user = await userRepository.GetByEmailAsync(providerAdminEmail); + + if (user == null) + { + return; + } + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + if (provider is not + { + Type: ProviderType.BusinessUnit, + Status: ProviderStatusType.Pending + }) + { + return; + } + + var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); + + if (providerUser is + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Invited + }) + { + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + await callback(provider, providerOrganization!, providerUser); + } + } + + private async Task SendInviteAsync( + Organization organization, + string providerAdminEmail) + { + var token = _dataProtector.Protect( + $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail); + } + + private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync( + Organization organization, + User? user, + string token) + { + if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) + { + Fail("Organization must be on an enterprise plan."); + } + + var subscription = await subscriberService.GetSubscription(organization); + + if (subscription is not + { + Status: + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue + }) + { + Fail("Organization must have a valid subscription."); + } + + if (user == null) + { + Fail("Provider admin must be a Bitwarden user."); + } + + if (!CoreHelpers.TokenIsValid( + "BusinessUnitConversionInvite", + _dataProtector, + token, + user.Email, + organization.Id, + globalSettings.OrganizationInviteExpirationHours)) + { + Fail("Email token is invalid."); + } + + var organizationUser = + await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); + + if (organizationUser is not + { + Status: OrganizationUserStatusType.Confirmed + }) + { + Fail("Provider admin must be a confirmed member of the organization being converted."); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + if (provider is not + { + Type: ProviderType.BusinessUnit, + Status: ProviderStatusType.Pending + }) + { + Fail("Linked provider is not a pending business unit."); + } + + var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); + + if (providerUser is not + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Invited + }) + { + Fail("Provider admin has not been invited."); + } + + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + + return (subscription, provider, providerOrganization!, providerUser); + + [DoesNotReturn] + void Fail(string scopedError) + { + logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}", + organization.Id, scopedError); + throw new BillingException(); + } + } + + private async Task?> ValidateInitiationAsync( + Organization organization, + User? user) + { + var problems = new List(); + + if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) + { + problems.Add("Organization must be on an enterprise plan."); + } + + var subscription = await subscriberService.GetSubscription(organization); + + if (subscription is not + { + Status: + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue + }) + { + problems.Add("Organization must have a valid subscription."); + } + + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + + if (providerOrganization != null) + { + problems.Add("Organization is already linked to a provider."); + } + + if (user == null) + { + problems.Add("Provider admin must be a Bitwarden user."); + } + else + { + var organizationUser = + await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); + + if (organizationUser is not + { + Status: OrganizationUserStatusType.Confirmed + }) + { + problems.Add("Provider admin must be a confirmed member of the organization being converted."); + } + } + + return problems.Count == 0 ? null : problems; + } + + #endregion +} diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 757d6510f1..98ebefd4f1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -791,7 +791,7 @@ public class ProviderBillingService( Provider provider, Organization organization) { - if (provider.Type == ProviderType.MultiOrganizationEnterprise) + if (provider.Type == ProviderType.BusinessUnit) { return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType; } diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs index 4cc0711ec9..a9dbb6febf 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs @@ -51,7 +51,7 @@ public static class ProviderPriceAdapter /// The provider's subscription. /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. - /// Thrown when the provider's type is not or . + /// Thrown when the provider's type is not or . /// Thrown when the provided does not relate to a Stripe price ID. public static string GetPriceId( Provider provider, @@ -78,7 +78,7 @@ public static class ProviderPriceAdapter PlanType.EnterpriseMonthly => MSP.Active.Enterprise, _ => throw invalidPlanType }, - ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any() + ProviderType.BusinessUnit => BusinessUnit.Legacy.List.Intersect(priceIds).Any() ? planType switch { PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually, @@ -103,7 +103,7 @@ public static class ProviderPriceAdapter /// The provider to get the Stripe price ID for. /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. - /// Thrown when the provider's type is not or . + /// Thrown when the provider's type is not or . /// Thrown when the provided does not relate to a Stripe price ID. public static string GetActivePriceId( Provider provider, @@ -120,7 +120,7 @@ public static class ProviderPriceAdapter PlanType.EnterpriseMonthly => MSP.Active.Enterprise, _ => throw invalidPlanType }, - ProviderType.MultiOrganizationEnterprise => planType switch + ProviderType.BusinessUnit => planType switch { PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually, PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly, diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 5ae5be8847..7f8c82e2c9 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddTransient(); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs index e354e44173..82fcb016b3 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs @@ -63,7 +63,7 @@ public class CreateProviderCommandTests } [Theory, BitAutoData] - public async Task CreateMultiOrganizationEnterpriseAsync_Success( + public async Task CreateBusinessUnitAsync_Success( Provider provider, User user, PlanType plan, @@ -71,13 +71,13 @@ public class CreateProviderCommandTests SutProvider sutProvider) { // Arrange - provider.Type = ProviderType.MultiOrganizationEnterprise; + provider.Type = ProviderType.BusinessUnit; var userRepository = sutProvider.GetDependency(); userRepository.GetByEmailAsync(user.Email).Returns(user); // Act - await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats); + await sutProvider.Sut.CreateBusinessUnitAsync(provider, user.Email, plan, minimumSeats); // Assert await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(provider); @@ -85,7 +85,7 @@ public class CreateProviderCommandTests } [Theory, BitAutoData] - public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws( + public async Task CreateBusinessUnitAsync_UserIdIsInvalid_Throws( Provider provider, SutProvider sutProvider) { @@ -94,7 +94,7 @@ public class CreateProviderCommandTests // Act var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default)); + () => sutProvider.Sut.CreateBusinessUnitAsync(provider, default, default, default)); // Assert Assert.Contains("Invalid owner.", exception.Message); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs new file mode 100644 index 0000000000..5d2d0a2c7c --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs @@ -0,0 +1,501 @@ +#nullable enable +using System.Text; +using Bit.Commercial.Core.Billing; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing; + +public class BusinessUnitConverterTests +{ + private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For(); + private readonly GlobalSettings _globalSettings = new(); + private readonly ILogger _logger = Substitute.For>(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For(); + private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IProviderUserRepository _providerUserRepository = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + + private BusinessUnitConverter BuildConverter() => new( + _dataProtectionProvider, + _globalSettings, + _logger, + _mailService, + _organizationRepository, + _organizationUserRepository, + _pricingClient, + _providerOrganizationRepository, + _providerPlanRepository, + _providerRepository, + _providerUserRepository, + _stripeAdapter, + _subscriberService, + _userRepository); + + #region FinalizeConversion + + [Theory, BitAutoData] + public async Task FinalizeConversion_Succeeds_ReturnsProviderId( + Organization organization, + Guid userId, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020); + + var subscription = new Subscription + { + Id = "subscription_id", + CustomerId = "customer_id", + Status = StripeConstants.SubscriptionStatus.Active, + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + Id = "subscription_item_id", + Price = new Price + { + Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId + } + } + ] + } + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var user = new User + { + Id = Guid.NewGuid(), + Email = "provider-admin@example.com" + }; + + _userRepository.GetByIdAsync(userId).Returns(user); + + var token = SetupDataProtection(organization, user.Email); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var provider = new Provider + { + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var providerOrganization = new ProviderOrganization(); + + _providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020) + .Returns(enterpriseAnnually2020); + + var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually) + .Returns(enterpriseAnnually); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey); + + await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any()); + + var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type); + + await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is( + arguments => + arguments.Items.Count == 2 && + arguments.Items[0].Id == "subscription_item_id" && + arguments.Items[0].Deleted == true && + arguments.Items[1].Price == updatedPriceId && + arguments.Items[1].Quantity == organization.Seats)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.PlanType == PlanType.EnterpriseAnnually && + arguments.Status == OrganizationStatusType.Managed && + arguments.GatewayCustomerId == null && + arguments.GatewaySubscriptionId == null)); + + await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == organizationKey)); + + await _providerRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Gateway == GatewayType.Stripe && + arguments.GatewayCustomerId == subscription.CustomerId && + arguments.GatewaySubscriptionId == subscription.Id && + arguments.Status == ProviderStatusType.Billable)); + + await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == providerKey && + arguments.Status == ProviderUserStatusType.Confirmed)); + } + + /* + * Because the validation for finalization is not an applicative like initialization is, + * I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure. + */ + [Theory, BitAutoData] + public async Task FinalizeConversion_ValidationFails_ThrowsBillingException( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var subscription = new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var businessUnitConverter = BuildConverter(); + + await Assert.ThrowsAsync(() => + businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey)); + + await _organizationUserRepository.DidNotReceiveWithAnyArgs() + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + #endregion + + #region InitiateConversion + + [Theory, BitAutoData] + public async Task InitiateConversion_Succeeds_ReturnsProviderId( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.EnterpriseAnnually; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Active + }); + + var user = new User + { + Id = Guid.NewGuid(), + Email = providerAdminEmail + }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var provider = new Provider { Id = Guid.NewGuid() }; + + _providerRepository.CreateAsync(Arg.Is(argument => + argument.Name == organization.Name && + argument.BillingEmail == organization.BillingEmail && + argument.Status == ProviderStatusType.Pending && + argument.Type == ProviderType.BusinessUnit)).Returns(provider); + + var plan = StaticStore.GetPlan(organization.PlanType); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT0); + + var providerId = result.AsT0; + + Assert.Equal(provider.Id, providerId); + + await _providerOrganizationRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.OrganizationId == organization.Id)); + + await _providerPlanRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.PlanType == PlanType.EnterpriseAnnually && + argument.SeatMinimum == 0 && + argument.PurchasedSeats == organization.Seats && + argument.AllocatedSeats == organization.Seats)); + + await _providerUserRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.UserId == user.Id && + argument.Email == user.Email && + argument.Status == ProviderUserStatusType.Invited && + argument.Type == ProviderUserType.ProviderAdmin)); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + user.Email); + } + + [Theory, BitAutoData] + public async Task InitiateConversion_ValidationFails_ReturnsErrors( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.TeamsMonthly; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }); + + var user = new User + { + Id = Guid.NewGuid(), + Email = providerAdminEmail + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(new ProviderOrganization()); + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT1); + + var problems = result.AsT1; + + Assert.Contains("Organization must be on an enterprise plan.", problems); + + Assert.Contains("Organization must have a valid subscription.", problems); + + Assert.Contains("Organization is already linked to a provider.", problems); + + Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems); + } + + #endregion + + #region ResendConversionInvite + + [Theory, BitAutoData] + public async Task ResendConversionInvite_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + SetupConversionInProgress(organization, providerAdminEmail); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + providerAdminEmail); + } + + [Theory, BitAutoData] + public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + #endregion + + #region ResetConversion + + [Theory, BitAutoData] + public async Task ResetConversion_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.Received(1) + .DeleteAsync(providerOrganization); + + await _providerUserRepository.Received(1) + .DeleteAsync(providerUser); + + await _providerPlanRepository.Received(1) + .DeleteAsync(providerPlan); + + await _providerRepository.Received(1) + .DeleteAsync(provider); + } + + [Theory, BitAutoData] + public async Task ResetConversion_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerUserRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerPlanRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + } + + #endregion + + #region Utilities + + private string SetupDataProtection( + Organization organization, + string providerAdminEmail) + { + var dataProtector = new MockDataProtector(organization, providerAdminEmail); + _dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector); + return dataProtector.Protect(dataProtector.Token); + } + + private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress( + Organization organization, + string providerAdminEmail) + { + var user = new User { Id = Guid.NewGuid() }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + UserId = user.Id, + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited, + Email = providerAdminEmail + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id) + .Returns(providerUser); + + var providerOrganization = new ProviderOrganization + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + ProviderId = provider.Id + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + var providerPlan = new ProviderPlan + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseAnnually + }; + + _providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]); + + return (provider, providerOrganization, providerUser, providerPlan); + } + + #endregion +} + +public class MockDataProtector( + Organization organization, + string providerAdminEmail) : IDataProtector +{ + public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"; + + public IDataProtector CreateProtector(string purpose) => this; + + public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token); + + public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token); +} diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index ab1000d631..2661a0eff6 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -116,7 +116,7 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange - provider.Type = ProviderType.MultiOrganizationEnterprise; + provider.Type = ProviderType.BusinessUnit; var providerPlanRepository = sutProvider.GetDependency(); var existingPlan = new ProviderPlan diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs index 4fce78c05a..9ecb4b0511 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs @@ -71,7 +71,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var subscription = new Subscription @@ -98,7 +98,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var subscription = new Subscription @@ -141,7 +141,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var result = ProviderPriceAdapter.GetActivePriceId(provider, planType); diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index 4a255eefb2..cd30e841b4 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -14,9 +14,6 @@ - - - diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 0b1e4035df..6dc33e4909 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -133,10 +133,10 @@ public class ProvidersController : Controller return View(new CreateResellerProviderModel()); } - [HttpGet("providers/create/multi-organization-enterprise")] - public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null) + [HttpGet("providers/create/business-unit")] + public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null) { - return View(new CreateMultiOrganizationEnterpriseProviderModel + return View(new CreateBusinessUnitProviderModel { OwnerEmail = ownerEmail, EnterpriseSeatMinimum = enterpriseMinimumSeats @@ -157,7 +157,7 @@ public class ProvidersController : Controller { ProviderType.Msp => RedirectToAction("CreateMsp"), ProviderType.Reseller => RedirectToAction("CreateReseller"), - ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"), + ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"), _ => View(model) }; } @@ -198,10 +198,10 @@ public class ProvidersController : Controller return RedirectToAction("Edit", new { id = provider.Id }); } - [HttpPost("providers/create/multi-organization-enterprise")] + [HttpPost("providers/create/business-unit")] [ValidateAntiForgeryToken] [RequirePermission(Permission.Provider_Create)] - public async Task CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model) + public async Task CreateBusinessUnit(CreateBusinessUnitProviderModel model) { if (!ModelState.IsValid) { @@ -209,7 +209,7 @@ public class ProvidersController : Controller } var provider = model.ToProvider(); - await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync( + await _createProviderCommand.CreateBusinessUnitAsync( provider, model.OwnerEmail, model.Plan.Value, @@ -307,7 +307,7 @@ public class ProvidersController : Controller ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: { var existingMoePlan = providerPlans.Single(); diff --git a/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs similarity index 76% rename from src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs rename to src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs index ef7210a9ef..b57d90e33b 100644 --- a/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs @@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities; namespace Bit.Admin.AdminConsole.Models; -public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject +public class CreateBusinessUnitProviderModel : IValidatableObject { [Display(Name = "Owner Email")] public string OwnerEmail { get; set; } @@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject { return new Provider { - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; } @@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject { if (string.IsNullOrWhiteSpace(OwnerEmail)) { - var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); + var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); } if (EnterpriseSeatMinimum < 0) { - var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseSeatMinimum); + var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseSeatMinimum); yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative."); } if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly) { - var planDisplayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); + var planDisplayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly."); } } diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index bcdf602c07..7f8ffb224e 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -34,7 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; - if (Type == ProviderType.MultiOrganizationEnterprise) + if (Type == ProviderType.BusinessUnit) { var plan = providerPlans.SingleOrDefault(); EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0; @@ -100,7 +100,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); } break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: if (Plan == null) { var displayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index 724e6220b3..bcb96df006 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -40,7 +40,7 @@ public class ProviderViewModel ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats)); } } - else if (Provider.Type == ProviderType.MultiOrganizationEnterprise) + else if (Provider.Type == ProviderType.BusinessUnit) { var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly) .Sum(po => po.OccupiedSeats).GetValueOrDefault(0); diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 3ac716a6d4..f240cb192f 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,8 +1,13 @@ @using Bit.Admin.Enums; @using Bit.Admin.Models +@using Bit.Core +@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums -@using Bit.Core.Enums +@using Bit.Core.Billing.Extensions +@using Bit.Core.Services +@using Microsoft.AspNetCore.Mvc.TagHelpers @inject Bit.Admin.Services.IAccessControlService AccessControlService +@inject IFeatureService FeatureService @model OrganizationEditModel @{ ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name; @@ -13,6 +18,13 @@ var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete); var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); + + var canConvertToBusinessUnit = + FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) && + AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && + Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && + !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && + Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; } @section Scripts { @@ -114,6 +126,15 @@ Enterprise Trial } + @if (canConvertToBusinessUnit) + { + + Convert to Business Unit + + } @if (canUnlinkFromProvider && Model.Provider is not null) { + + } + @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +

This organization has a business unit conversion in progress.

+ +
+ + +
+ +
+ + + + +
+ + +
+ @if (Model.ProviderId.HasValue) + { + + Go to Provider + + } +
+} +else +{ +

Convert @Model.Organization.Name to Business Unit

+ @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +
+
+
+ + +
+ +
+} diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 4edcd742b4..704fd770bb 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -38,6 +38,7 @@ public enum Permission Org_Billing_View, Org_Billing_Edit, Org_Billing_LaunchGateway, + Org_Billing_ConvertToBusinessUnit, Provider_List_View, Provider_Create, diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index 3b510781be..f342dfce7c 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -42,6 +42,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Provider_List_View, Permission.Provider_Create, Permission.Provider_View, @@ -90,6 +91,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Org_InitiateTrial, Permission.Provider_List_View, Permission.Provider_Create, @@ -166,6 +168,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Org_RequestDelete, Permission.Provider_Edit, Permission.Provider_View, diff --git a/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs index bdfec26242..9cc5b89ee8 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs @@ -22,6 +22,7 @@ public class ProfileProviderResponseModel : ResponseModel UserId = provider.UserId; UseEvents = provider.UseEvents; ProviderStatus = provider.ProviderStatus; + ProviderType = provider.ProviderType; } public Guid Id { get; set; } @@ -35,4 +36,5 @@ public class ProfileProviderResponseModel : ResponseModel public Guid? UserId { get; set; } public bool UseEvents { get; set; } public ProviderStatusType ProviderStatus { get; set; } + public ProviderType ProviderType { get; set; } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 2ec503281e..1d4ebc1511 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -2,6 +2,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; @@ -18,7 +19,9 @@ namespace Bit.Api.Billing.Controllers; [Route("organizations/{organizationId:guid}/billing")] [Authorize("Application")] public class OrganizationBillingController( + IBusinessUnitConverter businessUnitConverter, ICurrentContext currentContext, + IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, @@ -296,4 +299,40 @@ public class OrganizationBillingController( return TypedResults.Ok(); } + + [HttpPost("setup-business-unit")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task SetupBusinessUnitAsync( + [FromRoute] Guid organizationId, + [FromBody] SetupBusinessUnitRequestBody requestBody) + { + var enableOrganizationBusinessUnitConversion = + featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion); + + if (!enableOrganizationBusinessUnitConversion) + { + return Error.NotFound(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return Error.NotFound(); + } + + if (!await currentContext.OrganizationUser(organizationId)) + { + return Error.Unauthorized(); + } + + var providerId = await businessUnitConverter.FinalizeConversion( + organization, + requestBody.UserId, + requestBody.Token, + requestBody.ProviderKey, + requestBody.OrganizationKey); + + return TypedResults.Ok(providerId); + } } diff --git a/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs new file mode 100644 index 0000000000..c4b87a01f5 --- /dev/null +++ b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests; + +public class SetupBusinessUnitRequestBody +{ + [Required] + public Guid UserId { get; set; } + + [Required] + public string Token { get; set; } + + [Required] + public string ProviderKey { get; set; } + + [Required] + public string OrganizationKey { get; set; } +} diff --git a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs index e244b9391e..8e229ed508 100644 --- a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs +++ b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs @@ -8,6 +8,6 @@ public enum ProviderType : byte Msp = 0, [Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)] Reseller = 1, - [Display(ShortName = "MOE", Name = "Multi-organization Enterprises", Description = "Creates provider portal for multi-organization management", Order = 1)] - MultiOrganizationEnterprise = 2, + [Display(ShortName = "Business Unit", Name = "Business Unit", Description = "Creates provider portal for business unit management", Order = 1)] + BusinessUnit = 2, } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs index 23f4e1d57a..67565bad6d 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs @@ -17,4 +17,5 @@ public class ProviderUserProviderDetails public string Permissions { get; set; } public bool UseEvents { get; set; } public ProviderStatusType ProviderStatus { get; set; } + public ProviderType ProviderType { get; set; } } diff --git a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs index bea3c08a85..b2484bf632 100644 --- a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs +++ b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs @@ -7,5 +7,5 @@ public interface ICreateProviderCommand { Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateResellerAsync(Provider provider); - Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); + Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 4fb97e1db7..c8a1496726 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -25,19 +25,19 @@ public static class BillingExtensions public static bool IsBillable(this Provider provider) => provider is { - Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Type: ProviderType.Msp or ProviderType.BusinessUnit, Status: ProviderStatusType.Billable }; public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) => inviteOrganizationProvider is { - Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Type: ProviderType.Msp or ProviderType.BusinessUnit, Status: ProviderStatusType.Billable }; public static bool SupportsConsolidatedBilling(this ProviderType providerType) - => providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; + => providerType is ProviderType.Msp or ProviderType.BusinessUnit; public static bool IsValidClient(this Organization organization) => organization is diff --git a/src/Core/Billing/Services/IBusinessUnitConverter.cs b/src/Core/Billing/Services/IBusinessUnitConverter.cs new file mode 100644 index 0000000000..06ff883eae --- /dev/null +++ b/src/Core/Billing/Services/IBusinessUnitConverter.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using OneOf; + +namespace Bit.Core.Billing.Services; + +public interface IBusinessUnitConverter +{ + /// + /// Finalizes the process of converting the to a by + /// saving all the necessary key provided by the client and updating the 's subscription to a + /// provider subscription. + /// + /// The organization to convert to a business unit. + /// The ID of the organization member who will be the provider admin. + /// The token sent to the client as part of the process. + /// The encrypted provider key used to enable the . + /// The encrypted organization key used to enable the . + /// The provider ID + Task FinalizeConversion( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey); + + /// + /// Begins the process of converting the to a by + /// creating all the necessary database entities and sending a setup invitation to the . + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + /// Either the newly created provider ID or a list of validation failures. + Task>> InitiateConversion( + Organization organization, + string providerAdminEmail); + + /// + /// Checks if the has a business unit conversion in progress and, if it does, resends the + /// setup invitation to the provider admin. + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + Task ResendConversionInvite( + Organization organization, + string providerAdminEmail); + + /// + /// Checks if the has a business unit conversion in progress and, if it does, resets that conversion + /// by deleting all the database entities created as part of . + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + Task ResetConversion( + Organization organization, + string providerAdminEmail); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 33dec32e34..842c6b6341 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -147,6 +147,7 @@ public static class FeatureFlagKeys public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; + public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs new file mode 100644 index 0000000000..59da019839 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs @@ -0,0 +1,19 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ You have been invited to set up a new Business Unit Portal within Bitwarden. +
+
+
+ + Set Up Business Unit Portal Now + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs new file mode 100644 index 0000000000..b2973f32c2 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} + You have been invited to set up a new Business Unit Portal within Bitwarden. To continue, click the following link: + + {{{Url}}} +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs new file mode 100644 index 0000000000..328d37058b --- /dev/null +++ b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Models.Mail.Billing; + +public class BusinessUnitConversionInviteModel : BaseMailModel +{ + public string OrganizationId { get; set; } + public string Email { get; set; } + public string Token { get; set; } + + public string Url => + $"{WebVaultUrl}/providers/setup-business-unit?organizationId={OrganizationId}&email={Email}&token={Token}"; +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 48e0464905..805c143173 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -70,6 +70,7 @@ public interface IMailService Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage); Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName); Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email); + Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email); Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); Task SendProviderConfirmedEmailAsync(string providerName, string email); Task SendProviderUserRemoved(string providerName, string email); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 7bcf2c0ef5..81e17e7c6f 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; using Bit.Core.SecretsManager.Models.Mail; @@ -949,6 +950,22 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email) + { + var message = CreateDefaultMessage("Set Up Business Unit", email); + var model = new BusinessUnitConversionInviteModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + OrganizationId = organization.Id.ToString(), + Email = WebUtility.UrlEncode(email), + Token = WebUtility.UrlEncode(token) + }; + await AddMessageContentAsync(message, "Billing.BusinessUnitConversionInvite", model); + message.Category = "BusinessUnitConversionInvite"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) { var message = CreateDefaultMessage($"Join {providerName}", email); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index f6b27b0670..381af2fd1c 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -212,6 +212,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email) + { + return Task.FromResult(0); + } + public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) { return Task.FromResult(0); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index a5f4f0bd9d..26c782000e 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -332,7 +332,7 @@ public class OrganizationRepository : Repository PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes), - ProviderType.MultiOrganizationEnterprise => PlanConstants.EnterprisePlanTypes, + ProviderType.BusinessUnit => PlanConstants.EnterprisePlanTypes, _ => [] }; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs index 6469ffc9ce..231f587429 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs @@ -35,6 +35,7 @@ public class ProviderUserProviderDetailsReadByUserIdStatusQuery : IQuery sutProvider) { // Arrange // Act - var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + var actual = await sutProvider.Sut.CreateBusinessUnit(model); // Assert Assert.NotNull(actual); await sutProvider.GetDependency() .Received(Quantity.Exactly(1)) - .CreateMultiOrganizationEnterpriseAsync( - Arg.Is(x => x.Type == ProviderType.MultiOrganizationEnterprise), + .CreateBusinessUnitAsync( + Arg.Is(x => x.Type == ProviderType.BusinessUnit), model.OwnerEmail, Arg.Is(y => y == model.Plan), model.EnterpriseSeatMinimum); @@ -102,16 +102,16 @@ public class ProvidersControllerTests [BitAutoData] [SutProviderCustomize] [Theory] - public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToExpectedPage_AfterCreatingProvider( - CreateMultiOrganizationEnterpriseProviderModel model, + public async Task CreateBusinessUnitAsync_RedirectsToExpectedPage_AfterCreatingProvider( + CreateBusinessUnitProviderModel model, Guid expectedProviderId, SutProvider sutProvider) { // Arrange sutProvider.GetDependency() .When(x => - x.CreateMultiOrganizationEnterpriseAsync( - Arg.Is(y => y.Type == ProviderType.MultiOrganizationEnterprise), + x.CreateBusinessUnitAsync( + Arg.Is(y => y.Type == ProviderType.BusinessUnit), model.OwnerEmail, Arg.Is(y => y == model.Plan), model.EnterpriseSeatMinimum)) @@ -122,7 +122,7 @@ public class ProvidersControllerTests }); // Act - var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + var actual = await sutProvider.Sut.CreateBusinessUnit(model); // Assert Assert.NotNull(actual); From cb9d7e450f3b1cb2fb7f3e13e62adea9cc848b6b Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:44:31 -0600 Subject: [PATCH 09/17] Drop create_branch input, it's enabled by default. (#5634) --- .github/workflows/build.yml | 1 - .github/workflows/ephemeral-environment.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35c2070df3..33edd075a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -638,7 +638,6 @@ jobs: with: project: server pull_request_number: ${{ github.event.number }} - create_branch: true secrets: inherit check-failures: diff --git a/.github/workflows/ephemeral-environment.yml b/.github/workflows/ephemeral-environment.yml index 699a28c6fb..6dd89536b6 100644 --- a/.github/workflows/ephemeral-environment.yml +++ b/.github/workflows/ephemeral-environment.yml @@ -13,5 +13,4 @@ jobs: project: server pull_request_number: ${{ github.event.number }} sync_environment: true - create_branch: true secrets: inherit From a1016b4df9098dd0c15f4fbb5032e1b916b9b815 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 10 Apr 2025 11:28:53 -0700 Subject: [PATCH 10/17] Fix feature flag key value (#5636) --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 842c6b6341..e85eee9a7d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -197,7 +197,7 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; - public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; + public const string DesktopCipherForm = "pm-18520-desktop-cipher-form"; public static List GetAllKeys() { From 0b50a1819ef2895d7b3add307093872b8a6e11e7 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Thu, 10 Apr 2025 14:55:40 -0400 Subject: [PATCH 11/17] Added feature flag (#5632) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e85eee9a7d..23c51d5974 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -198,6 +198,7 @@ public static class FeatureFlagKeys public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string DesktopCipherForm = "pm-18520-desktop-cipher-form"; + public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public static List GetAllKeys() { From d553d52c93da67a503cb9f2598baa94481e921df Mon Sep 17 00:00:00 2001 From: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:24:16 -0400 Subject: [PATCH 12/17] revert back to plural key value (#5638) --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 23c51d5974..2b53c52ab8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -197,7 +197,7 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; - public const string DesktopCipherForm = "pm-18520-desktop-cipher-form"; + public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public static List GetAllKeys() From dff00e613d16789a4df822bfcdf3a2ee42947289 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 11 Apr 2025 09:34:51 -0400 Subject: [PATCH 13/17] Add invoice null check (#5642) --- .../Services/Implementations/OrganizationBillingService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index ae9ed15e72..5f5b803662 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -93,7 +93,9 @@ public class OrganizationBillingService( var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); - var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()); + var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId) + ? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()) + : null; return new OrganizationMetadata( isEligibleForSelfHost, From bfe5ecda92c3ce116fb383d528f15e3f76178c86 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 11 Apr 2025 15:59:54 -0400 Subject: [PATCH 14/17] Add UpdateCiphersAsync Test (#5543) * Add UpdateCiphersAsync Test * Fix UpdateCiphersAsync * Fix #2 * Fix SQL Server * Formatting --- .../Vault/Repositories/CipherRepository.cs | 2 +- .../Vault/Repositories/CipherRepository.cs | 26 +++++++++++++++-- .../Repositories/CipherRepositoryTests.cs | 29 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index b85f1991f7..3df365330c 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -711,7 +711,7 @@ public class CipherRepository : Repository, ICipherRepository row[creationDateColumn] = cipher.CreationDate; row[revisionDateColumn] = cipher.RevisionDate; row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; - row[repromptColumn] = cipher.Reprompt; + row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value; row[keyColummn] = cipher.Key; ciphersTable.Rows.Add(row); diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index e4930cb795..090c36ff29 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -863,8 +863,30 @@ public class CipherRepository : Repository>(ciphers); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, entities); + var ciphersToUpdate = ciphers.ToDictionary(c => c.Id); + + var existingCiphers = await dbContext.Ciphers + .Where(c => c.UserId == userId && ciphersToUpdate.Keys.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + foreach (var (cipherId, cipher) in ciphersToUpdate) + { + if (!existingCiphers.TryGetValue(cipherId, out var existingCipher)) + { + // The Dapper version does not validate that the same amount of items given where updated. + continue; + } + + existingCipher.UserId = cipher.UserId; + existingCipher.OrganizationId = cipher.OrganizationId; + existingCipher.Type = cipher.Type; + existingCipher.Data = cipher.Data; + existingCipher.Attachments = cipher.Attachments; + existingCipher.RevisionDate = cipher.RevisionDate; + existingCipher.DeletedDate = cipher.DeletedDate; + existingCipher.Key = cipher.Key; + } + await dbContext.UserBumpAccountRevisionDateAsync(userId); await dbContext.SaveChangesAsync(); } diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index 6f02740cf5..fde625e272 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -883,4 +883,33 @@ public class CipherRepositoryTests Assert.Contains(user2TaskCiphers, t => t.CipherId == manageCipher1.Id && t.TaskId == securityTasks[0].Id); Assert.Contains(user2TaskCiphers, t => t.CipherId == manageCipher2.Id && t.TaskId == securityTasks[1].Id); } + + [DatabaseTheory, DatabaseData] + public async Task UpdateCiphersAsync_Works(ICipherRepository cipherRepository, IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var cipher1 = await CreatePersonalCipher(user, cipherRepository); + var cipher2 = await CreatePersonalCipher(user, cipherRepository); + + cipher1.Type = CipherType.SecureNote; + cipher2.Attachments = "new_attachments"; + + await cipherRepository.UpdateCiphersAsync(user.Id, [cipher1, cipher2]); + + var updatedCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id); + var updatedCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id); + + Assert.NotNull(updatedCipher1); + Assert.NotNull(updatedCipher2); + + Assert.Equal(CipherType.SecureNote, updatedCipher1.Type); + Assert.Equal("new_attachments", updatedCipher2.Attachments); + } } From c986cbb2086f296c7a0df9afec9aa7e8b981dd06 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:10:37 -0400 Subject: [PATCH 15/17] Added IdentityServer directories to Auth ownership. (#5647) --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 03dbb7aac4..973405dea5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -37,6 +37,8 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev **/Auth @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev src/Identity @bitwarden/team-auth-dev +src/Core/Identity @bitwarden/team-auth-dev +src/Core/IdentityServer @bitwarden/team-auth-dev # Key Management team **/KeyManagement @bitwarden/team-key-management-dev 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 16/17] [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, + }; } } From c9a42d861cb87e4f1c17a42194dc55b341826331 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 14 Apr 2025 12:48:52 -0500 Subject: [PATCH 17/17] [PM-17987] Add feature flag (#5554) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2b53c52ab8..3a31480109 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -156,6 +156,7 @@ public static class FeatureFlagKeys public const string Argon2Default = "argon2-default"; public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; + public const string PM17987_BlockType0 = "pm-17987-block-type-0"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow";