From 82e757b9879d9eaac0eb010d1380f9aa3ddb3a57 Mon Sep 17 00:00:00 2001 From: Brandon Date: Fri, 6 Jun 2025 13:05:50 -0400 Subject: [PATCH] add xmldoc, refactor to make InviteOrganizationUsersCommand vNext instead of default --- .../OrganizationGroupImportData.cs | 17 +- .../OrganizationUserImportData.cs | 38 +-- .../ImportOrganizationUserCommand.cs | 268 ++++++++++++++---- .../ImportOrganizationUserCommandTests.cs | 121 ++++++-- 4 files changed, 329 insertions(+), 115 deletions(-) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationGroupImportData.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationGroupImportData.cs index ab059086ec..fb7c21e00a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationGroupImportData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationGroupImportData.cs @@ -5,23 +5,16 @@ namespace Bit.Core.Models.Data.Organizations; public class OrganizationGroupImportData { - public IEnumerable Groups { get; init; } - public ICollection ExistingGroups { get; init; } - public IEnumerable ExistingExternalGroups { get; init; } - public IDictionary GroupsDict { get; init; } + public readonly IEnumerable Groups; + public readonly ICollection ExistingGroups; + public readonly IEnumerable ExistingExternalGroups; + public readonly IDictionary GroupsDict; public OrganizationGroupImportData(IEnumerable groups, ICollection existingGroups) { Groups = groups; GroupsDict = groups.ToDictionary(g => g.Group.ExternalId); ExistingGroups = existingGroups; - ExistingExternalGroups = GetExistingExternalGroups(existingGroups); - } - - private IEnumerable GetExistingExternalGroups(ICollection existingGroups) - { - return existingGroups - .Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)) - .ToList(); + ExistingExternalGroups = existingGroups.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserImportData.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserImportData.cs index d9d7fd3282..117e34d637 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserImportData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserImportData.cs @@ -1,24 +1,30 @@ -namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Models.Business; +namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; public class OrganizationUserImportData { - public HashSet NewUsersSet { get; init; } - public ICollection ExistingUsers { get; init; } - public IEnumerable ExistingExternalUsers { get; init; } - public Dictionary ExistingExternalUsersIdDict { get; init; } + /// + /// Set of user ExternalIds that are being imported + /// + public readonly HashSet ImportedExternalIds; + /// + /// Exising organization users details + /// + public readonly ICollection ExistingUsers; + /// + /// List of ExternalIds belonging to existing organization Users + /// + public readonly IEnumerable ExistingExternalUsers; + /// + /// Mapping of an existing users's ExternalId to their Id + /// + public readonly Dictionary ExistingExternalUsersIdDict; - public OrganizationUserImportData(ICollection existingUsers, HashSet newUsersSet) + public OrganizationUserImportData(ICollection existingUsers, IEnumerable importedUsers) { - NewUsersSet = newUsersSet; + ImportedExternalIds = new HashSet(importedUsers?.Select(u => u.ExternalId) ?? new List()); ExistingUsers = existingUsers; - ExistingExternalUsers = GetExistingExternalUsers(existingUsers); - ExistingExternalUsersIdDict = GetExistingExternalUsers(existingUsers).ToDictionary(u => u.ExternalId, u => u.Id); - } - - private IEnumerable GetExistingExternalUsers(ICollection existingUsers) - { - return existingUsers - .Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)) - .ToList(); + ExistingExternalUsers = ExistingUsers.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + ExistingExternalUsersIdDict = ExistingExternalUsers.ToDictionary(u => u.ExternalId, u => u.Id); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommand.cs index 4c5f7a1d4b..e3716e0bc3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommand.cs @@ -11,11 +11,14 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; +#nullable enable + namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; public class ImportOrganizationUserCommand : IImportOrganizationUserCommand @@ -54,14 +57,25 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand _pricingClient = pricingClient; } + /// + /// Imports and synchronizes organization users and groups. + /// + /// The unique identifier of the organization. + /// List of groups to import. + /// List of users to import. + /// A collection of ExternalUserIds to be removed from the organization. + /// Indicates whether to delete existing external users from the organization + /// who are not included in the current import. + /// Thrown if the organization does not exist. + /// Thrown if the organization is not configured to use directory syncing. public async Task ImportAsync(Guid organizationId, - IEnumerable groups, - IEnumerable newUsers, + IEnumerable importedGroups, + IEnumerable importedUsers, IEnumerable removeUserExternalIds, bool overwriteExisting) { var organization = await GetOrgById(organizationId); - if (organization == null) + if (organization is null) { throw new NotFoundException(); } @@ -72,7 +86,7 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand } var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var importUserData = new OrganizationUserImportData(existingUsers, new HashSet(newUsers?.Select(u => u.ExternalId) ?? new List())); + var importUserData = new OrganizationUserImportData(existingUsers, importedUsers); var events = new List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)>(); await RemoveExistingExternalUsers(removeUserExternalIds, events, importUserData); @@ -82,27 +96,21 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand await OverwriteExisting(events, importUserData); } - await UpsertExistingUsers(newUsers, importUserData); + await Update(importedUsers, importUserData); - await AddNewUsers(organization, newUsers, importUserData); + await AddNewUsers(organization, importedUsers, importUserData); - await ImportGroups(organization, groups, importUserData); + await ImportGroups(organization, importedGroups, importUserData); await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, _EventSystemUser, e.d))); } - private async Task UpdateUsersAsync(Group group, HashSet groupUsers, - Dictionary existingUsersIdDict, HashSet existingUsers = null) - { - var availableUsers = groupUsers.Intersect(existingUsersIdDict.Keys); - var users = new HashSet(availableUsers.Select(u => existingUsersIdDict[u])); - if (existingUsers != null && existingUsers.Count == users.Count && users.SetEquals(existingUsers)) - { - return; - } - - await _groupRepository.UpdateUsersAsync(group.Id, users); - } + /// + /// Deletes external users based on provided set of ExternalIds. + /// + /// A collection of external user IDs to be deleted. + /// A list to which user removal events will be added. + /// Data containing imported and existing external users. private async Task RemoveExistingExternalUsers(IEnumerable removeUserExternalIds, List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events, @@ -114,8 +122,10 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand } var existingUsersDict = importUserData.ExistingExternalUsers.ToDictionary(u => u.ExternalId); + // Determine which ids in removeUserExternalIds to delete based on: + // They are not in ImportedExternalIds, they are in existingUsersDict, and they are not an owner. var removeUsersSet = new HashSet(removeUserExternalIds) - .Except(importUserData.NewUsersSet) + .Except(importUserData.ImportedExternalIds) .Where(u => existingUsersDict.ContainsKey(u) && existingUsersDict[u].Type != OrganizationUserType.Owner) .Select(u => existingUsersDict[u]); @@ -128,55 +138,160 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand ); } - private async Task UpsertExistingUsers(IEnumerable newUsers, OrganizationUserImportData importUserData) + /// + /// Updates existing organization users by assigning each an ExternalId from the imported user data + /// where a match is found by email and the existing user lacks an ExternalId. Saves the updated + /// users and updates the ExistingExternalUsersIdDict mapping. + /// + /// List of imported organization users. + /// Data containing existing and imported users, along with mapping dictionaries. + private async Task Update(IEnumerable importedUsers, OrganizationUserImportData importUserData) { - if (!newUsers.Any()) + if (!importedUsers.Any()) { return; } - // Marry existing users + var updateUsers = new List(); + + // Map existing and imported users to dicts keyed by Email var existingUsersEmailsDict = importUserData.ExistingUsers .Where(u => string.IsNullOrWhiteSpace(u.ExternalId)) .ToDictionary(u => u.Email); - var newUsersEmailsDict = newUsers.ToDictionary(u => u.Email); - var newAndExistingUsersIntersection = existingUsersEmailsDict.Keys.Intersect(newUsersEmailsDict.Keys).ToList(); - var organizationUsers = (await _organizationUserRepository.GetManyAsync(importUserData.ExistingUsers.Select(u => u.Id).ToList())).ToDictionary(u => u.Id); - var usersToUpsert = new List(); + var importedUsersEmailsDict = importedUsers.ToDictionary(u => u.Email); - foreach (var user in newAndExistingUsersIntersection) + // Determine which users to update. + var userDetailsToUpdate = existingUsersEmailsDict.Keys.Intersect(importedUsersEmailsDict.Keys).ToList(); + var userIdsToUpdate = importUserData.ExistingUsers + .Where(u => userDetailsToUpdate.Contains(u.Email)) + .Select(u => u.Id) + .ToList(); + + var organizationUsers = (await _organizationUserRepository.GetManyAsync(userIdsToUpdate)).ToDictionary(u => u.Id); + + foreach (var userEmail in userDetailsToUpdate) { - existingUsersEmailsDict.TryGetValue(user, out var existingUser); - organizationUsers.TryGetValue(existingUser.Id, out var organizationUser); + // verify userEmail has an associated OrganizationUser + existingUsersEmailsDict.TryGetValue(userEmail, out var existingUser); + organizationUsers.TryGetValue(existingUser!.Id, out var organizationUser); + importedUsersEmailsDict.TryGetValue(userEmail, out var user); - if (organizationUser != null) + if (organizationUser is null || user is null) { - organizationUser.ExternalId = newUsersEmailsDict[user].ExternalId; - usersToUpsert.Add(organizationUser); - importUserData.ExistingExternalUsersIdDict.Add(organizationUser.ExternalId, organizationUser.Id); + continue; } + + organizationUser.ExternalId = user.ExternalId; + updateUsers.Add(organizationUser); + importUserData.ExistingExternalUsersIdDict.Add(organizationUser.ExternalId, organizationUser.Id); } - await _organizationUserRepository.UpsertManyAsync(usersToUpsert); + await _organizationUserRepository.UpsertManyAsync(updateUsers); } + /// + /// Adds new external users to the organization by inviting users who are present in the imported data + /// but not already part of the organization. Sends invitations, updates the user Id mapping on success, + /// and throws exceptions on failure. + /// + /// The target organization to which users are being added. + /// A collection of imported users to consider for addition. + /// Data containing imported user info and existing user mappings. + private async Task AddNewUsers(Organization organization, - IEnumerable newUsers, + IEnumerable importedUsers, OrganizationUserImportData importUserData) { var userInvites = new List(); - foreach (var user in newUsers) + // Determine which users are already in the organization + var existingUsersSet = new HashSet(importUserData.ExistingExternalUsersIdDict.Keys).ToList(); + var usersToAdd = importUserData.ImportedExternalIds.Except(existingUsersSet).ToList(); + + foreach (var user in importedUsers) { + // Ignore users already part of the organization + if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) + { + continue; + } + userInvites.Add(new OrganizationUserInviteCommandModel(user.Email, user.ExternalId)); } - var commandResult = await InviteUsersAsync(userInvites, organization); + await InviteUsersAsync(organization, usersToAdd, importedUsers, importUserData); + } + + private async Task InviteUsersAsync(Organization organization, + IEnumerable usersToAdd, + IEnumerable importedUsers, + OrganizationUserImportData importUserData) + { + var seatsAvailable = int.MaxValue; + var enoughSeatsAvailable = true; + if (organization.Seats.HasValue) + { + var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + seatsAvailable = organization.Seats.Value - occupiedSeats; + enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count(); + } + + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + + var userInvites = new List<(OrganizationUserInvite, string)>(); + foreach (var user in importedUsers) + { + if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) + { + continue; + } + + try + { + var invite = new OrganizationUserInvite + { + Emails = new List { user.Email }, + Type = OrganizationUserType.User, + Collections = new List(), + AccessSecretsManager = hasStandaloneSecretsManager + }; + userInvites.Add((invite, user.ExternalId)); + } + catch (BadRequestException) + { + // Thrown when the user is already invited to the organization + continue; + } + } + + var invitedUsers = await _organizationService.InviteUsersAsync(organization.Id, Guid.Empty, _EventSystemUser, userInvites); + foreach (var invitedUser in invitedUsers) + { + importUserData.ExistingExternalUsersIdDict.TryAdd(invitedUser.ExternalId!, invitedUser.Id); + } + } + + /// + /// Creates an InviteOrganizationUsersRequest for the provided invites and sends the request via the InviteOrganizationUsersCommand. + /// + private async Task vNextInviteUsersAsync(List userInvites, + Organization organization, + OrganizationUserImportData importUserData) + { + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var inviteOrganization = new InviteOrganization(organization, plan); + var request = new InviteOrganizationUsersRequest(userInvites.ToArray(), inviteOrganization, Guid.Empty, DateTimeOffset.UtcNow); + var commandResult = await _inviteOrganizationUsersCommand.InviteImportedOrganizationUsersAsync(request); switch (commandResult) { case Success result: foreach (var u in result.Value.InvitedUsers) { + if (u.ExternalId is null) + { + continue; + } + importUserData.ExistingExternalUsersIdDict.Add(u.ExternalId, u.Id); } break; @@ -187,23 +302,19 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand } } - private async Task> InviteUsersAsync(List invites, Organization organization) - { - var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); - var inviteOrganization = new InviteOrganization(organization, plan); - var request = new InviteOrganizationUsersRequest(invites.ToArray(), inviteOrganization, Guid.Empty, DateTimeOffset.UtcNow); - - return await _inviteOrganizationUsersCommand.InviteImportedOrganizationUsersAsync(request); - } - + /// + /// Deletes existing external users from the organization who are not included in the current import and are not owners. + /// Records corresponding removal events and updates the internal mapping by removing deleted users. + /// + /// A list to which user removal events will be added. + /// Data containing existing and imported external users along with their Id mappings. private async Task OverwriteExisting( List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events, OrganizationUserImportData importUserData) { - // Remove existing external users that are not in new user set var usersToDelete = importUserData.ExistingExternalUsers.Where(u => u.Type != OrganizationUserType.Owner && - !importUserData.NewUsersSet.Contains(u.ExternalId) && + !importUserData.ImportedExternalIds.Contains(u.ExternalId) && importUserData.ExistingExternalUsersIdDict.ContainsKey(u.ExternalId)); await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); events.AddRange(usersToDelete.Select(u => ( @@ -218,33 +329,45 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand } } + /// + /// Imports group data into the organization by saving new groups and updating existing ones. + /// + /// The organization into which groups are being imported. + /// A collection of groups to be imported. + /// Data containing information about existing and imported users. private async Task ImportGroups(Organization organization, - IEnumerable groups, + IEnumerable importedGroups, OrganizationUserImportData importUserData) { - if (!groups.Any()) + if (!importedGroups.Any()) { return; } if (!organization.UseGroups) { - throw new BadRequestException("Organization cannot use groups."); + throw new BadRequestException("Organization cannot use importedGroups."); } var existingGroups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id); - var importGroupData = new OrganizationGroupImportData(groups, existingGroups); + var importGroupData = new OrganizationGroupImportData(importedGroups, existingGroups); await SaveNewGroups(importGroupData, importUserData); await UpdateExistingGroups(importGroupData, importUserData, organization); } + /// + /// Saves newly imported groups that do not already exist in the organization. + /// Sets their creation and revision dates, associates users with each group. + /// + /// Data containing both imported and existing groups. + /// Data containing information about existing and imported users. private async Task SaveNewGroups(OrganizationGroupImportData importGroupData, OrganizationUserImportData importUserData) { var newGroups = importGroupData.Groups - .Where(g => !importGroupData.ExistingExternalGroups.ToDictionary(g => g.ExternalId).ContainsKey(g.Group.ExternalId)) - .Select(g => g.Group).ToList(); + .Where(g => !importGroupData.ExistingExternalGroups.ToDictionary(g => g.ExternalId!).ContainsKey(g.Group.ExternalId!)) + .Select(g => g.Group).ToList()!; var savedGroups = new List(); foreach (var group in newGroups) @@ -260,6 +383,14 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)_EventSystemUser, (DateTime?)DateTime.UtcNow))); } + /// + /// Updates existing groups in the organization based on imported group data. + /// If a group's name has changed, it updates the name and revision date in the repository. + /// Also updates group-user associations. + /// + /// Data containing imported groups and their user associations. + /// Data containing imported and existing organization users. + /// The organization to which the groups belong. private async Task UpdateExistingGroups(OrganizationGroupImportData importGroupData, OrganizationUserImportData importUserData, Organization organization) @@ -270,6 +401,7 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand if (updateGroups.Any()) { + // get existing group users var groupUsers = await _groupRepository.GetManyGroupUsersByOrganizationIdAsync(organization.Id); var existingGroupUsers = groupUsers .GroupBy(gu => gu.GroupId) @@ -277,6 +409,7 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand foreach (var group in updateGroups) { + // Check for changes to the group, update if changed. var updatedGroup = importGroupData.GroupsDict[group.ExternalId].Group; if (group.Name != updatedGroup.Name) { @@ -286,6 +419,7 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand await _groupRepository.ReplaceAsync(group); } + // compare and update user group associations await UpdateUsersAsync(group, importGroupData.GroupsDict[group.ExternalId].ExternalUserIds, importUserData.ExistingExternalUsersIdDict, existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); @@ -298,7 +432,29 @@ public class ImportOrganizationUserCommand : IImportOrganizationUserCommand } - private async Task GetOrgById(Guid id) + /// + /// Updates the user associations for a given group. + /// Only updates if the set of associated users differs from the current group membership. + /// Filters users based on those present in the existing user Id dictionary. + /// + /// The group whose user associations are being updated. + /// A set of ExternalUserIds to be associated with the group. + /// A dictionary mapping ExternalUserIds to internal user Ids. + /// Optional set of currently associated user Ids for comparison. + private async Task UpdateUsersAsync(Group group, HashSet groupUsers, + Dictionary existingUsersIdDict, HashSet? existingUsers = null) + { + var availableUsers = groupUsers.Intersect(existingUsersIdDict.Keys); + var users = new HashSet(availableUsers.Select(u => existingUsersIdDict[u])); + if (existingUsers is not null && existingUsers.Count == users.Count && users.SetEquals(existingUsers)) + { + return; + } + + await _groupRepository.UpdateUsersAsync(group.Id, users); + } + + private async Task GetOrgById(Guid id) { return await _organizationRepository.GetByIdAsync(id); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommandTests.cs index 4abbd2b8af..8c682258ff 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ImportOrganizationUserCommandTests.cs @@ -1,13 +1,11 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -32,36 +30,46 @@ public class ImportOrganizationUserCommandTests SutProvider sutProvider, Organization org, List existingUsers, - List newUsers, + List importedUsers, List newGroups) { - SetupOrganizationConfigForImport(sutProvider, org, existingUsers, newUsers); + SetupOrganizationConfigForImport(sutProvider, org, existingUsers, importedUsers); - newUsers.Add(new ImportedOrganizationUser + var orgUsers = new List(); + + // fix mocked email format, mock OrganizationUsers. + foreach (var u in importedUsers) + { + u.Email += "@bitwardentest.com"; + orgUsers.Add(new OrganizationUser { Email = u.Email, ExternalId = u.ExternalId }); + } + + importedUsers.Add(new ImportedOrganizationUser { Email = existingUsers.First().Email, ExternalId = existingUsers.First().ExternalId }); - foreach (var u in newUsers) - { - u.Email += "@bitwardentest.com"; - } existingUsers.First().Type = OrganizationUserType.Owner; sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); - sutProvider.GetDependency().HasSecretsManagerStandalone(org).Returns(false); + + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + sutProvider.GetDependency().HasSecretsManagerStandalone(org).Returns(true); sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); sutProvider.GetDependency().GetCountByOrganizationIdAsync(org.Id).Returns(existingUsers.Count); sutProvider.GetDependency().ManageUsers(org.Id).Returns(true); - sutProvider.GetDependency().InviteImportedOrganizationUsersAsync(Arg.Any()) - .Returns(new Success(new InviteOrganizationUsersResponse(org.Id))); + sutProvider.GetDependency().InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Any>()) + .Returns(orgUsers); - await sutProvider.Sut.ImportAsync(org.Id, newGroups, newUsers, new List(), false); + await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false); + + var expectedNewUsersCount = importedUsers.Count - 1; - await sutProvider.GetDependency().Received(1) - .InviteImportedOrganizationUsersAsync(Arg.Any()); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .UpsertAsync(default); await sutProvider.GetDependency().Received(1) @@ -69,6 +77,11 @@ public class ImportOrganizationUserCommandTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .CreateAsync(default); + // Send Invites + await sutProvider.GetDependency().Received(1). + InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Is>(invites => invites.Count() == expectedNewUsersCount)); + // Send events await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Any>()); @@ -79,36 +92,55 @@ public class ImportOrganizationUserCommandTests SutProvider sutProvider, Organization org, List existingUsers, - List newUsers, + List importedUsers, List newGroups) { - SetupOrganizationConfigForImport(sutProvider, org, existingUsers, newUsers); + SetupOrganizationConfigForImport(sutProvider, org, existingUsers, importedUsers); + var orgUsers = new List(); var reInvitedUser = existingUsers.First(); - reInvitedUser.ExternalId = null; + // Existing user has no external ID. This will make the SUT call UpsertManyAsync + reInvitedUser.ExternalId = ""; + + // Mock an existing org user for this "existing" user + var reInvitedOrgUser = new OrganizationUser { Email = reInvitedUser.Email, Id = reInvitedUser.Id }; + + // fix email formatting, mock orgUsers to be returned foreach (var u in existingUsers) { u.Email += "@bitwardentest.com"; + orgUsers.Add(new OrganizationUser { Email = u.Email, ExternalId = u.ExternalId }); } - foreach (var u in newUsers) + foreach (var u in importedUsers) { u.Email += "@bitwardentest.com"; + orgUsers.Add(new OrganizationUser { Email = u.Email, ExternalId = u.ExternalId }); } - newUsers.Add(new ImportedOrganizationUser + // add the existing user to be re-imported + importedUsers.Add(new ImportedOrganizationUser { Email = reInvitedUser.Email, ExternalId = reInvitedUser.Email, }); + var expectedNewUsersCount = importedUsers.Count - 1; + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + sutProvider.GetDependency().GetManyAsync(Arg.Any>()) + .Returns(new List([reInvitedOrgUser])); sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); sutProvider.GetDependency().GetCountByOrganizationIdAsync(org.Id).Returns(existingUsers.Count); - sutProvider.GetDependency().GetManyAsync(Arg.Any>()).Returns(new List { new OrganizationUser { Id = reInvitedUser.Id } }); - sutProvider.GetDependency().InviteImportedOrganizationUsersAsync(Arg.Any()) - .Returns(new Success(new InviteOrganizationUsersResponse(org.Id))); - await sutProvider.Sut.ImportAsync(org.Id, newGroups, newUsers, new List(), false); + sutProvider.GetDependency().InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Any>()) + .Returns(orgUsers); + + await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .UpsertAsync(default); @@ -119,28 +151,55 @@ public class ImportOrganizationUserCommandTests // Upserted existing user await sutProvider.GetDependency().Received(1) - .UpsertManyAsync(Arg.Is>(users => users.Count() == 1)); + .UpsertManyAsync(Arg.Is>(users => users.Count() == 1 && users.First() == reInvitedOrgUser)); - await sutProvider.GetDependency().Received(1) - .InviteImportedOrganizationUsersAsync(Arg.Any()); + // Send Invites + await sutProvider.GetDependency().Received(1). + InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Is>(invites => invites.Count() == expectedNewUsersCount)); // Send events await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Any>()); - } private void SetupOrganizationConfigForImport( SutProvider sutProvider, Organization org, List existingUsers, - List newUsers) + List importedUsers) { // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); sutProvider.Create(); org.UseDirectory = true; - org.Seats = newUsers.Count + existingUsers.Count + 1; + org.Seats = importedUsers.Count + existingUsers.Count + 1; + } + + // Must set real guids in order for dictionary of guids to not throw aggregate exceptions + private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository) + { + organizationUserRepository.CreateManyAsync(Arg.Any>()).Returns( + info => + { + var orgUsers = info.Arg>(); + foreach (var orgUser in orgUsers) + { + orgUser.Id = Guid.NewGuid(); + } + + return Task.FromResult>(orgUsers.Select(u => u.Id).ToList()); + } + ); + + organizationUserRepository.CreateAsync(Arg.Any(), Arg.Any>()).Returns( + info => + { + var orgUser = info.Arg(); + orgUser.Id = Guid.NewGuid(); + return Task.FromResult(orgUser.Id); + } + ); } }