From 6ec850e3840920eec286ca412a5a23cc95f47c90 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Fri, 21 Feb 2025 09:15:41 -0600 Subject: [PATCH] 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 --- .../InviteOrganizationUsersCommand.cs | 180 +++++++++++-- ...InviteUserOrganizationValidationRequest.cs | 3 + .../SendOrganizationInvitesCommand.cs | 98 +++++++ .../PasswordManagerSubscriptionUpdate.cs | 2 + .../Implementations/OrganizationService.cs | 79 +----- ...OrganizationServiceCollectionExtensions.cs | 2 +- .../SendOrganizationInvitesCommandTests.cs | 107 ++++++++ .../Services/OrganizationServiceTests.cs | 248 ++++-------------- 8 files changed, 418 insertions(+), 301 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index e0f90dfe8a..ff0cec3548 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -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> 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 logger, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand ) : IInviteOrganizationUsersCommand { public async Task> 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 invalid) { - return new Failure>(validationResult.ErrorMessageString); + return new Failure>(invalid.ErrorMessageString); } + var valid = validationResult as Valid; + 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>(InviteOrganizationUsersErrorMessages.FailedToInviteUsers); } - return null; + return new Success>(organizationUserCollection.Select(x => x.User)); + } + + private async Task RevertPasswordManagerChangesAsync(Valid 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 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 valid, + Organization organization) => + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext) + { + Users = valid.Value.Invites.Length + }); + + private async Task SendInvitesAsync(IEnumerable users, Organization organization) => + await sendOrganizationInvitesCommand.SendInvitesAsync( + new SendInvitesRequest( + users.Select(x => x.User), + organization)); + + private async Task SendNotificationsAsync(Valid valid, Organization organization) + { + await SendPasswordManagerMaxSeatLimitEmailsAsync(valid, organization); + } + + private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid 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 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 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 + }); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteUserOrganizationValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteUserOrganizationValidationRequest.cs index 866bee156d..a11bb51fc4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteUserOrganizationValidationRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteUserOrganizationValidationRequest.cs @@ -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; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..97cfe5e56e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -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 BuildOrganizationInvitesInfoAsync(IEnumerable orgUsers, + Organization organization, bool initOrganization = false); +} + +public class SendInvitesRequest +{ + public SendInvitesRequest() { } + + public SendInvitesRequest(IEnumerable 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 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 BuildOrganizationInvitesInfoAsync(IEnumerable 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(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 + ); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PasswordManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PasswordManagerSubscriptionUpdate.cs index 44bf1eb5f7..34f0928858 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PasswordManagerSubscriptionUpdate.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PasswordManagerSubscriptionUpdate.cs @@ -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, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 284c11cc78..00e396580a 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -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 _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 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 orgUsers, Organization organization) - { - var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization); - - await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); - } + private async Task SendInvitesAsync(IEnumerable 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 BuildOrganizationInvitesInfoAsync( - IEnumerable 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(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 orgUsers, Organization organization, bool initOrganization = false) => + await _sendOrganizationInvitesCommand.BuildOrganizationInvitesInfoAsync(orgUsers, organization, + initOrganization); public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId) diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 80bcc44e4d..4973413024 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -169,9 +169,9 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs new file mode 100644 index 0000000000..b3e68f6a41 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs @@ -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 _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + + [Theory] + [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] + public async Task SendInvitesAsync_SsoOrgWithNeverEnabledRequireSsoPolicy_SendsEmailWithoutRequiringSso( + Organization organization, + SsoConfig ssoConfig, + OrganizationUser invite, + SutProvider 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().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); + + // Return null policy to mimic new org that's never turned on the require sso policy + sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull(); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + // Act + await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(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 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().GetByOrganizationIdAsync(organization.Id).ReturnsNull(); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); + + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(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)); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 77db1d43ce..bc8302e725 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -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().ManageUsers(org.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - 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().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync( - Arg.Is(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync( + Arg.Is( + info => info.Users.Length == expectedNewUsersCount && + info.Organization == org)); // Send events await sutProvider.GetDependency().Received(1) @@ -156,16 +147,6 @@ public class OrganizationServiceTests var currentContext = sutProvider.GetDependency(); currentContext.ManageUsers(org.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -183,14 +164,15 @@ public class OrganizationServiceTests await sutProvider.GetDependency().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.Length == expectedNewUsersCount && + request.Organization == org)); // Sent events await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Is>(events => - events.Where(e => e.Item2 == EventType.OrganizationUser_Invited).Count() == expectedNewUsersCount)); + events.Count(e => e.Item2 == EventType.OrganizationUser_Invited) == expectedNewUsersCount)); await sutProvider.GetDependency().Received(1) .RaiseEventAsync(Arg.Is(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() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - 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().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(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 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().GetByOrganizationIdAsync(organization.Id).ReturnsNull(); - - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); - sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); - var organizationUserRepository = sutProvider.GetDependency(); - 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() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - 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().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(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 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().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); - sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); - var organizationUserRepository = sutProvider.GetDependency(); - organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) - .Returns(new[] { owner }); - - ssoConfig.Enabled = true; - sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); - - - // Return null policy to mimic new org that's never turned on the require sso policy - sutProvider.GetDependency().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() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - 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().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(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 sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); + // sutProvider.GetDependency() + // .CreateToken(Arg.Any()) + // .Returns( + // info => new OrgUserInviteTokenable(info.Arg()) + // { + // ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + // } + // ); sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) @@ -657,11 +529,10 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == 1 && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.Length == 1 && + request.Organization == organization)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -714,16 +585,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); @@ -735,12 +596,11 @@ OrganizationUserInvite invite, SutProvider 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().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(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().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Organization == organization && + info.Users.Length == 0)); await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Is>(events => !events.Any())); } @@ -789,16 +649,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); @@ -808,11 +658,10 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, invites); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Organization == organization && + info.Users.Length == invites.SelectMany(x => x.invite.Emails).Distinct().Count())); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -850,23 +699,12 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.ManageUsers(organization.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitingUserId: null, eventSystemUser, invites); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Users.Length == invites.SelectMany(i => i.invite.Emails).Count() && + info.Organization == organization)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -972,8 +810,10 @@ OrganizationUserInvite invite, SutProvider 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 sutProvider) { var organizationUserRepository = sutProvider.GetDependency();