1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 07:36:14 -05:00

[PM-16811] - SCIM Invite Users Optimizations (#5398)

* WIP changes for Invite User optimization from Scim

* feature flag string

* Added plan validation to PasswordManagerInviteUserValidation. Cleaned up a few things.

* Added Secrets Manager Validations and Tests.

* Added bulk procedure for saving users, collections and groups from inviting. Added test to validate Ef and Sproc

* 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

* First test of new command.

* Added test to verify valid request with a user calls db method and sends the invite

* Added more tests for the updates

* Added integration test around enabling feature and sending invite via scim. Did a bit of refactoring on the SM validation. Fixed couple bugs found.

* Switching over to a local factory.

* created response model and split interface out.

* switched to initialization block

* Moved to private method. Made ScimInvite inherit the single invite base model. Moved create methods to constructors. A few more CR changes included.

* Moved `FromOrganization` mapper method to a constructor

* Updated to use new pricing client. Supressed null dereference errors.

* Fixing bad merge.

* Rename of OrgDto

* undoing this

* Moved into class

* turned into a switch statement

* Separated into separate files.

* Renamed dto and added ctor

* Dto rename. Moved from static methods to ctors

* Removed unused request model

* changes from main

* missed value

* Fixed some compilation errors.

* Fixed some changes.

* Removed comment

* fixed compiler warning.

* Refactored to use new ValidationResult pattern. added mapping method.

* Added throwing of Failure as the previous implementation would have.

* Cleaned up return.

* fixing test.

* Made HasSecretsManagerStandalone return if org doesn't have sm. Added overload for lighter weight model and moved common code to private method.

* Fixed tests.

* Made public method private. added some comments.

* Refactor validation parameter to improve clarity and consistency. Added XML doc

* fixed test

* Removed test only constructor from InviteOrganization

* Separated old and new code explicitly. Moved old code checks down into new code as well. Added error and mapper to Failure<T>

* Variable/Field/Property renames

* Renamed InviteUsersValidation to InviteUsersValidator

* Rename for InvitingUserOrganizationValidation to InvitingUserOrganizationValidator

* PasswordManagerInviteUserValidation to PasswordManagerInviteUserValidator

* Moved XML comment. Added check to see if additional seats are needed.

* Fixing name.

* Updated names.

* Corrected double negation.

* Added groups and collection and users checks.

* Fixed comment. Fixed multiple enumeration. Changed variable name.

* Cleaned up DTO models. Moved some validation steps around. A few quick fixes to address CR concerns. Still need to move a few things yet.

* Fixed naming in subscription update models.

* put back in the request for now.

* Quick rename

* Added provider email addresses as well.

* Removed valid wrapper to pass in to validation methods.

* fix tests

* Code Review changes.

* Removed unused classes

* Using GetPlanOrThrow instead.

* Switches to extension method

* Made Revert and Adjust Sm methods consistent. Corrected string comparer. Added comment for revert sm.

* Fixing compiler complaint.

* Adding XML docs

* Calculated seat addition for SM.

* Fixing compiler complaints.

* Renames for organization.

* Fixing comparison issue.

* Adding error and aligning message.

* fixing name of method.

* Made extension method.

* Rearranged some things. Fixed the tests.

* Added test around validating the revert.

* Added test to validate the provider email is sent if org is managed by a provider.

* Created new errors and removed references in business code to ErrorMessages property. This aligns Invite User code to use Errors instead of ErrorMessages

* Delayed the hasSecretsManagerStandalone call as long as possible.

* Corrected model name. Corrected SM seat calculation. Added test for it.

* Corrected logic and added more tests.
This commit is contained in:
Jared McCannon
2025-04-07 09:14:10 -05:00
committed by GitHub
parent 3c56866a76
commit 0d7363c6af
66 changed files with 3337 additions and 362 deletions

View File

@ -0,0 +1,67 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages;
namespace Bit.Core.Test.AdminConsole.Models;
public class InviteOrganizationUsersRequestTests
{
[Theory]
[BitAutoData]
public void Constructor_WhenPassedInvalidEmail_ThrowsException(string email, OrganizationUserType type, Permissions permissions, string externalId)
{
var exception = Assert.Throws<BadRequestException>(() =>
new OrganizationUserInvite(email, [], [], type, permissions, externalId, false));
Assert.Contains(InvalidEmailErrorMessage, exception.Message);
}
[Fact]
public void Constructor_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException()
{
const string validEmail = "test@email.com";
var invalidCollectionConfiguration = new CollectionAccessSelection
{
Manage = true,
HidePasswords = true
};
var exception = Assert.Throws<BadRequestException>(() =>
new OrganizationUserInvite(
email: validEmail,
assignedCollections: [invalidCollectionConfiguration],
groups: [],
type: default,
permissions: new Permissions(),
externalId: string.Empty,
accessSecretsManager: false));
Assert.Equal(InvalidCollectionConfigurationErrorMessage, exception.Message);
}
[Fact]
public void Constructor_WhenPassedValidArguments_ReturnsInvite()
{
const string validEmail = "test@email.com";
var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true };
var invite = new OrganizationUserInvite(
email: validEmail,
assignedCollections: [validCollectionConfiguration],
groups: [],
type: default,
permissions: null,
externalId: null,
accessSecretsManager: false);
Assert.NotNull(invite);
Assert.Contains(validEmail, invite.Email);
Assert.Contains(validCollectionConfiguration, invite.AssignedCollections);
}
}

