[PM-15159] Create SelfHostedOrganizationSignUp command (#6089)

* Add SelfHostedOrganizationSignUpCommand for organization sign-up process

Method extracted from OrganizationService

* Register SelfHostedOrganizationSignUpCommand for dependency injection

* Add unit tests for SelfHostedOrganizationSignUpCommand

* Refactor SelfHostedOrganizationLicensesController to use ISelfHostedOrganizationSignUpCommand

* Remove SignUpAsync method and related validation from IOrganizationService and OrganizationService

* Move ISelfHostedOrganizationSignUpCommand into a separate file and update references

* Enable null safety in SelfHostedOrganizationSignUpCommand and update ISelfHostedOrganizationSignUpCommand interface to reflect nullable types for organizationUser and collectionName.
This commit is contained in:
Rui Tomé 2025-07-21 14:35:41 +01:00 committed by GitHub
parent 79661dd5f5
commit 4464bfe900
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 588 additions and 168 deletions

View File

@ -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);

View File

@ -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
{
/// <summary>
/// Create a new organization on a self-hosted instance
/// </summary>
Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync(
OrganizationLicense license, User owner, string ownerKey,
string? collectionName, string publicKey, string privateKey);
}

View File

@ -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.");
}
}
/// <summary>
/// Private helper method to create a new organization.
/// This is common code used by both the cloud and self-hosted methods.
/// </summary>
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<CollectionAccessSelection>? 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<IEnumerable<string>> GetUserDeviceIdsAsync(Guid userId)
{
var devices = await _deviceRepository.GetManyByUserIdAsync(userId);
return devices
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString());
}
}

View File

@ -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<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment);
Task VerifyBankAsync(Guid organizationId, int amount1, int amount2);
/// <summary>
/// Create a new organization on a self-hosted instance
/// </summary>
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);

View File

@ -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.");
}
}
/// <summary>
/// Create a new organization on a self-hosted instance
/// </summary>
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);
}
/// <summary>
/// Private helper method to create a new organization.
/// This is common code used by both the cloud and self-hosted methods.
/// </summary>
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<CollectionAccessSelection> 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<IEnumerable<string>> 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);

View File

@ -71,6 +71,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<ICloudOrganizationSignUpCommand, CloudOrganizationSignUpCommand>();
services.AddScoped<IProviderClientOrganizationSignUpCommand, ProviderClientOrganizationSignUpCommand>();
services.AddScoped<IResellerClientOrganizationSignUpCommand, ResellerClientOrganizationSignUpCommand>();
services.AddScoped<ISelfHostedOrganizationSignUpCommand, SelfHostedOrganizationSignUpCommand>();
}
private static void AddOrganizationDeleteCommands(this IServiceCollection services)

View File

