diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs index d0411f9bc5..b4eecdba0f 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs @@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request; using Bit.Api.Models.Request.Organizations; using Bit.Api.Utilities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Queries; @@ -28,7 +29,7 @@ public class SelfHostedOrganizationLicensesController : Controller private readonly ICurrentContext _currentContext; private readonly IGetSelfHostedOrganizationLicenseQuery _getSelfHostedOrganizationLicenseQuery; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; - private readonly IOrganizationService _organizationService; + private readonly ISelfHostedOrganizationSignUpCommand _selfHostedOrganizationSignUpCommand; private readonly IOrganizationRepository _organizationRepository; private readonly IUserService _userService; private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; @@ -37,7 +38,7 @@ public class SelfHostedOrganizationLicensesController : Controller ICurrentContext currentContext, IGetSelfHostedOrganizationLicenseQuery getSelfHostedOrganizationLicenseQuery, IOrganizationConnectionRepository organizationConnectionRepository, - IOrganizationService organizationService, + ISelfHostedOrganizationSignUpCommand selfHostedOrganizationSignUpCommand, IOrganizationRepository organizationRepository, IUserService userService, IUpdateOrganizationLicenseCommand updateOrganizationLicenseCommand) @@ -45,7 +46,7 @@ public class SelfHostedOrganizationLicensesController : Controller _currentContext = currentContext; _getSelfHostedOrganizationLicenseQuery = getSelfHostedOrganizationLicenseQuery; _organizationConnectionRepository = organizationConnectionRepository; - _organizationService = organizationService; + _selfHostedOrganizationSignUpCommand = selfHostedOrganizationSignUpCommand; _organizationRepository = organizationRepository; _userService = userService; _updateOrganizationLicenseCommand = updateOrganizationLicenseCommand; @@ -66,7 +67,7 @@ public class SelfHostedOrganizationLicensesController : Controller throw new BadRequestException("Invalid license"); } - var result = await _organizationService.SignUpAsync(license, user, model.Key, + var result = await _selfHostedOrganizationSignUpCommand.SignUpAsync(license, user, model.Key, model.CollectionName, model.Keys?.PublicKey, model.Keys?.EncryptedPrivateKey); return new OrganizationResponseModel(result.Item1, null); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..2686384a34 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface ISelfHostedOrganizationSignUpCommand +{ + /// + /// Create a new organization on a self-hosted instance + /// + Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( + OrganizationLicense license, User owner, string ownerKey, + string? collectionName, string publicKey, string privateKey); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..c52b7c10c9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUpCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; + private readonly IPushRegistrationService _pushRegistrationService; + private readonly IPushNotificationService _pushNotificationService; + private readonly IDeviceRepository _deviceRepository; + private readonly ILicensingService _licensingService; + private readonly IPolicyService _policyService; + private readonly IGlobalSettings _globalSettings; + private readonly IPaymentService _paymentService; + + public SelfHostedOrganizationSignUpCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository, + IPushRegistrationService pushRegistrationService, + IPushNotificationService pushNotificationService, + IDeviceRepository deviceRepository, + ILicensingService licensingService, + IPolicyService policyService, + IGlobalSettings globalSettings, + IPaymentService paymentService) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; + _pushRegistrationService = pushRegistrationService; + _pushNotificationService = pushNotificationService; + _deviceRepository = deviceRepository; + _licensingService = licensingService; + _policyService = policyService; + _globalSettings = globalSettings; + _paymentService = paymentService; + } + + public async Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( + OrganizationLicense license, User owner, string ownerKey, string? collectionName, string publicKey, + string privateKey) + { + if (license.LicenseType != LicenseType.Organization) + { + throw new BadRequestException("Premium licenses cannot be applied to an organization. " + + "Upload this license from your personal account settings page."); + } + + var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception); + + if (!canUse) + { + throw new BadRequestException(exception); + } + + var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); + if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey))) + { + throw new BadRequestException("License is already in use by another organization."); + } + + await ValidateSignUpPoliciesAsync(owner.Id); + + var organization = claimsPrincipal != null + // If the ClaimsPrincipal exists (there's a token on the license), use it to build the organization. + ? OrganizationFactory.Create(owner, claimsPrincipal, publicKey, privateKey) + // If there's no ClaimsPrincipal (there's no token on the license), use the license to build the organization. + : OrganizationFactory.Create(owner, license, publicKey, privateKey); + + var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); + + var dir = $"{_globalSettings.LicenseDirectory}/organization"; + Directory.CreateDirectory(dir); + await using var fs = new FileStream(Path.Combine(dir, $"{organization.Id}.json"), FileMode.Create); + await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); + return (result.organization, result.organizationUser); + } + + private async Task ValidateSignUpPoliciesAsync(Guid ownerId) + { + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + + /// + /// Private helper method to create a new organization. + /// This is common code used by both the cloud and self-hosted methods. + /// + private async Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> + SignUpAsync(Organization organization, + Guid ownerId, string ownerKey, string? collectionName, bool withPayment) + { + try + { + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + // ownerId == default if the org is created by a provider - in this case it's created without an + // owner and the first owner is immediately invited afterwards + OrganizationUser? orgUser = null; + if (ownerId != default) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = ownerId, + Key = ownerKey, + AccessSecretsManager = organization.UseSecretsManager, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + orgUser.SetNewId(); + + await _organizationUserRepository.CreateAsync(orgUser); + + var devices = await GetUserDeviceIdsAsync(orgUser.UserId!.Value); + await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, + organization.Id.ToString()); + await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); + } + + Collection? defaultCollection = null; + if (!string.IsNullOrWhiteSpace(collectionName)) + { + defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + // Give the owner Can Manage access over the default collection + List? defaultOwnerAccess = null; + if (orgUser != null) + { + defaultOwnerAccess = + [ + new CollectionAccessSelection + { + Id = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + } + ]; + } + + await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); + } + + return (organization, orgUser, defaultCollection); + } + catch + { + if (withPayment) + { + await _paymentService.CancelAndRecoverChargesAsync(organization); + } + + if (organization.Id != default(Guid)) + { + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } + + private async Task> GetUserDeviceIdsAsync(Guid userId) + { + var devices = await _deviceRepository.GetManyByUserIdAsync(userId); + return devices + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => d.Id.ToString()); + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 2fa6772c62..bec9507adf 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Auth.Enums; -using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -21,11 +20,6 @@ public interface IOrganizationService Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); - /// - /// Create a new organization on a self-hosted instance - /// - Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, - string ownerKey, string collectionName, string publicKey, string privateKey); Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 3e81494fc3..4f25f5fc53 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -20,7 +20,6 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -396,155 +395,6 @@ public class OrganizationService : IOrganizationService } } - private async Task ValidateSignUpPoliciesAsync(Guid ownerId) - { - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - throw new BadRequestException("You may not create an organization. You belong to an organization " + - "which has a policy that prohibits you from being a member of any other organization."); - } - } - - /// - /// Create a new organization on a self-hosted instance - /// - public async Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync( - OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, - string privateKey) - { - if (license.LicenseType != LicenseType.Organization) - { - throw new BadRequestException("Premium licenses cannot be applied to an organization. " + - "Upload this license from your personal account settings page."); - } - - var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); - var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception); - - if (!canUse) - { - throw new BadRequestException(exception); - } - - var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); - if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey))) - { - throw new BadRequestException("License is already in use by another organization."); - } - - await ValidateSignUpPoliciesAsync(owner.Id); - - var organization = claimsPrincipal != null - // If the ClaimsPrincipal exists (there's a token on the license), use it to build the organization. - ? OrganizationFactory.Create(owner, claimsPrincipal, publicKey, privateKey) - // If there's no ClaimsPrincipal (there's no token on the license), use the license to build the organization. - : OrganizationFactory.Create(owner, license, publicKey, privateKey); - - var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); - - var dir = $"{_globalSettings.LicenseDirectory}/organization"; - Directory.CreateDirectory(dir); - await using var fs = new FileStream(Path.Combine(dir, $"{organization.Id}.json"), FileMode.Create); - await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); - return (result.organization, result.organizationUser); - } - - /// - /// Private helper method to create a new organization. - /// This is common code used by both the cloud and self-hosted methods. - /// - private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> - SignUpAsync(Organization organization, - Guid ownerId, string ownerKey, string collectionName, bool withPayment) - { - try - { - await _organizationRepository.CreateAsync(organization); - await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey - { - OrganizationId = organization.Id, - ApiKey = CoreHelpers.SecureRandomString(30), - Type = OrganizationApiKeyType.Default, - RevisionDate = DateTime.UtcNow, - }); - await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - // ownerId == default if the org is created by a provider - in this case it's created without an - // owner and the first owner is immediately invited afterwards - OrganizationUser orgUser = null; - if (ownerId != default) - { - orgUser = new OrganizationUser - { - OrganizationId = organization.Id, - UserId = ownerId, - Key = ownerKey, - AccessSecretsManager = organization.UseSecretsManager, - Type = OrganizationUserType.Owner, - Status = OrganizationUserStatusType.Confirmed, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - orgUser.SetNewId(); - - await _organizationUserRepository.CreateAsync(orgUser); - - var devices = await GetUserDeviceIdsAsync(orgUser.UserId.Value); - await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, - organization.Id.ToString()); - await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); - } - - Collection defaultCollection = null; - if (!string.IsNullOrWhiteSpace(collectionName)) - { - defaultCollection = new Collection - { - Name = collectionName, - OrganizationId = organization.Id, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - - // Give the owner Can Manage access over the default collection - List defaultOwnerAccess = null; - if (orgUser != null) - { - defaultOwnerAccess = - [ - new CollectionAccessSelection - { - Id = orgUser.Id, - HidePasswords = false, - ReadOnly = false, - Manage = true - } - ]; - } - - await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); - } - - return (organization, orgUser, defaultCollection); - } - catch - { - if (withPayment) - { - await _paymentService.CancelAndRecoverChargesAsync(organization); - } - - if (organization.Id != default(Guid)) - { - await _organizationRepository.DeleteAsync(organization); - await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); - } - - throw; - } - } - public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate) { var org = await GetOrgById(organizationId); @@ -1338,14 +1188,6 @@ public class OrganizationService : IOrganizationService await _groupRepository.UpdateUsersAsync(group.Id, users); } - private async Task> GetUserDeviceIdsAsync(Guid userId) - { - var devices = await _deviceRepository.GetManyByUserIdAsync(userId); - return devices - .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) - .Select(d => d.Id.ToString()); - } - public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) { await _organizationRepository.ReplaceAsync(org); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index ae24017e48..b78a305d31 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -71,6 +71,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationDeleteCommands(this IServiceCollection services) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommandTests.cs new file mode 100644 index 0000000000..26c092797b --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommandTests.cs @@ -0,0 +1,351 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class SelfHostedOrganizationSignUpCommandTests +{ + [Theory, BitAutoData] + public async Task SignUpAsync_WithValidRequest_CreatesOrganizationSuccessfully( + User owner, string ownerKey, string collectionName, string publicKey, + string privateKey, List devices, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(owner.Id) + .Returns(devices); + + // Act + var result = await sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey); + + // Assert + Assert.NotNull(result.organization); + Assert.NotNull(result.organizationUser); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(result.organization); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(key => + key.OrganizationId == result.organization.Id && + key.Type == OrganizationApiKeyType.Default && + !string.IsNullOrEmpty(key.ApiKey))); + + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(result.organization); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(user => + user.OrganizationId == result.organization.Id && + user.UserId == owner.Id && + user.Key == ownerKey && + user.Type == OrganizationUserType.Owner && + user.Status == OrganizationUserStatusType.Confirmed)); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(c => c.Name == collectionName && c.OrganizationId == result.organization.Id), + Arg.Is>(groups => groups == null), + Arg.Is>(access => + access.Any(a => a.Id == result.organizationUser.Id && a.Manage && !a.ReadOnly && !a.HidePasswords))); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(owner.Id); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithPremiumLicense_ThrowsBadRequestException( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings, LicenseType.User); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey)); + + Assert.Contains("Premium licenses cannot be applied to an organization", exception.Message); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithInvalidLicense_ThrowsBadRequestException( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + license.CanUse(globalSettings, sutProvider.GetDependency(), null, out _) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey)); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithLicenseAlreadyInUse_ThrowsBadRequestException( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, Organization existingOrganization, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + existingOrganization.LicenseKey = license.LicenseKey; + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + sutProvider.GetDependency() + .GetManyByEnabledAsync() + .Returns(new List { existingOrganization }); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey)); + + Assert.Contains("License is already in use", exception.Message); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithSingleOrgPolicy_ThrowsBadRequestException( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(owner.Id, PolicyType.SingleOrg) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey)); + + Assert.Contains("You may not create an organization", exception.Message); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithClaimsPrincipal_UsesClaimsPrincipalToCreateOrganization( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns(claimsPrincipal); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(owner.Id) + .Returns(new List()); + + // Act + var result = await sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey); + + // Assert + Assert.NotNull(result.organization); + Assert.NotNull(result.organizationUser); + + sutProvider.GetDependency() + .Received(1) + .GetClaimsPrincipalFromLicense(license); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithoutCollectionName_DoesNotCreateCollection( + User owner, string ownerKey, string publicKey, string privateKey, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(owner.Id) + .Returns(new List()); + + // Act + var result = await sutProvider.Sut.SignUpAsync(license, owner, ownerKey, null, publicKey, privateKey); + + // Assert + Assert.NotNull(result.organization); + Assert.NotNull(result.organizationUser); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any(), Arg.Is>(x => true), Arg.Is>(x => true)); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithDevices_RegistersDevicesForPushNotifications( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, List devices, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + foreach (var device in devices) + { + device.PushToken = "push-token-" + device.Id; + } + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(owner.Id) + .Returns(devices); + + // Act + var result = await sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey); + + // Assert + Assert.NotNull(result.organization); + Assert.NotNull(result.organizationUser); + + var expectedDeviceIds = devices.Select(d => d.Id.ToString()); + await sutProvider.GetDependency() + .Received(1) + .AddUserRegistrationOrganizationAsync( + Arg.Is>(ids => ids.SequenceEqual(expectedDeviceIds)), + result.organization.Id.ToString()); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_OnException_CleansUpOrganization( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Throws(new Exception("Test exception")); + + // Act & Assert + await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey)); + + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(Arg.Any()); + + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(Arg.Any()); + } + + private void SetupCommonMocks( + SutProvider sutProvider, + User owner) + { + var globalSettings = sutProvider.GetDependency(); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(callInfo => + { + var org = callInfo.Arg(); + org.Id = Guid.NewGuid(); + return Task.FromResult(org); + }); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(owner.Id, PolicyType.SingleOrg) + .Returns(false); + + globalSettings.LicenseDirectory.Returns("/tmp/licenses"); + } + + private void SetupLicenseValidation( + SutProvider sutProvider, + OrganizationLicense license) + { + var globalSettings = sutProvider.GetDependency(); + + sutProvider.GetDependency() + .VerifyLicense(license) + .Returns(true); + + license.CanUse(globalSettings, sutProvider.GetDependency(), null, out _) + .Returns(true); + } + + private OrganizationLicense CreateValidOrganizationLicense( + IGlobalSettings globalSettings, + LicenseType licenseType = LicenseType.Organization) + { + return new OrganizationLicense + { + LicenseType = licenseType, + Signature = Guid.NewGuid().ToString().Replace('-', '+'), + Issued = DateTime.UtcNow.AddDays(-1), + Expires = DateTime.UtcNow.AddDays(10), + Version = OrganizationLicense.CurrentLicenseFileVersion, + InstallationId = globalSettings.Installation.Id, + Enabled = true, + SelfHost = true + }; + } +}