View File

@ -0,0 +1,51 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.Models.Business;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers;
public static class InviteUserOrganizationValidationRequestHelpers
{
public static InviteOrganizationUsersValidationRequest GetInviteValidationRequestMock(InviteOrganizationUsersRequest request,
InviteOrganization inviteOrganization, Organization organization) =>
new()
{
Invites = request.Invites,
InviteOrganization = inviteOrganization,
PerformedBy = Guid.Empty,
PerformedAt = request.PerformedAt,
OccupiedPmSeats = 0,
OccupiedSmSeats = 0,
PasswordManagerSubscriptionUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, 0, 0),
SecretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager))
};
public static InviteOrganizationUsersValidationRequest WithPasswordManagerUpdate(this InviteOrganizationUsersValidationRequest request, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) =>
new()
{
Invites = request.Invites,
InviteOrganization = request.InviteOrganization,
PerformedBy = request.PerformedBy,
PerformedAt = request.PerformedAt,
OccupiedPmSeats = request.OccupiedPmSeats,
OccupiedSmSeats = request.OccupiedSmSeats,
PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate,
SecretsManagerSubscriptionUpdate = request.SecretsManagerSubscriptionUpdate
};
public static InviteOrganizationUsersValidationRequest WithSecretsManagerUpdate(this InviteOrganizationUsersValidationRequest request, SecretsManagerSubscriptionUpdate secretsManagerSubscriptionUpdate) =>
new()
{
Invites = request.Invites,
InviteOrganization = request.InviteOrganization,
PerformedBy = request.PerformedBy,
PerformedAt = request.PerformedAt,
OccupiedPmSeats = request.OccupiedPmSeats,
OccupiedSmSeats = request.OccupiedSmSeats,
PasswordManagerSubscriptionUpdate = request.PasswordManagerSubscriptionUpdate,
SecretsManagerSubscriptionUpdate = secretsManagerSubscriptionUpdate
};
}

View File

