1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-10 23:58:13 -05:00

Created SendOrganizationInvitesCommand and moved some tests from OrgServiceTests. Fixed some tests in org service in relation to moving out SendOrgInviteCommand code.

Added side effects to InviteOrganizationUsersCommand
This commit is contained in:
jrmccannon 2025-02-21 09:15:41 -06:00
parent 649e8b5c0a
commit 6ec850e384
No known key found for this signature in database
GPG Key ID: CF03F3DB01CE96A6
8 changed files with 418 additions and 301 deletions

View File

@ -1,14 +1,28 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Commands;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Microsoft.Extensions.Logging;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.CreateOrganizationUserExtensions;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public static class InviteOrganizationUsersErrorMessages
{
public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached.";
public const string FailedToInviteUsers = "Failed to invite user(s).";
}
public interface IInviteOrganizationUsersCommand
{
Task<CommandResult<OrganizationUser>> InviteScimOrganizationUserAsync(InviteScimOrganizationUserRequest request);
@ -16,7 +30,16 @@ public interface IInviteOrganizationUsersCommand
public class InviteOrganizationUsersCommand(IEventService eventService,
IOrganizationUserRepository organizationUserRepository,
IInviteUsersValidation inviteUsersValidation
IInviteUsersValidation inviteUsersValidation,
IPaymentService paymentService,
IOrganizationRepository organizationRepository,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<InviteOrganizationUsersCommand> logger,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand
) : IInviteOrganizationUsersCommand
{
public async Task<CommandResult<OrganizationUser>> InviteScimOrganizationUserAsync(InviteScimOrganizationUserRequest request)
@ -61,48 +84,151 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.Organization.OrganizationId)
});
if (!validationResult.IsValid)
if (validationResult is Invalid<InviteUserOrganizationValidationRequest> invalid)
{
return new Failure<IEnumerable<OrganizationUser>>(validationResult.ErrorMessageString);
return new Failure<IEnumerable<OrganizationUser>>(invalid.ErrorMessageString);
}
var valid = validationResult as Valid<InviteUserOrganizationValidationRequest>;
var organizationUserCollection = invitesToSend
.Select(MapToDataModel(request.PerformedAt));
var organization = await organizationRepository.GetByIdAsync(valid.Value.Organization.OrganizationId);
try
{
await organizationUserRepository.CreateManyAsync(organizationUserCollection);
// save organization users
// org users
// collections
// groups
// save new seat totals
// password manager
// secrets manager
// update stripe
await AdjustPasswordManagerSeatsAsync(valid, organization);
// send invites
await AdjustSecretsManagerSeatsAsync(valid, organization);
// notify owners
// seats added
// autoscaling
// max seat limit has been reached
await SendNotificationsAsync(valid, organization);
// publish events
// Reference events
await SendInvitesAsync(organizationUserCollection, organization);
// update cache
await PublishEventAsync(valid, organization);
}
catch (Exception)
catch (Exception ex)
{
// rollback saves
// remove org users
// remove collections
// remove groups
// correct stripe
logger.LogError(ex, InviteOrganizationUsersErrorMessages.FailedToInviteUsers);
await organizationUserRepository.DeleteManyAsync(organizationUserCollection.Select(x => x.User.Id));
await RevertSecretsManagerChangesAsync(valid, organization);
await RevertPasswordManagerChangesAsync(valid, organization);
return new Failure<IEnumerable<OrganizationUser>>(InviteOrganizationUsersErrorMessages.FailedToInviteUsers);
}
return null;
return new Success<IEnumerable<OrganizationUser>>(organizationUserCollection.Select(x => x.User));
}
private async Task RevertPasswordManagerChangesAsync(Valid<InviteUserOrganizationValidationRequest> valid, Organization organization)
{
if (valid.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd < 0)
{
await paymentService.AdjustSeatsAsync(organization, valid.Value.Organization.Plan, -valid.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
organization.Seats = (short?)valid.Value.PasswordManagerSubscriptionUpdate.Seats;
await organizationRepository.ReplaceAsync(organization);
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
private async Task RevertSecretsManagerChangesAsync(Valid<InviteUserOrganizationValidationRequest> valid, Organization organization)
{
if (valid.Value.SecretsManagerSubscriptionUpdate.SeatsRequiredToAdd < 0)
{
var updateRevert = new SecretsManagerSubscriptionUpdate(organization, false)
{
SmSeats = valid.Value.SecretsManagerSubscriptionUpdate.Seats
};
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(updateRevert);
}
}
private async Task PublishEventAsync(Valid<InviteUserOrganizationValidationRequest> valid,
Organization organization) =>
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext)
{
Users = valid.Value.Invites.Length
});
private async Task SendInvitesAsync(IEnumerable<CreateOrganizationUser> users, Organization organization) =>
await sendOrganizationInvitesCommand.SendInvitesAsync(
new SendInvitesRequest(
users.Select(x => x.User),
organization));
private async Task SendNotificationsAsync(Valid<InviteUserOrganizationValidationRequest> valid, Organization organization)
{
await SendPasswordManagerMaxSeatLimitEmailsAsync(valid, organization);
}
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteUserOrganizationValidationRequest> valid, Organization organization)
{
if (!valid.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
{
return;
}
try
{
var ownerEmails = (await organizationUserRepository
.GetManyByMinimumRoleAsync(valid.Value.Organization.OrganizationId, OrganizationUserType.Owner))
.Select(x => x.Email)
.Distinct();
await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
valid.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats.Value, ownerEmails);
}
catch (Exception ex)
{
logger.LogError(ex, InviteOrganizationUsersErrorMessages.IssueNotifyingOwnersOfSeatLimitReached);
}
}
private async Task AdjustSecretsManagerSeatsAsync(Valid<InviteUserOrganizationValidationRequest> valid, Organization organization)
{
if (valid.Value.SecretsManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
{
return;
}
var subscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, true)
.AdjustSeats(valid.Value.SecretsManagerSubscriptionUpdate.SeatsRequiredToAdd);
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(subscriptionUpdate);
}
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteUserOrganizationValidationRequest> valid, Organization organization)
{
if (valid.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
{
return;
}
// These are the important steps
await paymentService.AdjustSeatsAsync(organization, valid.Value.Organization.Plan, valid.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
organization.Seats = (short?)valid.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
// Do we want to fail if this fails?
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
{
PlanName = valid.Value.Organization.Plan.Name,
PlanType = valid.Value.Organization.Plan.Type,
Seats = valid.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
PreviousSeats = valid.Value.PasswordManagerSubscriptionUpdate.Seats
});
}
}

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
@ -10,4 +11,6 @@ public class InviteUserOrganizationValidationRequest
public DateTimeOffset PerformedAt { get; init; }
public int OccupiedPmSeats { get; init; }
public int OccupiedSmSeats { get; init; }
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; }
public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; }
}

