1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

Auth/PM-1658 - Dynamic Org Invite Link to accelerate users through org invite accept process (#3378)

* PM-1658 - Create User_ReadByEmails stored proc

* PM-1658 - Update UserRepository.cs with dapper and EF implementations of GetManyByEmailsAsync using new stored proc

* PM-1658 - OrganizationService.cs - Proved out that the new GetManyByEmailsAsync along with a hash set will allow me to generate a a dict mapping org user ids to a bool representing if they have an org user account or not.

* PM-1658 - OrganizationService.cs - re-implement all send invites logic as part of rebase

* PM-1658 - Add new User_ReadByEmails stored proc to SQL project

* PM-1658 - HandlebarsMailService.cs - (1) Remove unnecessary SendOrganizationInviteEmailAsync method as we can simply use the bulk method for one or more emails (2) Refactor BulkSendOrganizationInviteEmailAsync parameters into new OrganizationInvitesInfo class

* PM-1658 - OrganizationService.cs - rebase commit 2

* PM-1658 - rebase commit 3 - org service + IMailService conflicts resolved

* PM-1658 - Update HandlebarsMailService.cs and OrganizationUserInvitedViewModel.cs to include new query params required client side for accelerating the user through the org invite accept process.

* dotnet format

* PM-1658 - rebase commit 4 -  Fix broken OrganizationServiceTests.cs

* PM-1658 TODO cleanup

* PM-1658 - Remove noop for deleted method.

* rebase commit 5 - fix NoopMailService merge conflicts

* PM-1658 - Fix SQL formatting with proper indentations

* PM-1658 - Rename BulkSendOrganizationInviteEmailAsync to SendOrganizationInviteEmailsAsync per PR feedback

* PM-1658 - Per PR Feedback, refactor OrganizationUserInvitedViewModel creation to use new static factory function for better encapsulation of the creation process.

* PM-1658 - Rename OrganizationInvitesInfo.Invites to OrgUserTokenPairs b/c that just makes sense.

* PM-1658 - Per PR feedback, simplify query params sent down to client. Always include whether the user exists but only include the org sso identifier if it is meant to be used (b/c sso is enabled and sso required policy is on)

* dotnet format

* PM-1658 - OrganizationServiceTests.cs - Fix mysteriously failing tests - several tests were falling into logic which created n org users using the organizationUserRepository.CreateAsync instead of the organizationUserRepository.CreateManyAsync method.  This meant that I had to add a new mock helper to ensure that those created org users had valid and distinct guids to avoid aggregate exceptions due to my added dict in the latter parts of the invite process.

* PM-1658 - Resolve errors from mistakes made during rebase merge conflict resolutions

* PM-1658 - OrganizationServiceTests.cs - fix new test with mock to make guids unique.

* dotnet format

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Jared Snider 2023-12-18 11:16:17 -05:00 committed by GitHub
parent d206c03ad1
commit d2808b2615
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 349 additions and 75 deletions

View File

@ -18,6 +18,7 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Mail;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Settings;
@ -1078,6 +1079,54 @@ public class OrganizationService : IOrganizationService
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
{
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
// convert single org user into array of 1 org user
var orgUsers = new[] { orgUser };
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(
IEnumerable<OrganizationUser> orgUsers,
Organization organization,
bool initOrganization = false)
{
// Materialize the sequence into a list to avoid multiple enumeration warnings
var orgUsersList = orgUsers.ToList();
// Email links must include information about the org and user for us to make routing decisions client side
// Given an org user, determine if existing BW user exists
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails);
// hash existing users emails list for O(1) lookups
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
ou => ou.Id,
ou => existingUserEmailsHashSet.Contains(ou.Email)
);
// Determine if org has SSO enabled and if user is required to login with SSO
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id)).Enabled;
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso)).Enabled;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
@ -1087,22 +1136,12 @@ public class OrganizationService : IOrganizationService
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
await _mailService.BulkSendOrganizationInviteEmailAsync(
organization.Name,
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
organization.PlanType == PlanType.Free
);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
await _mailService.SendOrganizationInviteEmailAsync(
organization.Name,
orgUser,
new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate),
organization.PlanType == PlanType.Free,
orgUserHasExistingUserDict,
initOrganization
);
}

View File