@ -0,0 +1,613 @@
using System.Net.Mail;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Commands;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
using static Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers.InviteUserOrganizationValidationRequestHelpers;
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
[SutProviderCustomize]
public class InviteOrganizationUserCommandTests
{
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenEmailAlreadyExists_ThenNoInviteIsSentAndNoSeatsAreAdjusted(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
user.Email = address.Address;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: user.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns([user.Email]);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result);
Assert.Equal(NoUsersToInviteError.Code, (result as Failure<ScimInviteOrganizationUsersResponse>).ErrorMessage);
await sutProvider.GetDependency<IPaymentService>()
.DidNotReceiveWithAnyArgs()
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.DidNotReceiveWithAnyArgs()
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
await sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionAsync(Arg.Any<Core.Models.Business.SecretsManagerSubscriptionUpdate>());
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenEmailDoesNotExistAndRequestIsValid_ThenUserIsSavedAndInviteIsSent(
MailAddress address,
Organization organization,
OrganizationUser orgUser,
FakeTimeProvider timeProvider,
string externalId,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
orgUser.Email = address.Address;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: orgUser.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<CreateOrganizationUser>>(users =>
users.Any(user => user.OrganizationUser.Email == request.Invites.First().Email)));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(invite =>
invite.Organization == organization &&
invite.Users.Count(x => x.Email == orgUser.Email) == 1));
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenEmailIsNewAndRequestIsInvalid_ThenFailureIsReturnedWithValidationFailureReason(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
const string errorMessage = "Org cannot add user for some given reason";
user.Email = address.Address;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: user.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
var validationRequest = GetInviteValidationRequestMock(request, inviteOrganization, organization);
sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Invalid<InviteOrganizationUsersValidationRequest>(
new Error<InviteOrganizationUsersValidationRequest>(errorMessage, validationRequest)));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result);
var failure = result as Failure<ScimInviteOrganizationUsersResponse>;
Assert.Equal(errorMessage, failure!.ErrorMessage);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceive()
.CreateManyAsync(Arg.Any<IEnumerable<CreateOrganizationUser>>());
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.DidNotReceive()
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenValidInviteCausesOrganizationToReachMaxSeats_ThenOrganizationOwnersShouldBeNotified(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
OrganizationUserUserDetails ownerDetails,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
user.Email = address.Address;
organization.Seats = 1;
organization.MaxAutoscaleSeats = 2;
ownerDetails.Type = OrganizationUserType.Owner;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: user.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
Assert.NotNull(inviteOrganization.MaxAutoScaleSeats);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
inviteOrganization.MaxAutoScaleSeats.Value,
Arg.Is<IEnumerable<string>>(emails => emails.Any(email => email == ownerDetails.Email)));
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
OrganizationUserUserDetails ownerDetails,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
user.Email = address.Address;
organization.Seats = 1;
organization.MaxAutoscaleSeats = 2;
ownerDetails.Type = OrganizationUserType.Owner;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: user.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
var passwordManagerUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1);
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
orgRepository.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(passwordManagerUpdate)));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
await sutProvider.GetDependency<IPaymentService>()
.AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd);
await orgRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));
await sutProvider.GetDependency<IApplicationCacheService>()
.Received(1)
.UpsertOrganizationAbilityAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSecretsManagerSeats_ThenSecretsManagerShouldBeUpdated(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
OrganizationUserUserDetails ownerDetails,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
user.Email = address.Address;
organization.Seats = 1;
organization.SmSeats = 1;
organization.MaxAutoscaleSeats = 2;
organization.MaxAutoscaleSmSeats = 2;
ownerDetails.Type = OrganizationUserType.Owner;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: user.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager));
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
orgRepository.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate)));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert;
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
await sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
.Received(1)
.UpdateSubscriptionAsync(secretsManagerSubscriptionUpdate);
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenAnErrorOccursWhileInvitingUsers_ThenAnySeatChangesShouldBeReverted(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
OrganizationUserUserDetails ownerDetails,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
user.Email = address.Address;
organization.Seats = 1;
organization.SmSeats = 1;
organization.MaxAutoscaleSeats = 2;
organization.MaxAutoscaleSmSeats = 2;
ownerDetails.Type = OrganizationUserType.Owner;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: user.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager));
var passwordManagerSubscriptionUpdate =
new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length);
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
orgRepository.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate)
.WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate)));
sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.SendInvitesAsync(Arg.Any<SendInvitesRequest>())
.Throws(new Exception("Something went wrong"));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result);
Assert.Equal(FailedToInviteUsersError.Code, (result as Failure<ScimInviteOrganizationUsersResponse>)!.ErrorMessage);
// org user revert
await orgUserRepository.Received(1).DeleteManyAsync(Arg.Is<IEnumerable<Guid>>(x => x.Count() == 1));
// SM revert
await sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
.Received(2)
.UpdateSubscriptionAsync(Arg.Any<SecretsManagerSubscriptionUpdate>());
// PM revert
await sutProvider.GetDependency<IPaymentService>()
.Received(2)
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
await orgRepository.Received(2).ReplaceAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IApplicationCacheService>().Received(2)
.UpsertOrganizationAbilityAsync(Arg.Any<Organization>());
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationIsManagedByAProvider_ThenAnEmailShouldBeSentToTheProvider(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
OrganizationUserUserDetails ownerDetails,
ProviderOrganization providerOrganization,
SutProvider<InviteOrganizationUsersCommand> sutProvider)
{
// Arrange
user.Email = address.Address;
organization.Seats = 1;
organization.SmSeats = 1;
organization.MaxAutoscaleSeats = 2;
organization.MaxAutoscaleSmSeats = 2;
ownerDetails.Type = OrganizationUserType.Owner;
providerOrganization.OrganizationId = organization.Id;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var request = new InviteOrganizationUsersRequest(
invites: [
new OrganizationUserInvite(
email: user.Email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
accessSecretsManager: true)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty,
timeProvider.GetUtcNow());
var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager));
var passwordManagerSubscriptionUpdate =
new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length);
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
orgRepository.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate)
.WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate)));
sutProvider.GetDependency<IProviderOrganizationRepository>()
.GetByOrganizationId(organization.Id)
.Returns(providerOrganization);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed)
.Returns(new List<ProviderUserUserDetails>
{
new()
{
Email = "provider@email.com"
}
});
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2,
Arg.Is<IEnumerable<string>>(emails => emails.Any(email => email == "provider@email.com")));
}
}