View File

@ -0,0 +1,98 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tokens;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public interface ISendOrganizationInvitesCommand
{
Task SendInvitesAsync(SendInvitesRequest request);
Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IEnumerable<OrganizationUser> orgUsers,
Organization organization, bool initOrganization = false);
}
public class SendInvitesRequest
{
public SendInvitesRequest() { }
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization) =>
(Users, Organization) = (users.ToArray(), organization);
public OrganizationUser[] Users { get; set; } = [];
public Organization Organization { get; set; } = null!;
}
public class SendOrganizationInvitesCommand(
IUserRepository userRepository,
ISsoConfigRepository ssoConfigurationRepository,
IPolicyRepository policyRepository,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService) : ISendOrganizationInvitesCommand
{
public async Task SendInvitesAsync(SendInvitesRequest request)
{
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization);
await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
public async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IEnumerable<OrganizationUser> orgUsers,
Organization organization, bool initOrganization = false)
{
// Materialize the sequence into a list to avoid multiple enumeration warnings
var orgUsersList = orgUsers.ToList();
// Email links must include information about the org and user for us to make routing decisions client side
// Given an org user, determine if existing BW user exists
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails);
// hash existing users emails list for O(1) lookups
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
ou => ou.Id,
ou => existingUserEmailsHashSet.Contains(ou.Email)
);
// Determine if org has SSO enabled and if user is required to login with SSO
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable);
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
}
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
orgUserHasExistingUserDict,
initOrganization
);
}
}

View File