@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.Models.Mail;
public class OrganizationInvitesInfo
{
public OrganizationInvitesInfo(
Organization org,
bool orgSsoEnabled,
bool orgSsoLoginRequiredPolicyEnabled,
IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs,
Dictionary<Guid, bool> orgUserHasExistingUserDict,
bool initOrganization = false
)
{
OrganizationName = org.Name;
OrgSsoIdentifier = org.Identifier;
IsFreeOrg = org.PlanType == PlanType.Free;
InitOrganization = initOrganization;
OrgSsoEnabled = orgSsoEnabled;
OrgSsoLoginRequiredPolicyEnabled = orgSsoLoginRequiredPolicyEnabled;
OrgUserTokenPairs = orgUserTokenPairs;
OrgUserHasExistingUserDict = orgUserHasExistingUserDict;
}
public string OrganizationName { get; }
public bool IsFreeOrg { get; }
public bool InitOrganization { get; } = false;
public bool OrgSsoEnabled { get; }
public string OrgSsoIdentifier { get; }
public bool OrgSsoLoginRequiredPolicyEnabled { get; }
public IEnumerable<(OrganizationUser OrgUser, ExpiringToken Token)> OrgUserTokenPairs { get; }
public Dictionary<Guid, bool> OrgUserHasExistingUserDict { get; }
}

View File

@ -1,7 +1,46 @@
namespace Bit.Core.Models.Mail;
using System.Net;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Mail;
public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel
{
// Private constructor to enforce usage of the factory method.
private OrganizationUserInvitedViewModel() { }
public static OrganizationUserInvitedViewModel CreateFromInviteInfo(
OrganizationInvitesInfo orgInvitesInfo,
OrganizationUser orgUser,
ExpiringToken expiringToken,
GlobalSettings globalSettings)
{
var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!";
return new OrganizationUserInvitedViewModel
{
TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ",
TitleSecondBold = orgInvitesInfo.IsFreeOrg ? string.Empty : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false),
TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!",
OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status,
Email = WebUtility.UrlEncode(orgUser.Email),
OrganizationId = orgUser.OrganizationId.ToString(),
OrganizationUserId = orgUser.Id.ToString(),
Token = WebUtility.UrlEncode(expiringToken.Token),
ExpirationDate = $"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC",
OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName),
WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash,
SiteName = globalSettings.SiteName,
InitOrganization = orgInvitesInfo.InitOrganization,
OrgSsoIdentifier = orgInvitesInfo.OrgSsoIdentifier,
OrgSsoEnabled = orgInvitesInfo.OrgSsoEnabled,
OrgSsoLoginRequiredPolicyEnabled = orgInvitesInfo.OrgSsoLoginRequiredPolicyEnabled,
OrgUserHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id]
};
}
public string OrganizationName { get; set; }
public string OrganizationId { get; set; }
public string OrganizationUserId { get; set; }
@ -10,13 +49,34 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel
public string Token { get; set; }
public string ExpirationDate { get; set; }
public bool InitOrganization { get; set; }
public string Url => string.Format("{0}/accept-organization?organizationId={1}&" +
"organizationUserId={2}&email={3}&organizationName={4}&token={5}&initOrganization={6}",
WebVaultUrl,
OrganizationId,
OrganizationUserId,
Email,
OrganizationNameUrlEncoded,
Token,
InitOrganization);
public string OrgSsoIdentifier { get; set; }
public bool OrgSsoEnabled { get; set; }
public bool OrgSsoLoginRequiredPolicyEnabled { get; set; }
public bool OrgUserHasExistingUser { get; set; }
public string Url
{
get
{
var baseUrl = $"{WebVaultUrl}/accept-organization";
var queryParams = new List<string>
{
$"organizationId={OrganizationId}",
$"organizationUserId={OrganizationUserId}",
$"email={Email}",
$"organizationName={OrganizationNameUrlEncoded}",
$"token={Token}",
$"initOrganization={InitOrganization}",
$"orgUserHasExistingUser={OrgUserHasExistingUser}"
};
if (OrgSsoEnabled && OrgSsoLoginRequiredPolicyEnabled)
{
// Only send down the orgSsoIdentifier if we are going to accelerate the user to the SSO login page.
queryParams.Add($"orgSsoIdentifier={OrgSsoIdentifier}");
}
return $"{baseUrl}?{string.Join("&", queryParams)}";
}
}
}

View File