View File

@ -0,0 +1,108 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
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

@ -0,0 +1,161 @@
using Bit.Core.AdminConsole.Entities;
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;
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;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
[SutProviderCustomize]
public class InviteOrganizationUsersValidatorTests
{
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndDoesNotHaveEnoughSeatsAvailable_ThenShouldCorrectlyCalculateSeatsToAdd(
Organization organization,
SutProvider<InviteOrganizationUsersValidator> sutProvider
)
{
organization.Seats = null;
organization.SmSeats = 10;
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<IPaymentService>()
.HasSecretsManagerStandalone(request.InviteOrganization)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
_ = await sutProvider.Sut.ValidateAsync(request);
sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
.Received(1)
.ValidateUpdateAsync(Arg.Is<SecretsManagerSubscriptionUpdate>(x =>
x.SmSeatsChanged == true && x.SmSeats == 12));
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndHasSeatsAvailable_ThenShouldReturnValid(
Organization organization,
SutProvider<InviteOrganizationUsersValidator> 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<IPaymentService>()
.HasSecretsManagerStandalone(request.InviteOrganization)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var result = await sutProvider.Sut.ValidateAsync(request);
Assert.IsType<Valid<InviteOrganizationUsersValidationRequest>>(result);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndSmSeatUpdateFailsValidation_ThenShouldReturnInvalid(
Organization organization,
SutProvider<InviteOrganizationUsersValidator> 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<IPaymentService>()
.HasSecretsManagerStandalone(request.InviteOrganization)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
.ValidateUpdateAsync(Arg.Any<SecretsManagerSubscriptionUpdate>())
.Throws(new BadRequestException("Some Secrets Manager Failure"));
var result = await sutProvider.Sut.ValidateAsync(request);
Assert.IsType<Invalid<InviteOrganizationUsersValidationRequest>>(result);
Assert.Equal("Some Secrets Manager Failure", (result as Invalid<InviteOrganizationUsersValidationRequest>)!.ErrorMessageString);
}
}

View File

@ -0,0 +1,58 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
[SutProviderCustomize]
public class InviteUserOrganizationValidationTests
{
[Theory]
[BitAutoData]
public async Task Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization, SutProvider<InviteUsersOrganizationValidator> sutProvider)
{
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var result = await sutProvider.Sut.ValidateAsync(inviteOrganization);
Assert.IsType<Valid<InviteOrganization>>(result);
}
[Theory]
[BitAutoData]
public async Task Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage(
Organization organization, SutProvider<InviteUsersOrganizationValidator> sutProvider)
{
organization.GatewayCustomerId = string.Empty;
organization.Seats = 3;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var result = await sutProvider.Sut.ValidateAsync(inviteOrganization);
Assert.IsType<Invalid<InviteOrganization>>(result);
Assert.Equal(OrganizationNoPaymentMethodFoundError.Code, (result as Invalid<InviteOrganization>)!.ErrorMessageString);
}
[Theory]
[BitAutoData]
public async Task Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage(
Organization organization, SutProvider<InviteUsersOrganizationValidator> sutProvider)
{
organization.GatewaySubscriptionId = string.Empty;
organization.Seats = 3;
organization.MaxAutoscaleSeats = 4;
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
var result = await sutProvider.Sut.ValidateAsync(inviteOrganization);
Assert.IsType<Invalid<InviteOrganization>>(result);
Assert.Equal(OrganizationNoSubscriptionFoundError.Code, (result as Invalid<InviteOrganization>)!.ErrorMessageString);
}
}

View File

@ -0,0 +1,56 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public class InviteUserPaymentValidationTests
{
[Theory]
[BitAutoData]
public void Validate_WhenPlanIsFree_ReturnsValidResponse(Organization organization)
{
organization.PlanType = PlanType.Free;
var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription
{
SubscriptionStatus = StripeConstants.SubscriptionStatus.Active,
ProductTierType = new InviteOrganization(organization, new FreePlan()).Plan.ProductTier
});
Assert.IsType<Valid<PaymentsSubscription>>(result);
}
[Fact]
public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse()
{
var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription
{
SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled,
ProductTierType = ProductTierType.Enterprise
});
Assert.IsType<Invalid<PaymentsSubscription>>(result);
Assert.Equal(PaymentCancelledSubscriptionError.Code, (result as Invalid<PaymentsSubscription>)!.ErrorMessageString);
}
[Fact]
public void Validate_WhenSubscriptionIsActive_ReturnsValidResponse()
{
var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription
{
SubscriptionStatus = StripeConstants.SubscriptionStatus.Active,
ProductTierType = ProductTierType.Enterprise
});
Assert.IsType<Valid<PaymentsSubscription>>(result);
}
}