@ -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<Device> devices,
SutProvider<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
SetupCommonMocks(sutProvider, owner);
SetupLicenseValidation(sutProvider, license);
sutProvider.GetDependency<IDeviceRepository>()
.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<IOrganizationRepository>()
.Received(1)
.CreateAsync(result.organization);
await sutProvider.GetDependency<IOrganizationApiKeyRepository>()
.Received(1)
.CreateAsync(Arg.Is<OrganizationApiKey>(key =>
key.OrganizationId == result.organization.Id &&
key.Type == OrganizationApiKeyType.Default &&
!string.IsNullOrEmpty(key.ApiKey)));
await sutProvider.GetDependency<IApplicationCacheService>()
.Received(1)
.UpsertOrganizationAbilityAsync(result.organization);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateAsync(Arg.Is<OrganizationUser>(user =>
user.OrganizationId == result.organization.Id &&
user.UserId == owner.Id &&
user.Key == ownerKey &&
user.Type == OrganizationUserType.Owner &&
user.Status == OrganizationUserStatusType.Confirmed));
await sutProvider.GetDependency<ICollectionRepository>()
.Received(1)
.CreateAsync(
Arg.Is<Collection>(c => c.Name == collectionName && c.OrganizationId == result.organization.Id),
Arg.Is<IEnumerable<CollectionAccessSelection>>(groups => groups == null),
Arg.Is<IEnumerable<CollectionAccessSelection>>(access =>
access.Any(a => a.Id == result.organizationUser.Id && a.Manage && !a.ReadOnly && !a.HidePasswords)));
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncOrgKeysAsync(owner.Id);
}
[Theory, BitAutoData]
public async Task SignUpAsync_WithPremiumLicense_ThrowsBadRequestException(
User owner, string ownerKey, string collectionName,
string publicKey, string privateKey,
SutProvider<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings, LicenseType.User);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
license.CanUse(globalSettings, sutProvider.GetDependency<ILicensingService>(), null, out _)
.Returns(false);
// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(
() => 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<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
existingOrganization.LicenseKey = license.LicenseKey;
SetupCommonMocks(sutProvider, owner);
SetupLicenseValidation(sutProvider, license);
sutProvider.GetDependency<IOrganizationRepository>()
.GetManyByEnabledAsync()
.Returns(new List<Organization> { existingOrganization });
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
SetupCommonMocks(sutProvider, owner);
SetupLicenseValidation(sutProvider, license);
sutProvider.GetDependency<IPolicyService>()
.AnyPoliciesApplicableToUserAsync(owner.Id, PolicyType.SingleOrg)
.Returns(true);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
SetupCommonMocks(sutProvider, owner);
SetupLicenseValidation(sutProvider, license);
sutProvider.GetDependency<ILicensingService>()
.GetClaimsPrincipalFromLicense(license)
.Returns(claimsPrincipal);
sutProvider.GetDependency<IDeviceRepository>()
.GetManyByUserIdAsync(owner.Id)
.Returns(new List<Device>());
// Act
var result = await sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey);
// Assert
Assert.NotNull(result.organization);
Assert.NotNull(result.organizationUser);
sutProvider.GetDependency<ILicensingService>()
.Received(1)
.GetClaimsPrincipalFromLicense(license);
}
[Theory, BitAutoData]
public async Task SignUpAsync_WithoutCollectionName_DoesNotCreateCollection(
User owner, string ownerKey, string publicKey, string privateKey,
SutProvider<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
SetupCommonMocks(sutProvider, owner);
SetupLicenseValidation(sutProvider, license);
sutProvider.GetDependency<IDeviceRepository>()
.GetManyByUserIdAsync(owner.Id)
.Returns(new List<Device>());
// 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<ICollectionRepository>()
.DidNotReceive()
.CreateAsync(Arg.Any<Collection>(), Arg.Is<IEnumerable<CollectionAccessSelection>>(x => true), Arg.Is<IEnumerable<CollectionAccessSelection>>(x => true));
}
[Theory, BitAutoData]
public async Task SignUpAsync_WithDevices_RegistersDevicesForPushNotifications(
User owner, string ownerKey, string collectionName,
string publicKey, string privateKey, List<Device> devices,
SutProvider<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
foreach (var device in devices)
{
device.PushToken = "push-token-" + device.Id;
}
SetupCommonMocks(sutProvider, owner);
SetupLicenseValidation(sutProvider, license);
sutProvider.GetDependency<IDeviceRepository>()
.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<IPushRegistrationService>()
.Received(1)
.AddUserRegistrationOrganizationAsync(
Arg.Is<IEnumerable<string>>(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<SelfHostedOrganizationSignUpCommand> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var license = CreateValidOrganizationLicense(globalSettings);
SetupCommonMocks(sutProvider, owner);
SetupLicenseValidation(sutProvider, license);
sutProvider.GetDependency<IOrganizationApiKeyRepository>()
.CreateAsync(Arg.Any<OrganizationApiKey>())
.Throws(new Exception("Test exception"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey));
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.DeleteAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IApplicationCacheService>()
.Received(1)
.DeleteOrganizationAbilityAsync(Arg.Any<Guid>());
}
private void SetupCommonMocks(
SutProvider<SelfHostedOrganizationSignUpCommand> sutProvider,
User owner)
{
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
sutProvider.GetDependency<IOrganizationRepository>()
.CreateAsync(Arg.Any<Organization>())
.Returns(callInfo =>
{
var org = callInfo.Arg<Organization>();
org.Id = Guid.NewGuid();
return Task.FromResult(org);
});
sutProvider.GetDependency<IPolicyService>()
.AnyPoliciesApplicableToUserAsync(owner.Id, PolicyType.SingleOrg)
.Returns(false);
globalSettings.LicenseDirectory.Returns("/tmp/licenses");
}
private void SetupLicenseValidation(
SutProvider<SelfHostedOrganizationSignUpCommand> sutProvider,
OrganizationLicense license)
{
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
sutProvider.GetDependency<ILicensingService>()
.VerifyLicense(license)
.Returns(true);
license.CanUse(globalSettings, sutProvider.GetDependency<ILicensingService>(), 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
};
}
}