@ -23,6 +23,8 @@ public class PasswordManagerSubscriptionUpdate
public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd;
public bool MaxSeatsReached => Seats.HasValue && MaxAutoScaleSeats.HasValue && Seats.Value == MaxAutoScaleSeats.Value;
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
private PasswordManagerSubscriptionUpdate(int? organizationSeats,

View File

@ -6,11 +6,10 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
@ -29,7 +28,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
@ -56,7 +54,6 @@ public class OrganizationService : IOrganizationService
private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IReferenceEventService _referenceEventService;
private readonly IGlobalSettings _globalSettings;
@ -68,12 +65,11 @@ public class OrganizationService : IOrganizationService
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IProviderRepository _providerRepository;
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IOrganizationBillingService _organizationBillingService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
public OrganizationService(
IOrganizationRepository organizationRepository,
@ -91,7 +87,6 @@ public class OrganizationService : IOrganizationService
IPaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyService policyService,
ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository,
IReferenceEventService referenceEventService,
IGlobalSettings globalSettings,
@ -101,14 +96,13 @@ public class OrganizationService : IOrganizationService
IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IProviderRepository providerRepository,
IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IOrganizationBillingService organizationBillingService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -125,7 +119,6 @@ public class OrganizationService : IOrganizationService
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyService = policyService;
_ssoConfigRepository = ssoConfigRepository;
_ssoUserRepository = ssoUserRepository;
_referenceEventService = referenceEventService;
_globalSettings = globalSettings;
@ -137,12 +130,11 @@ public class OrganizationService : IOrganizationService
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_providerRepository = providerRepository;
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_organizationBillingService = organizationBillingService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -1080,12 +1072,8 @@ public class OrganizationService : IOrganizationService
await SendInviteAsync(orgUser, org, initOrganization);
}
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
{
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
@ -1098,56 +1086,9 @@ public class OrganizationService : IOrganizationService
}
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(
IEnumerable<OrganizationUser> orgUsers,
Organization organization,
bool initOrganization = false)
{
// Materialize the sequence into a list to avoid multiple enumeration warnings
var orgUsersList = orgUsers.ToList();
// Email links must include information about the org and user for us to make routing decisions client side
// Given an org user, determine if existing BW user exists
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails);
// hash existing users emails list for O(1) lookups
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
ou => ou.Id,
ou => existingUserEmailsHashSet.Contains(ou.Email)
);
// Determine if org has SSO enabled and if user is required to login with SSO
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
}
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
orgUserHasExistingUserDict,
initOrganization
);
}
IEnumerable<OrganizationUser> orgUsers, Organization organization, bool initOrganization = false) =>
await _sendOrganizationInvitesCommand.BuildOrganizationInvitesInfoAsync(orgUsers, organization,
initOrganization);
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId)

View File

@ -169,9 +169,9 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
services.AddScoped<IInviteUsersValidation, InviteUsersValidation>();
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
}
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of

View File

@ -0,0 +1,107 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Tokens;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Fakes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
[SutProviderCustomize]
public class SendOrganizationInvitesCommandTests
{
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
[Theory]
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
public async Task SendInvitesAsync_SsoOrgWithNeverEnabledRequireSsoPolicy_SendsEmailWithoutRequiringSso(
Organization organization,
SsoConfig ssoConfig,
OrganizationUser invite,
SutProvider<SendOrganizationInvitesCommand> sutProvider)
{
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
sutProvider.Create();
// Org must be able to use SSO and policies to trigger this test case
organization.UseSso = true;
organization.UsePolicies = true;
ssoConfig.Enabled = true;
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig);
// Return null policy to mimic new org that's never turned on the require sso policy
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull();
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
});
// Act
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization));
// Assert
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == 1 &&
info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name &&
info.OrgSsoLoginRequiredPolicyEnabled == false));
}
[Theory]
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
public async Task InviteUsers_SsoOrgWithNullSsoConfig_SendsInvite(
Organization organization,
OrganizationUser invite,
SutProvider<SendOrganizationInvitesCommand> sutProvider)
{
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
sutProvider.Create();
// Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve
// an org's SSO config if the org can use SSO
organization.UseSso = true;
// Return null for sso config
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).ReturnsNull();
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
});
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization));
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == 1 &&
info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
}
}

View File

