diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs index 345d678fdb..79a3487d19 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -18,11 +18,13 @@ public class InviteOrganizationUsersValidator( IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand, IPaymentService paymentService) : IInviteUsersValidator { - public async Task> ValidateAsync(InviteOrganizationUsersValidationRequest request) + public async Task> ValidateAsync( + InviteOrganizationUsersValidationRequest request) { var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request); - var passwordManagerValidationResult = await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate); + var passwordManagerValidationResult = + await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate); if (passwordManagerValidationResult is Invalid invalidSubscriptionUpdate) { @@ -53,18 +55,25 @@ public class InviteOrganizationUsersValidator( } private async Task> ValidateSecretsManagerSubscriptionUpdateAsync( - InviteOrganizationUsersValidationRequest request, - PasswordManagerSubscriptionUpdate subscriptionUpdate) + InviteOrganizationUsersValidationRequest request, + PasswordManagerSubscriptionUpdate subscriptionUpdate) { try { - var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate( - organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId), - plan: request.InviteOrganization.Plan, - autoscaling: true) - .AdjustSeats(GetSecretManagerSeatAdjustment(request)); - await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate); + var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate( + organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId), + plan: request.InviteOrganization.Plan, + autoscaling: true); + + var seatsToAdd = GetSecretManagerSeatAdjustment(request); + + if (seatsToAdd > 0) + { + smSubscriptionUpdate.AdjustSeats(seatsToAdd); + + await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate); + } return new Valid(new InviteOrganizationUsersValidationRequest( request, @@ -73,13 +82,27 @@ public class InviteOrganizationUsersValidator( } catch (Exception ex) { - return new Invalid(new Error(ex.Message, request)); + return new Invalid( + new Error(ex.Message, request)); } - - int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) => - Math.Abs( - request.InviteOrganization.SmSeats - - request.OccupiedSmSeats - - request.Invites.Count(x => x.AccessSecretsManager) ?? 0); } + + /// + /// This calculates the number of SM seats to add to the organization seat total. + /// + /// If they have a current seat limit (it can be null), we want to figure out how many are available (seats - + /// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite. + /// + /// If it's negative, we have available seats and do not need to increase, so we go with 0. + /// + /// + /// + private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) => + request.InviteOrganization.SmSeats.HasValue + ? Math.Max( + request.Invites.Count(x => x.AccessSecretsManager) - + (request.InviteOrganization.SmSeats.Value - + request.OccupiedSmSeats), + 0) + : 0; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs index fdc58cbdda..191ef05603 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs @@ -2,7 +2,9 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.Shared.Validation; using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; @@ -10,6 +12,7 @@ using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; @@ -20,7 +23,7 @@ public class InviteOrganizationUsersValidatorTests { [Theory] [BitAutoData] - public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvites_ThenShouldCorrectlyCalculateSeatsToAdd( + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndDoesNotHaveEnoughSeatsAvailable_ThenShouldCorrectlyCalculateSeatsToAdd( Organization organization, SutProvider sutProvider ) @@ -63,4 +66,96 @@ public class InviteOrganizationUsersValidatorTests .ValidateUpdateAsync(Arg.Is(x => x.SmSeatsChanged == true && x.SmSeats == 12)); } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndHasSeatsAvailable_ThenShouldReturnValid( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 12; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 9 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndSmSeatUpdateFailsValidation_ThenShouldReturnInvalid( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 5; + organization.MaxAutoscaleSmSeats = 5; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 4 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateUpdateAsync(Arg.Any()) + .Throws(new BadRequestException("Some Secrets Manager Failure")); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.IsType>(result); + Assert.Equal("Some Secrets Manager Failure", (result as Invalid)!.ErrorMessageString); + } }