@ -7,6 +7,7 @@ namespace Bit.Core.Repositories;
public interface IUserRepository : IRepository<User, Guid>
{
Task<User> GetByEmailAsync(string email);
Task<IEnumerable<User>> GetManyByEmailsAsync(IEnumerable<string> emails);
Task<User> GetBySsoUserAsync(string externalId, Guid? organizationId);
Task<UserKdfInformation> GetKdfInformationByEmailAsync(string email);
Task<ICollection<User>> SearchAsync(string email, int skip, int take);

View File

@ -1,7 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
@ -17,8 +16,12 @@ public interface IMailService
Task SendTwoFactorEmailAsync(string email, string token);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);
Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false);
Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false);
/// <summary>
/// Sends one or many organization invite emails.
/// </summary>
/// <param name="orgInvitesInfo">The information required to send the organization invites.</param>
Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo);
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails);

View File

@ -3,7 +3,6 @@ using System.Reflection;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Mail;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
@ -207,35 +206,20 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false) =>
BulkSendOrganizationInviteEmailAsync(organizationName, new[] { (orgUser, token) }, isFreeOrg, initOrganization);
public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false)
public async Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo)
{
MailQueueMessage CreateMessage(string email, object model)
{
var message = CreateDefaultMessage($"Join {organizationName}", email);
var message = CreateDefaultMessage($"Join {orgInvitesInfo.OrganizationName}", email);
return new MailQueueMessage(message, "OrganizationUserInvited", model);
}
var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!";
var messageModels = invites.Select(invite => CreateMessage(invite.orgUser.Email,
new OrganizationUserInvitedViewModel
var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair =>
{
TitleFirst = isFreeOrg ? freeOrgTitle : "Join ",
TitleSecondBold = isFreeOrg ? string.Empty : CoreHelpers.SanitizeForEmail(organizationName, false),
TitleThird = isFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!",
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false) + invite.orgUser.Status,
Email = WebUtility.UrlEncode(invite.orgUser.Email),
OrganizationId = invite.orgUser.OrganizationId.ToString(),
OrganizationUserId = invite.orgUser.Id.ToString(),
Token = WebUtility.UrlEncode(invite.token.Token),
ExpirationDate = $"{invite.token.ExpirationDate.ToLongDateString()} {invite.token.ExpirationDate.ToShortTimeString()} UTC",
OrganizationNameUrlEncoded = WebUtility.UrlEncode(organizationName),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
InitOrganization = initOrganization
}
));
var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(
orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings);
return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel);
});
await EnqueueMailAsync(messageModels);
}

View File

@ -1,7 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
@ -54,12 +53,7 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false)
{
return Task.FromResult(0);
}
public Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false)
public Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo)
{
return Task.FromResult(0);
}

View File