@ -3,11 +3,10 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Context;
@ -17,7 +16,6 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.Mail;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
@ -81,15 +79,6 @@ public class OrganizationServiceTests
.Returns(true);
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi);
@ -104,9 +93,11 @@ public class OrganizationServiceTests
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(
Arg.Is<OrganizationInvitesInfo>(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
.SendInvitesAsync(
Arg.Is<SendInvitesRequest>(
info => info.Users.Length == expectedNewUsersCount &&
info.Organization == org));
// Send events
await sutProvider.GetDependency<IEventService>().Received(1)
@ -156,16 +147,6 @@ public class OrganizationServiceTests
var currentContext = sutProvider.GetDependency<ICurrentContext>();
currentContext.ManageUsers(org.Id).Returns(true);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
@ -183,14 +164,15 @@ public class OrganizationServiceTests
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(request =>
request.Users.Length == expectedNewUsersCount &&
request.Organization == org));
// Sent events
await sutProvider.GetDependency<IEventService>().Received(1)
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>(events =>
events.Where(e => e.Item2 == EventType.OrganizationUser_Invited).Count() == expectedNewUsersCount));
events.Count(e => e.Item2 == EventType.OrganizationUser_Invited) == expectedNewUsersCount));
await sutProvider.GetDependency<IReferenceEventService>().Received(1)
.RaiseEventAsync(Arg.Is<ReferenceEvent>(referenceEvent =>
referenceEvent.Type == ReferenceEventType.InvitedUsers && referenceEvent.Id == org.Id &&
@ -272,125 +254,15 @@ public class OrganizationServiceTests
// Must set guids in order for dictionary of guids to not throw aggregate exceptions
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) });
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(request =>
request.Users.DistinctBy(x => x.Email).Count() == invite.Emails.Distinct().Count() &&
request.Organization == organization));
}
[Theory]
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
public async Task InviteUsers_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
{
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
sutProvider.Create();
// Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve
// an org's SSO config if the org can use SSO
organization.UseSso = true;
// Return null for sso config
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).ReturnsNull();
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { owner });
// Must set guids in order for dictionary of guids to not throw aggregate exceptions
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) });
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
}
[Theory]
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
public async Task InviteUsers_SsoOrgWithNeverEnabledRequireSsoPolicy_Passes(Organization organization, SsoConfig ssoConfig, OrganizationUser invitor,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
{
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
sutProvider.Create();
// Org must be able to use SSO and policies to trigger this test case
organization.UseSso = true;
organization.UsePolicies = true;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { owner });
ssoConfig.Enabled = true;
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig);
// Return null policy to mimic new org that's never turned on the require sso policy
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull();
// Must set guids in order for dictionary of guids to not throw aggregate exceptions
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) });
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
}
[Theory]
[OrganizationInviteCustomize(
InviteeUserType = OrganizationUserType.Admin,
@ -639,14 +511,14 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
// sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
// .CreateToken(Arg.Any<OrganizationUser>())
// .Returns(
// info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
// {
// ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
// }
// );
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
@ -657,11 +529,10 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == 1 &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(request =>
request.Users.Length == 1 &&
request.Organization == organization));
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
@ -714,16 +585,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
@ -735,12 +596,11 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId));
Assert.Contains("This user has already been invited", exception.Message);
// MailService and EventService are still called, but with no OrgUsers
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
!info.OrgUserTokenPairs.Any() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
// SendOrganizationInvitesCommand and EventService are still called, but with no OrgUsers
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(info =>
info.Organization == organization &&
info.Users.Length == 0));
await sutProvider.GetDependency<IEventService>().Received(1)
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(events => !events.Any()));
}
@ -789,16 +649,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
@ -808,11 +658,10 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, invites);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(info =>
info.Organization == organization &&
info.Users.Length == invites.SelectMany(x => x.invite.Emails).Distinct().Count()));
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
@ -850,23 +699,12 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
currentContext.ManageUsers(organization.Id).Returns(true);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
.CreateToken(Arg.Any<OrganizationUser>())
.Returns(
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
{
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
}
);
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitingUserId: null, eventSystemUser, invites);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(info =>
info.Users.Length == invites.SelectMany(i => i.invite.Emails).Count() &&
info.Organization == organization));
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>());
}
@ -972,8 +810,10 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
}
[Theory, BitAutoData]
public async Task ConfirmUser_InvalidStatus(OrganizationUser confirmingUser,
[OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser, string key,
public async Task ConfirmUser_InvalidStatus(
OrganizationUser confirmingUser,
[OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser,
string key,
SutProvider<OrganizationService> sutProvider)
{
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();