View File

@ -0,0 +1,93 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
[SutProviderCustomize]
public class InviteUsersPasswordManagerValidatorTests
{
[Theory]
[BitAutoData]
public async Task Validate_OrganizationDoesNotHaveSeatsLimit_ShouldReturnValidResult(Organization organization,
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
{
organization.Seats = null;
var organizationDto = new InviteOrganization(organization, new FreePlan());
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, 0, 0);
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
Assert.IsType<Valid<PasswordManagerSubscriptionUpdate>>(result);
}
[Theory]
[BitAutoData]
public async Task Validate_NumberOfSeatsToAddMatchesSeatsAvailable_ShouldReturnValidResult(Organization organization,
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
{
organization.Seats = 8;
organization.PlanType = PlanType.EnterpriseAnnually;
var seatsOccupiedByUsers = 4;
var additionalSeats = 4;
var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true));
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats);
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
Assert.IsType<Valid<PasswordManagerSubscriptionUpdate>>(result);
}
[Theory]
[BitAutoData]
public async Task Validate_NumberOfSeatsToAddIsGreaterThanMaxSeatsAllowed_ShouldBeInvalidWithSeatLimitMessage(Organization organization,
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
{
organization.Seats = 4;
organization.MaxAutoscaleSeats = 4;
organization.PlanType = PlanType.EnterpriseAnnually;
var seatsOccupiedByUsers = 4;
var additionalSeats = 1;
var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true));
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats);
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
Assert.IsType<Invalid<PasswordManagerSubscriptionUpdate>>(result);
Assert.Equal(PasswordManagerSeatLimitHasBeenReachedError.Code, (result as Invalid<PasswordManagerSubscriptionUpdate>)!.ErrorMessageString);
}
[Theory]
[BitAutoData]
public async Task Validate_GivenThePlanDoesNotAllowAdditionalSeats_ShouldBeInvalidMessageOfPlanNotAllowingSeats(Organization organization,
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
{
organization.Seats = 4;
organization.MaxAutoscaleSeats = 9;
var seatsOccupiedByUsers = 4;
var additionalSeats = 4;
organization.PlanType = PlanType.Free;
var organizationDto = new InviteOrganization(organization, new FreePlan());
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats);
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
Assert.IsType<Invalid<PasswordManagerSubscriptionUpdate>>(result);
Assert.Equal(PasswordManagerPlanDoesNotAllowAdditionalSeatsError.Code, (result as Invalid<PasswordManagerSubscriptionUpdate>)!.ErrorMessageString);
}
}

View File

@ -2,10 +2,10 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
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.Billing.Pricing;
using Bit.Core.Context;
@ -15,7 +15,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;
@ -37,6 +36,7 @@ using NSubstitute.ReturnsExtensions;
using Xunit;
using Organization = Bit.Core.AdminConsole.Entities.Organization;
using OrganizationUser = Bit.Core.Entities.OrganizationUser;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
namespace Bit.Core.Test.Services;
@ -77,15 +77,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);
@ -100,9 +91,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)
@ -152,16 +145,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()
@ -179,14 +162,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 &&
@ -270,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,
@ -637,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>>())
@ -655,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?)>>());
}
@ -712,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);
@ -733,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()));
}
@ -787,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);
@ -806,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?)>>());
}
@ -848,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?)>>());
}