@ -48,6 +48,27 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
}
}
public async Task<IEnumerable<User>> GetManyByEmailsAsync(IEnumerable<string> emails)
{
var emailTable = new DataTable();
emailTable.Columns.Add("Email", typeof(string));
foreach (var email in emails)
{
emailTable.Rows.Add(email);
}
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<User>(
$"[{Schema}].[{Table}_ReadByEmails]",
new { Emails = emailTable.AsTableValuedParameter("dbo.EmailArray") },
commandType: CommandType.StoredProcedure);
UnprotectData(results);
return results.ToList();
}
}
public async Task<User> GetBySsoUserAsync(string externalId, Guid? organizationId)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@ -24,6 +24,18 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
}
}
public async Task<IEnumerable<Core.Entities.User>> GetManyByEmailsAsync(IEnumerable<string> emails)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var users = await GetDbSet(dbContext)
.Where(u => emails.Contains(u.Email))
.ToListAsync();
return Mapper.Map<List<Core.Entities.User>>(users);
}
}
public async Task<DataModel.UserKdfInformation> GetKdfInformationByEmailAsync(string email)
{
using (var scope = ServiceScopeFactory.CreateScope())

View File

@ -0,0 +1,19 @@
CREATE PROCEDURE [dbo].[User_ReadByEmails]
@Emails AS [dbo].[EmailArray] READONLY
AS
BEGIN
SET NOCOUNT ON;
IF (SELECT COUNT(1) FROM @Emails) < 1
BEGIN
RETURN(-1)
END
SELECT
*
FROM
[dbo].[UserView]
WHERE
[Email] IN (SELECT [Email] FROM @Emails)
END
GO

View File

@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
@ -17,6 +16,7 @@ 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.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
@ -68,11 +68,15 @@ public class OrganizationServiceTests
existingUsers.First().Type = OrganizationUserType.Owner;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyDetailsByOrganizationAsync(org.Id)
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
organizationUserRepository.GetManyDetailsByOrganizationAsync(org.Id)
.Returns(existingUsers);
sutProvider.GetDependency<IOrganizationUserRepository>().GetCountByOrganizationIdAsync(org.Id)
organizationUserRepository.GetCountByOrganizationIdAsync(org.Id)
.Returns(existingUsers.Count);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner)
organizationUserRepository.GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner)
.Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList());
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
@ -98,9 +102,10 @@ public class OrganizationServiceTests
// Create new users
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(org.Name,
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free);
.SendOrganizationInviteEmailsAsync(
Arg.Is<OrganizationInvitesInfo>(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
// Send events
await sutProvider.GetDependency<IEventService>().Received(1)
@ -139,8 +144,14 @@ public class OrganizationServiceTests
.Returns(existingUsers.Count);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(reInvitedUser.Id)
.Returns(new OrganizationUser { Id = reInvitedUser.Id });
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner)
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
organizationUserRepository.GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner)
.Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList());
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
var currentContext = sutProvider.GetDependency<ICurrentContext>();
currentContext.ManageUsers(org.Id).Returns(true);
@ -170,9 +181,10 @@ public class OrganizationServiceTests
// Created and invited new users
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(org.Name,
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free);
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
// Sent events
await sutProvider.GetDependency<IEventService>().Received(1)
@ -396,6 +408,9 @@ public class OrganizationServiceTests
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>())
@ -406,11 +421,15 @@ public class OrganizationServiceTests
}
);
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) });
await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(organization.Name,
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invite.Emails.Distinct().Count()), organization.PlanType == PlanType.Free);
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
}
[Theory]
@ -516,6 +535,9 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { invitor });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationOwner(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(true);
@ -543,6 +565,9 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { invitor });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationOwner(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(true);
@ -567,6 +592,8 @@ public class OrganizationServiceTests
var currentContext = sutProvider.GetDependency<ICurrentContext>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationCustom(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(false);
@ -618,6 +645,9 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { invitor });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationOwner(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(true);
@ -683,11 +713,16 @@ public class OrganizationServiceTests
}
);
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, invites);
await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(organization.Name,
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free);
.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<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
@ -719,6 +754,10 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { owner });
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.ManageUsers(organization.Id).Returns(true);
// Mock tokenable factory to return a token that expires in 5 days
@ -734,8 +773,10 @@ public class OrganizationServiceTests
await sutProvider.Sut.InviteUsersAsync(organization.Id, eventSystemUser, invites);
await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(organization.Name,
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free);
.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<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>());
}
@ -760,6 +801,10 @@ public class OrganizationServiceTests
sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>()
.CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, invites);
sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>().Received(1)
@ -2074,4 +2119,35 @@ public class OrganizationServiceTests
Assert.Contains("custom users can only grant the same custom permissions that they have.", exception.Message.ToLowerInvariant());
}
// Must set real guids in order for dictionary of guids to not throw aggregate exceptions
private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository)
{
organizationUserRepository.CreateManyAsync(Arg.Any<IEnumerable<OrganizationUser>>()).Returns(
info =>
{
var orgUsers = info.Arg<IEnumerable<OrganizationUser>>();
foreach (var orgUser in orgUsers)
{
orgUser.Id = Guid.NewGuid();
}
return Task.FromResult<ICollection<Guid>>(orgUsers.Select(u => u.Id).ToList());
}
);
}
// Must set real guids in order for dictionary of guids to not throw aggregate exceptions
private void SetupOrgUserRepositoryCreateAsyncMock(IOrganizationUserRepository organizationUserRepository)
{
organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>()).Returns(
info =>
{
var orgUser = info.Arg<OrganizationUser>();
orgUser.Id = Guid.NewGuid();
return Task.FromResult<Guid>(orgUser.Id);
}
);
}
}

View File

@ -0,0 +1,24 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE OR ALTER PROCEDURE [dbo].[User_ReadByEmails]
@Emails AS [dbo].[EmailArray] READONLY
AS
BEGIN
SET NOCOUNT ON;
IF (SELECT COUNT(1) FROM @Emails) < 1
BEGIN
RETURN(-1)
END
SELECT
*
FROM
[dbo].[UserView]
WHERE
[Email] IN (SELECT [Email] FROM @Emails)
END
GO