diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 516e43420..f15850a8d 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -554,7 +554,7 @@ public class ProviderService : IProviderService ] : Array.Empty(); - await _organizationService.InviteUsersAsync(organization.Id, user.Id, + await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null, new (OrganizationUserInvite, string)[] { ( diff --git a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs index a489e03ad..e95e197db 100644 --- a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs @@ -1,8 +1,67 @@ -namespace Bit.Scim.Models; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Scim.Models; public class ScimUserRequestModel : BaseScimUserModel { public ScimUserRequestModel() : base(false) { } + + public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider) + { + return new OrganizationUserInvite + { + Emails = new[] { EmailForInvite(scimProvider) }, + + // Permissions cannot be set via SCIM so we use default values + Type = OrganizationUserType.User, + AccessAll = false, + Collections = new List(), + Groups = new List() + }; + } + + private string EmailForInvite(ScimProviderType scimProvider) + { + var email = PrimaryEmail?.ToLowerInvariant(); + + if (!string.IsNullOrWhiteSpace(email)) + { + return email; + } + + switch (scimProvider) + { + case ScimProviderType.AzureAd: + return UserName?.ToLowerInvariant(); + default: + email = WorkEmail?.ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(email)) + { + email = Emails?.FirstOrDefault()?.Value?.ToLowerInvariant(); + } + + return email; + } + } + + public string ExternalIdForInvite() + { + if (!string.IsNullOrWhiteSpace(ExternalId)) + { + return ExternalId; + } + + if (!string.IsNullOrWhiteSpace(UserName)) + { + return UserName; + } + + return CoreHelpers.RandomString(15); + } } diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index a96633e01..26ddd2051 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -1,11 +1,8 @@ -using Bit.Core.AdminConsole.Enums; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Bit.Scim.Context; using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; @@ -36,23 +33,11 @@ public class PostUserCommand : IPostUserCommand public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) { - var email = model.PrimaryEmail?.ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(email)) - { - switch (_scimContext.RequestScimProvider) - { - case ScimProviderType.AzureAd: - email = model.UserName?.ToLowerInvariant(); - break; - default: - email = model.WorkEmail?.ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(email)) - { - email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant(); - } - break; - } - } + var scimProvider = _scimContext.RequestScimProvider; + var invite = model.ToOrganizationUserInvite(scimProvider); + + var email = invite.Emails.Single(); + var externalId = model.ExternalIdForInvite(); if (string.IsNullOrWhiteSpace(email) || !model.Active) { @@ -66,20 +51,6 @@ public class PostUserCommand : IPostUserCommand throw new ConflictException(); } - string externalId = null; - if (!string.IsNullOrWhiteSpace(model.ExternalId)) - { - externalId = model.ExternalId; - } - else if (!string.IsNullOrWhiteSpace(model.UserName)) - { - externalId = model.UserName; - } - else - { - externalId = CoreHelpers.RandomString(15); - } - var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId); if (orgUserByExternalId != null) { @@ -87,12 +58,11 @@ public class PostUserCommand : IPostUserCommand } var organization = await _organizationRepository.GetByIdAsync(organizationId); - var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + invite.AccessSecretsManager = hasStandaloneSecretsManager; - var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email, - OrganizationUserType.User, false, externalId, new List(), new List(), hasStandaloneSecretsManager); - + var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + invite, externalId); var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); return orgUser; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 274af2d9d..564b631b1 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -631,7 +631,7 @@ public class ProviderServiceTests .Received().LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created); await sutProvider.GetDependency() - .Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is>( + .Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is>( t => t.Count() == 1 && t.First().Item1.Emails.Count() == 1 && t.First().Item1.Emails.First() == clientOwnerEmail && @@ -709,6 +709,7 @@ public class ProviderServiceTests .InviteUsersAsync( organization.Id, user.Id, + systemUser: null, Arg.Is>( t => t.Count() == 1 && @@ -740,7 +741,7 @@ public class ProviderServiceTests .Received().LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created); await sutProvider.GetDependency() - .Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is>( + .Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is>( t => t.Count() == 1 && t.First().Item1.Emails.Count() == 1 && t.First().Item1.Emails.First() == clientOwnerEmail && diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index 4c67f0173..cf1c33702 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data; +using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -39,15 +39,27 @@ public class PostUserCommandTests sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); sutProvider.GetDependency() - .InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), - OrganizationUserType.User, false, externalId, Arg.Any>(), - Arg.Any>(), true) + .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + Arg.Is(i => + i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) && + i.Type == OrganizationUserType.User && + !i.AccessAll && + !i.Collections.Any() && + !i.Groups.Any() && + i.AccessSecretsManager), externalId) .Returns(newUser); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); - await sutProvider.GetDependency().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), - OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>(), Arg.Any>(), true); + await sutProvider.GetDependency().Received(1).InviteUserAsync(organizationId, + invitingUserId: null, EventSystemUser.SCIM, + Arg.Is(i => + i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) && + i.Type == OrganizationUserType.User && + !i.AccessAll && + !i.Collections.Any() && + !i.Groups.Any() && + i.AccessSecretsManager), externalId); await sutProvider.GetDependency().Received(1).GetDetailsByIdAsync(newUser.Id); } diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index c4aea8d3a..558434d0c 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -225,7 +225,7 @@ public class OrganizationUsersController : Controller } var userId = _userService.GetProperUserId(User); - await _organizationService.InviteUsersAsync(orgId, userId.Value, + await _organizationService.InviteUsersAsync(orgId, userId.Value, systemUser: null, new (OrganizationUserInvite, string)[] { (new OrganizationUserInvite(model.ToData()), null) }); } diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 60aab4f2f..3ec5b5ecd 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -127,10 +127,11 @@ public class MembersController : Controller public async Task Post([FromBody] MemberCreateRequestModel model) { var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value); - var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList(); + var invite = model.ToOrganizationUserInvite(flexibleCollectionsIsEnabled); + var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null, - model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations, model.Groups); - var response = new MemberResponseModel(user, associations, flexibleCollectionsIsEnabled); + systemUser: null, invite, model.ExternalId); + var response = new MemberResponseModel(user, invite.Collections, flexibleCollectionsIsEnabled); return new JsonResult(response); } diff --git a/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs b/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs index 983a35f84..4dbce6a35 100644 --- a/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs @@ -1,4 +1,6 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -21,6 +23,11 @@ public abstract class MemberBaseModel AccessAll = user.AccessAll; ExternalId = user.ExternalId; ResetPasswordEnrolled = user.ResetPasswordKey != null; + + if (Type == OrganizationUserType.Custom) + { + Permissions = new PermissionsModel(user.GetPermissions()); + } } public MemberBaseModel(OrganizationUserUserDetails user, bool flexibleCollectionsEnabled) @@ -34,6 +41,11 @@ public abstract class MemberBaseModel AccessAll = user.AccessAll; ExternalId = user.ExternalId; ResetPasswordEnrolled = user.ResetPasswordKey != null; + + if (Type == OrganizationUserType.Custom) + { + Permissions = new PermissionsModel(user.GetPermissions()); + } } /// @@ -59,6 +71,11 @@ public abstract class MemberBaseModel /// [Required] public bool ResetPasswordEnrolled { get; set; } + /// + /// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will + /// default to false. + /// + public PermissionsModel? Permissions { get; set; } // TODO: AC-2188 - Remove this method when the custom users with no other permissions than 'Edit/Delete Assigned Collections' are migrated private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions) diff --git a/src/Api/AdminConsole/Public/Models/PermissionsModel.cs b/src/Api/AdminConsole/Public/Models/PermissionsModel.cs new file mode 100644 index 000000000..3ab79f819 --- /dev/null +++ b/src/Api/AdminConsole/Public/Models/PermissionsModel.cs @@ -0,0 +1,67 @@ +#nullable enable + +using System.Text.Json.Serialization; +using Bit.Core.Models.Data; + +namespace Bit.Api.AdminConsole.Public.Models; + +/// +/// Represents a member's custom permissions if the member has a Custom role. +/// +public class PermissionsModel +{ + [JsonConstructor] + public PermissionsModel() { } + public PermissionsModel(Permissions? data) + { + if (data is null) + { + return; + } + + AccessEventLogs = data.AccessEventLogs; + AccessImportExport = data.AccessImportExport; + AccessReports = data.AccessReports; + CreateNewCollections = data.CreateNewCollections; + EditAnyCollection = data.EditAnyCollection; + DeleteAnyCollection = data.DeleteAnyCollection; + ManageGroups = data.ManageGroups; + ManagePolicies = data.ManagePolicies; + ManageSso = data.ManageSso; + ManageUsers = data.ManageUsers; + ManageResetPassword = data.ManageResetPassword; + ManageScim = data.ManageScim; + } + + public bool AccessEventLogs { get; set; } + public bool AccessImportExport { get; set; } + public bool AccessReports { get; set; } + public bool CreateNewCollections { get; set; } + public bool EditAnyCollection { get; set; } + public bool DeleteAnyCollection { get; set; } + public bool ManageGroups { get; set; } + public bool ManagePolicies { get; set; } + public bool ManageSso { get; set; } + public bool ManageUsers { get; set; } + public bool ManageResetPassword { get; set; } + public bool ManageScim { get; set; } + + public Permissions ToData() + { + return new Permissions + { + AccessEventLogs = AccessEventLogs, + AccessImportExport = AccessImportExport, + AccessReports = AccessReports, + CreateNewCollections = CreateNewCollections, + EditAnyCollection = EditAnyCollection, + DeleteAnyCollection = DeleteAnyCollection, + ManageGroups = ManageGroups, + ManagePolicies = ManagePolicies, + ManageSso = ManageSso, + ManageUsers = ManageUsers, + ManageResetPassword = ManageResetPassword, + ManageScim = ManageScim + }; + } +} diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index cdbc99d0d..cc783a6e5 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Public.Models.Request; @@ -19,4 +21,24 @@ public class MemberCreateRequestModel : MemberUpdateRequestModel { throw new NotImplementedException(); } + + public OrganizationUserInvite ToOrganizationUserInvite(bool flexibleCollectionsIsEnabled) + { + var invite = new OrganizationUserInvite + { + Emails = new[] { Email }, + Type = Type.Value, + AccessAll = AccessAll.Value, + Collections = Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList(), + Groups = Groups + }; + + // Permissions property is optional for backwards compatibility with existing usage + if (Type is OrganizationUserType.Custom && Permissions is not null) + { + invite.Permissions = Permissions.ToData(); + } + + return invite; + } } diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs index 0d584af2f..ba65d356c 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs @@ -1,8 +1,10 @@ -using Bit.Core.Entities; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Enums; namespace Bit.Api.AdminConsole.Public.Models.Request; -public class MemberUpdateRequestModel : MemberBaseModel +public class MemberUpdateRequestModel : MemberBaseModel, IValidatableObject { /// /// The associated collections that this member can access. @@ -19,6 +21,21 @@ public class MemberUpdateRequestModel : MemberBaseModel existingUser.Type = Type.Value; existingUser.AccessAll = AccessAll.Value; existingUser.ExternalId = ExternalId; + + // Permissions property is optional for backwards compatibility with existing usage + if (existingUser.Type is OrganizationUserType.Custom && Permissions is not null) + { + existingUser.SetPermissions(Permissions.ToData()); + } + return existingUser; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Type is not OrganizationUserType.Custom && Permissions is not null) + { + yield return new ValidationResult("Only users with the Custom role may use custom permissions."); + } + } } diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index de57e4fc4..6b329be6a 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Bit.Api.Models.Public.Response; using Bit.Core.Entities; using Bit.Core.Enums; @@ -12,6 +13,9 @@ namespace Bit.Api.AdminConsole.Public.Models.Response; /// public class MemberResponseModel : MemberBaseModel, IResponseModel { + [JsonConstructor] + public MemberResponseModel() { } + public MemberResponseModel(OrganizationUser user, IEnumerable collections, bool flexibleCollectionsEnabled) : base(user, flexibleCollectionsEnabled) diff --git a/src/Core/AdminConsole/Entities/OrganizationUser.cs b/src/Core/AdminConsole/Entities/OrganizationUser.cs index c5bd39658..73df7b1e8 100644 --- a/src/Core/AdminConsole/Entities/OrganizationUser.cs +++ b/src/Core/AdminConsole/Entities/OrganizationUser.cs @@ -35,4 +35,9 @@ public class OrganizationUser : ITableObject, IExternal return string.IsNullOrWhiteSpace(Permissions) ? null : CoreHelpers.LoadClassFromJsonData(Permissions); } + + public void SetPermissions(Permissions permissions) + { + Permissions = CoreHelpers.ClassToJsonData(permissions); + } } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 0d2472b95..53f912287 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -43,14 +43,10 @@ public interface IOrganizationService Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); - Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, + Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, + OrganizationUserInvite invite, string externalId); + Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); - Task> InviteUsersAsync(Guid organizationId, EventSystemUser systemUser, - IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); - Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, - OrganizationUserType type, bool accessAll, string externalId, ICollection collections, IEnumerable groups); - Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups, bool accessSecretsManager); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index e71b9c1be..55bb223ad 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -957,12 +957,53 @@ public class OrganizationService : IOrganizationService await UpdateAsync(organization); } - public async Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, + public async Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, + OrganizationUserInvite invite, string externalId) + { + // Ideally OrganizationUserInvite should represent a single user so that this doesn't have to be a runtime check + if (invite.Emails.Count() > 1) + { + throw new BadRequestException("This method can only be used to invite a single user."); + } + + // Validate Collection associations if org is using latest collection enhancements + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (organizationAbility?.FlexibleCollections ?? false) + { + var invalidAssociations = invite.Collections?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords)); + if (invalidAssociations?.Any() ?? false) + { + throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); + } + } + + var results = await InviteUsersAsync(organizationId, invitingUserId, systemUser, + new (OrganizationUserInvite, string)[] { (invite, externalId) }); + + var result = results.FirstOrDefault(); + if (result == null) + { + throw new BadRequestException("This user has already been invited."); + } + return result; + } + + /// + /// Invite users to an organization. + /// + /// The organization Id + /// The current authenticated user who is sending the invite. Only used when inviting via a client app; null if using SCIM or Public API. + /// The system user which is sending the invite. Only used when inviting via SCIM; null if using a client app or Public API + /// Details about the users being invited + /// + public async Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) { var inviteTypes = new HashSet(invites.Where(i => i.invite.Type.HasValue) .Select(i => i.invite.Type.Value)); + // If authenticating via a client app, verify the inviting user has permissions + // cf. SCIM and Public API have superuser permissions here if (invitingUserId.HasValue && inviteTypes.Count > 0) { foreach (var (invite, _) in invites) @@ -972,25 +1013,24 @@ public class OrganizationService : IOrganizationService } } - var (organizationUsers, events) = await SaveUsersSendInvitesAsync(organizationId, invites, systemUser: null); + var (organizationUsers, events) = await SaveUsersSendInvitesAsync(organizationId, invites); - await _eventService.LogOrganizationUserEventsAsync(events); - - return organizationUsers; - } - - public async Task> InviteUsersAsync(Guid organizationId, EventSystemUser systemUser, - IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) - { - var (organizationUsers, events) = await SaveUsersSendInvitesAsync(organizationId, invites, systemUser); - - await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.Item1, e.Item2, systemUser, e.Item3))); + if (systemUser.HasValue) + { + // Log SCIM event + await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.Item1, e.Item2, systemUser.Value, e.Item3))); + } + else + { + // Log client app or Public Api event + await _eventService.LogOrganizationUserEventsAsync(events); + } return organizationUsers; } private async Task<(List organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)> SaveUsersSendInvitesAsync(Guid organizationId, - IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, EventSystemUser? systemUser) + IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) { var organization = await GetOrgById(organizationId); var initialSeatCount = organization.Seats; @@ -1087,9 +1127,9 @@ public class OrganizationService : IOrganizationService RevisionDate = DateTime.UtcNow, }; - if (invite.Permissions != null) + if (invite.Type == OrganizationUserType.Custom) { - orgUser.Permissions = JsonSerializer.Serialize(invite.Permissions, JsonHelpers.CamelCase); + orgUser.SetPermissions(invite.Permissions ?? new Permissions()); } if (!orgUser.AccessAll && invite.Collections.Any()) @@ -1667,55 +1707,6 @@ public class OrganizationService : IOrganizationService EventType.OrganizationUser_ResetPassword_Enroll : EventType.OrganizationUser_ResetPassword_Withdraw); } - public async Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, - OrganizationUserType type, bool accessAll, string externalId, ICollection collections, - IEnumerable groups) - { - // Validate Collection associations if org is using latest collection enhancements - var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); - if (organizationAbility?.FlexibleCollections ?? false) - { - var invalidAssociations = collections?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords)); - if (invalidAssociations?.Any() ?? false) - { - throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); - } - } - - return await SaveUserSendInviteAsync(organizationId, invitingUserId, systemUser: null, email, type, accessAll, externalId, collections, groups); - } - - public async Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, - IEnumerable groups, bool accessSecretsManager) - { - // Collection associations validation not required as they are always an empty list - created via system user (scim) - return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups, accessSecretsManager); - } - - private async Task SaveUserSendInviteAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups, bool accessSecretsManager = false) - { - var invite = new OrganizationUserInvite() - { - Emails = new List { email }, - Type = type, - AccessAll = accessAll, - Collections = collections, - Groups = groups, - AccessSecretsManager = accessSecretsManager - }; - var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value, - new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId, - new (OrganizationUserInvite, string)[] { (invite, externalId) }); - var result = results.FirstOrDefault(); - if (result == null) - { - throw new BadRequestException("This user has already been invited."); - } - return result; - } - public async Task ImportAsync(Guid organizationId, Guid? importingUserId, IEnumerable groups, @@ -1831,7 +1822,7 @@ public class OrganizationService : IOrganizationService } } - var invitedUsers = await InviteUsersAsync(organizationId, importingUserId, userInvites); + var invitedUsers = await InviteUsersAsync(organizationId, importingUserId, systemUser: null, userInvites); foreach (var invitedUser in invitedUsers) { existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id); diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs new file mode 100644 index 000000000..2a63eee87 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -0,0 +1,254 @@ +using System.Net; +using Bit.Api.AdminConsole.Public.Models; +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Api.AdminConsole.Public.Models.Response; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Public.Controllers; + +public class MembersControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + private Organization _organization; + private string _ownerEmail; + + public MembersControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + // Create the owner account + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + // Create the organization + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Authorize with the organization api key + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task List_Member_Success() + { + var (userEmail1, orgUser1) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Custom, new Permissions { AccessImportExport = true, ManagePolicies = true, AccessReports = true }); + var (userEmail2, orgUser2) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Owner); + var (userEmail3, orgUser3) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + var (userEmail4, orgUser4) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Admin); + + var response = await _client.GetAsync($"/public/members"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(result?.Data); + Assert.Equal(5, result.Data.Count()); + + // The owner + Assert.NotNull(result.Data.SingleOrDefault(m => + m.Email == _ownerEmail && m.Type == OrganizationUserType.Owner)); + + // The custom user + var user1Result = result.Data.SingleOrDefault(m => m.Email == userEmail1); + Assert.Equal(OrganizationUserType.Custom, user1Result.Type); + AssertHelper.AssertPropertyEqual( + new PermissionsModel { AccessImportExport = true, ManagePolicies = true, AccessReports = true }, + user1Result.Permissions); + + // Everyone else + Assert.NotNull(result.Data.SingleOrDefault(m => + m.Email == userEmail2 && m.Type == OrganizationUserType.Owner)); + Assert.NotNull(result.Data.SingleOrDefault(m => + m.Email == userEmail3 && m.Type == OrganizationUserType.User)); + Assert.NotNull(result.Data.SingleOrDefault(m => + m.Email == userEmail4 && m.Type == OrganizationUserType.Admin)); + } + + [Fact] + public async Task Get_CustomMember_Success() + { + var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Custom, new Permissions { AccessReports = true, ManageScim = true }); + + var response = await _client.GetAsync($"/public/members/{orgUser.Id}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(email, result.Email); + + Assert.Equal(OrganizationUserType.Custom, result.Type); + AssertHelper.AssertPropertyEqual(new PermissionsModel { AccessReports = true, ManageScim = true }, + result.Permissions); + } + + [Theory] + [BitAutoData(true, true)] + [BitAutoData(false, true)] + [BitAutoData(true, false)] + public async Task Get_CustomMember_WithDeprecatedPermissions_TreatsAsUser(bool editAssignedCollections, bool deleteAssignedCollections) + { + var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Custom, new Permissions { EditAssignedCollections = editAssignedCollections, DeleteAssignedCollections = deleteAssignedCollections }); + + var response = await _client.GetAsync($"/public/members/{orgUser.Id}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(email, result.Email); + + Assert.Equal(OrganizationUserType.User, result.Type); + Assert.Null(result.Permissions); + } + + [Fact] + public async Task Post_CustomMember_Success() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var request = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.Custom, + ExternalId = "myCustomUser", + AccessAll = false, + Collections = [], + Groups = [] + }; + + var response = await _client.PostAsync("/public/members", JsonContent.Create(request)); + + // Assert against the response + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.Equal(email, result.Email); + Assert.Equal(OrganizationUserType.Custom, result.Type); + Assert.Equal("myCustomUser", result.ExternalId); + Assert.False(result.AccessAll); + Assert.Empty(result.Collections); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByIdAsync(result.Id); + + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.Custom, orgUser.Type); + Assert.Equal("myCustomUser", orgUser.ExternalId); + Assert.False(orgUser.AccessAll); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + } + + [Fact] + public async Task Put_CustomMember_Success() + { + var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + + var request = new MemberUpdateRequestModel + { + Type = OrganizationUserType.Custom, + Permissions = new PermissionsModel + { + DeleteAnyCollection = true, + EditAnyCollection = true, + AccessEventLogs = true + }, + AccessAll = false, + ExternalId = "example", + Collections = [] + }; + + var response = await _client.PutAsync($"/public/members/{orgUser.Id}", JsonContent.Create(request)); + + // Assert against the response + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.Equal(email, result.Email); + Assert.Equal(OrganizationUserType.Custom, result.Type); + Assert.Equal("example", result.ExternalId); + AssertHelper.AssertPropertyEqual( + new PermissionsModel { DeleteAnyCollection = true, EditAnyCollection = true, AccessEventLogs = true }, + result.Permissions); + Assert.False(result.AccessAll); + Assert.Empty(result.Collections); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + var updatedOrgUser = await organizationUserRepository.GetByIdAsync(result.Id); + + Assert.Equal(OrganizationUserType.Custom, updatedOrgUser.Type); + Assert.Equal("example", updatedOrgUser.ExternalId); + Assert.False(updatedOrgUser.AccessAll); + Assert.Equal(OrganizationUserStatusType.Confirmed, updatedOrgUser.Status); + Assert.Equal(_organization.Id, updatedOrgUser.OrganizationId); + } + + /// + /// The Permissions property is optional and should not overwrite existing Permissions if not provided. + /// This is to preserve backwards compatibility with existing usage. + /// + [Fact] + public async Task Put_ExistingCustomMember_NullPermissions_DoesNotOverwritePermissions() + { + var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Custom, new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true }); + + var request = new MemberUpdateRequestModel + { + Type = OrganizationUserType.Custom, + AccessAll = false, + ExternalId = "example", + Collections = [] + }; + + var response = await _client.PutAsync($"/public/members/{orgUser.Id}", JsonContent.Create(request)); + + // Assert against the response + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.Equal(OrganizationUserType.Custom, result.Type); + AssertHelper.AssertPropertyEqual( + new PermissionsModel { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true }, + result.Permissions); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + var updatedOrgUser = await organizationUserRepository.GetByIdAsync(result.Id); + + Assert.Equal(OrganizationUserType.Custom, updatedOrgUser.Type); + AssertHelper.AssertPropertyEqual( + new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true }, + orgUser.GetPermissions()); + } +} diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index f669e89eb..7d3785839 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -73,4 +73,13 @@ public class ApiApplicationFactory : WebApplicationFactoryBase { return await _identityApplicationFactory.TokenFromAccessTokenAsync(clientId, clientSecret); } + + /// + /// Helper for logging in with an Organization api key. + /// Currently used for the Public Api + /// + public async Task LoginWithOrganizationApiKeyAsync(string clientId, string clientSecret) + { + return await _identityApplicationFactory.TokenFromOrganizationApiKeyAsync(clientId, clientSecret); + } } diff --git a/test/Api.IntegrationTest/Helpers/LoginHelper.cs b/test/Api.IntegrationTest/Helpers/LoginHelper.cs new file mode 100644 index 000000000..f036970a0 --- /dev/null +++ b/test/Api.IntegrationTest/Helpers/LoginHelper.cs @@ -0,0 +1,37 @@ +using System.Net.Http.Headers; +using Bit.Api.IntegrationTest.Factories; +using Bit.Core.Repositories; +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.Api.IntegrationTest.Helpers; + +public class LoginHelper +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + + public LoginHelper(ApiApplicationFactory factory, HttpClient client) + { + _factory = factory; + _client = client; + } + + public async Task LoginWithOrganizationApiKeyAsync(Guid organizationId) + { + var (clientId, apiKey) = await GetOrganizationApiKey(_factory, organizationId); + var token = await _factory.LoginWithOrganizationApiKeyAsync(clientId, apiKey); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + _client.DefaultRequestHeaders.Add("client_id", clientId); + } + + private async Task<(string clientId, string apiKey)> GetOrganizationApiKey( + WebApplicationFactoryBase factory, + Guid organizationId) + where T : class + { + var organizationApiKeyRepository = factory.GetService(); + var apiKeys = await organizationApiKeyRepository.GetManyByOrganizationIdTypeAsync(organizationId); + var clientId = $"organization.{organizationId}"; + return (clientId, apiKeys.SingleOrDefault().ApiKey); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 2144c6301..1287fb4aa 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -1,7 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Api.IntegrationTest.Factories; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; +using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.IntegrationTestCommon.Factories; @@ -15,7 +17,9 @@ public static class OrganizationTestHelpers string ownerEmail = "integration-test@bitwarden.com", string name = "Integration Test Org", string billingEmail = "integration-test@bitwarden.com", - string ownerKey = "test-key") where T : class + string ownerKey = "test-key", + int passwordManagerSeats = 0, + PaymentMethodType paymentMethod = PaymentMethodType.None) where T : class { var userRepository = factory.GetService(); var organizationService = factory.GetService(); @@ -29,17 +33,23 @@ public static class OrganizationTestHelpers Plan = plan, OwnerKey = ownerKey, Owner = owner, + AdditionalSeats = passwordManagerSeats, + PaymentMethodType = paymentMethod }); return new Tuple(signUpResult.organization, signUpResult.organizationUser); } + /// + /// Creates an OrganizationUser. The user account must already be created. + /// public static async Task CreateUserAsync( WebApplicationFactoryBase factory, Guid organizationId, string userEmail, OrganizationUserType type, - bool accessSecretsManager = false + bool accessSecretsManager = false, + Permissions? permissions = null ) where T : class { var userRepository = factory.GetService(); @@ -59,8 +69,36 @@ public static class OrganizationTestHelpers AccessSecretsManager = accessSecretsManager, }; + if (permissions != null) + { + orgUser.SetPermissions(permissions); + } + await organizationUserRepository.CreateAsync(orgUser); return orgUser; } + + /// + /// Creates a new User account with a unique email address and a corresponding OrganizationUser for + /// the specified organization. + /// + public static async Task<(string, OrganizationUser)> CreateNewUserWithAccountAsync( + ApiApplicationFactory factory, + Guid organizationId, + OrganizationUserType userType, + Permissions? permissions = null + ) + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + + // Create user + await factory.LoginWithNewAccount(email); + + // Create organizationUser + var organizationUser = await OrganizationTestHelpers.CreateUserAsync(factory, organizationId, email, userType, + permissions: permissions); + + return (email, organizationUser); + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index def8c6c21..c057c5322 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -127,7 +127,7 @@ public class OrganizationUsersControllerTests await sutProvider.Sut.Invite(organizationAbility.Id, model); await sutProvider.GetDependency().Received(1).InviteUsersAsync(organizationAbility.Id, - userId, Arg.Is>(invites => + userId, systemUser: null, Arg.Is>(invites => invites.Count() == 1 && invites.First().Item1.Emails.SequenceEqual(model.Emails) && invites.First().Item1.Type == model.Type && diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 48a66dff0..d71732e5f 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -464,7 +464,7 @@ public class OrganizationServiceTests [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_NoEmails_Throws(Organization organization, OrganizationUser invitor, + public async Task InviteUsers_NoEmails_Throws(Organization organization, OrganizationUser invitor, OrganizationUserInvite invite, SutProvider sutProvider) { invite.Emails = null; @@ -472,12 +472,12 @@ public class OrganizationServiceTests sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); } [Theory] [OrganizationInviteCustomize, OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_DuplicateEmails_PassesWithoutDuplicates(Organization organization, OrganizationUser invitor, + public async Task InviteUsers_DuplicateEmails_PassesWithoutDuplicates(Organization organization, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, OrganizationUserInvite invite, SutProvider sutProvider) { @@ -508,7 +508,7 @@ public class OrganizationServiceTests ); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); await sutProvider.GetDependency().Received(1) .SendOrganizationInviteEmailsAsync(Arg.Is(info => @@ -520,7 +520,7 @@ public class OrganizationServiceTests [Theory] [OrganizationInviteCustomize, OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor, + public async Task InviteUsers_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, OrganizationUserInvite invite, SutProvider sutProvider) { @@ -557,19 +557,18 @@ public class OrganizationServiceTests - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); await sutProvider.GetDependency().Received(1) .SendOrganizationInviteEmailsAsync(Arg.Is(info => info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && info.IsFreeOrg == (organization.PlanType == PlanType.Free) && info.OrganizationName == organization.Name)); - } [Theory] [OrganizationInviteCustomize, OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_SsoOrgWithNeverEnabledRequireSsoPolicy_Passes(Organization organization, SsoConfig ssoConfig, OrganizationUser invitor, + public async Task InviteUsers_SsoOrgWithNeverEnabledRequireSsoPolicy_Passes(Organization organization, SsoConfig ssoConfig, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, OrganizationUserInvite invite, SutProvider sutProvider) { @@ -608,8 +607,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) } ); - - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); await sutProvider.GetDependency().Received(1) .SendOrganizationInviteEmailsAsync(Arg.Is(info => @@ -623,14 +621,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.Admin, InvitorUserType = OrganizationUserType.Owner ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_NoOwner_Throws(Organization organization, OrganizationUser invitor, + public async Task InviteUsers_NoOwner_Throws(Organization organization, OrganizationUser invitor, OrganizationUserInvite invite, SutProvider sutProvider) { sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("Organization must have at least one confirmed owner.", exception.Message); } @@ -639,7 +637,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.Owner, InvitorUserType = OrganizationUserType.Admin ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_NonOwnerConfiguringOwner_Throws(Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_NonOwnerConfiguringOwner_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { var organizationRepository = sutProvider.GetDependency(); @@ -649,7 +647,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.OrganizationAdmin(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("only an owner", exception.Message.ToLowerInvariant()); } @@ -658,7 +656,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.Custom, InvitorUserType = OrganizationUserType.User ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_NonAdminConfiguringAdmin_Throws(Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_NonAdminConfiguringAdmin_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { organization.UseCustomPermissions = true; @@ -670,7 +668,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.OrganizationUser(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("your account does not have permission to manage users", exception.Message.ToLowerInvariant()); } @@ -679,7 +677,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.Custom, InvitorUserType = OrganizationUserType.Admin ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_WithCustomType_WhenUseCustomPermissionsIsFalse_Throws(Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_WithCustomType_WhenUseCustomPermissionsIsFalse_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { organization.UseCustomPermissions = false; @@ -697,7 +695,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.ManageUsers(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("to enable custom permissions", exception.Message.ToLowerInvariant()); } @@ -706,7 +704,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.Custom, InvitorUserType = OrganizationUserType.Admin ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_WithCustomType_WhenUseCustomPermissionsIsTrue_Passes(Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_WithCustomType_WhenUseCustomPermissionsIsTrue_Passes(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { organization.Seats = 10; @@ -727,7 +725,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); } [Theory] @@ -736,7 +734,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [BitAutoData(OrganizationUserType.Manager)] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.User)] - public async Task InviteUser_WithNonCustomType_WhenUseCustomPermissionsIsFalse_Passes(OrganizationUserType inviteUserType, Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_WithNonCustomType_WhenUseCustomPermissionsIsFalse_Passes(OrganizationUserType inviteUserType, Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { organization.Seats = 10; @@ -758,7 +756,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); } [Theory] @@ -766,7 +764,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.Manager, InvitorUserType = OrganizationUserType.Custom ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_CustomUserWithoutManageUsersConfiguringUser_Throws(Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_CustomUserWithoutManageUsersConfiguringUser_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { invitor.Permissions = JsonSerializer.Serialize(new Permissions() { ManageUsers = false }, @@ -785,7 +783,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.ManageUsers(organization.Id).Returns(false); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("account does not have permission", exception.Message.ToLowerInvariant()); } @@ -794,7 +792,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.Admin, InvitorUserType = OrganizationUserType.Custom ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_CustomUserConfiguringAdmin_Throws(Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_CustomUserConfiguringAdmin_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { invitor.Permissions = JsonSerializer.Serialize(new Permissions() { ManageUsers = true }, @@ -811,7 +809,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.ManageUsers(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("can not manage admins", exception.Message.ToLowerInvariant()); } @@ -820,7 +818,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_NoPermissionsObject_Passes(Organization organization, OrganizationUserInvite invite, + public async Task InviteUsers_NoPermissionsObject_Passes(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { invite.Permissions = null; @@ -838,7 +836,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); } [Theory] @@ -846,28 +844,132 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Custom ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_Passes(Organization organization, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, + public async Task InviteUser_Passes(Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, SutProvider sutProvider) { + // This method is only used to invite 1 user at a time + invite.Emails = new[] { invite.Emails.First() }; + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); sutProvider.Create(); - invitor.Permissions = JsonSerializer.Serialize(new Permissions() { ManageUsers = true }, - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }); + InviteUser_ArrangeCurrentContextPermissions(organization, sutProvider); var organizationRepository = sutProvider.GetDependency(); var organizationUserRepository = sutProvider.GetDependency(); - var currentContext = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) .Returns(new[] { owner }); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); + + await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); + + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == 1 && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [OrganizationInviteCustomize( + InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Custom + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] + public async Task InviteUser_InvitingMoreThanOneUser_Throws(Organization organization, OrganizationUserInvite invite, string externalId, + OrganizationUser invitor, + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + Assert.Contains("This method can only be used to invite a single user.", exception.Message); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SendOrganizationInviteEmailsAsync(default); + await sutProvider.GetDependency().DidNotReceive() + .LogOrganizationUserEventsAsync(Arg.Any>()); + await sutProvider.GetDependency().DidNotReceive() + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [OrganizationInviteCustomize( + InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Custom + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] + public async Task InviteUser_UserAlreadyInvited_Throws(Organization organization, OrganizationUserInvite invite, string externalId, + OrganizationUser invitor, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + SutProvider sutProvider) + { + // This method is only used to invite 1 user at a time + invite.Emails = new[] { invite.Emails.First() }; + + // The user has already been invited + sutProvider.GetDependency() + .SelectKnownEmailsAsync(organization.Id, Arg.Any>(), false) + .Returns(new List { invite.Emails.First() }); + + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + InviteUser_ArrangeCurrentContextPermissions(organization, sutProvider); + + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) + .Returns(new[] { owner }); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut + .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + Assert.Contains("This user has already been invited", exception.Message); + + // MailService and EventService are still called, but with no OrgUsers + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + !info.OrgUserTokenPairs.Any() && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>(events => !events.Any())); + } + + private void InviteUser_ArrangeCurrentContextPermissions(Organization organization, SutProvider sutProvider) + { + var currentContext = sutProvider.GetDependency(); currentContext.ManageUsers(organization.Id).Returns(true); currentContext.AccessReports(organization.Id).Returns(true); currentContext.ManageGroups(organization.Id).Returns(true); @@ -889,6 +991,30 @@ OrganizationUserInvite invite, SutProvider sutProvider) DeleteAnyCollection = true } }); + } + + [Theory] + [OrganizationInviteCustomize( + InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Custom + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] + public async Task InviteUsers_Passes(Organization organization, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, + OrganizationUser invitor, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + SutProvider sutProvider) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + InviteUser_ArrangeCurrentContextPermissions(organization, sutProvider); + + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) + .Returns(new[] { owner }); // Mock tokenable factory to return a token that expires in 5 days sutProvider.GetDependency() @@ -903,7 +1029,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, invites); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, invites); await sutProvider.GetDependency().Received(1) .SendOrganizationInviteEmailsAsync(Arg.Is(info => @@ -919,7 +1045,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Custom ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] - public async Task InviteUser_WithEventSystemUser_Passes(Organization organization, EventSystemUser eventSystemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, + public async Task InviteUsers_WithEventSystemUser_Passes(Organization organization, EventSystemUser eventSystemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, SutProvider sutProvider) @@ -957,7 +1083,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) } ); - await sutProvider.Sut.InviteUsersAsync(organization.Id, eventSystemUser, invites); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitingUserId: null, eventSystemUser, invites); await sutProvider.GetDependency().Received(1) .SendOrganizationInviteEmailsAsync(Arg.Is(info => @@ -969,7 +1095,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory, BitAutoData, OrganizationCustomize(FlexibleCollections = false), OrganizationInviteCustomize] - public async Task InviteUser_WithSecretsManager_Passes(Organization organization, + public async Task InviteUsers_WithSecretsManager_Passes(Organization organization, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, OrganizationUser savingUser, SutProvider sutProvider) { @@ -992,7 +1118,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); - await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, invites); + await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, systemUser: null, invites); sutProvider.GetDependency().Received(1) .UpdateSubscriptionAsync(Arg.Is(update => @@ -1003,7 +1129,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory, BitAutoData, OrganizationCustomize(FlexibleCollections = false), OrganizationInviteCustomize] - public async Task InviteUser_WithSecretsManager_WhenErrorIsThrown_RevertsAutoscaling(Organization organization, + public async Task InviteUsers_WithSecretsManager_WhenErrorIsThrown_RevertsAutoscaling(Organization organization, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, OrganizationUser savingUser, SutProvider sutProvider) { @@ -1030,7 +1156,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) // Throw error at the end of the try block sutProvider.GetDependency().RaiseEventAsync(default).ThrowsForAnyArgs(); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, invites)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, systemUser: null, invites)); // OrgUser is reverted // Note: we don't know what their guids are so comparing length is the best we can do @@ -1059,7 +1185,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData] - public async Task InviteUser_WithFlexibleCollections_WhenInvitingManager_Throws(Organization organization, + public async Task InviteUsers_WithFlexibleCollections_WhenInvitingManager_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { invite.Type = OrganizationUserType.Manager; @@ -1074,14 +1200,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) .Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("manager role has been deprecated", exception.Message.ToLowerInvariant()); } [Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData] - public async Task InviteUser_WithFlexibleCollections_WithAccessAll_Throws(Organization organization, + public async Task InviteUsers_WithFlexibleCollections_WithAccessAll_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { invite.Type = OrganizationUserType.User; @@ -1096,7 +1222,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) .Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("accessall property has been deprecated", exception.Message.ToLowerInvariant()); diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 2dc23056d..472913777 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -61,4 +61,23 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase return root.GetProperty("access_token").GetString(); } + + public async Task TokenFromOrganizationApiKeyAsync(string clientId, string clientSecret, + DeviceType deviceType = DeviceType.FirefoxBrowser) + { + var context = await Server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "scope", "api.organization" }, + { "client_id", clientId }, + { "client_secret", clientSecret }, + { "grant_type", "client_credentials" }, + { "deviceType", ((int)deviceType).ToString() } + })); + + using var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + return root.GetProperty("access_token").GetString(); + } } diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 10b506006..785b3bf7f 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -166,6 +166,11 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory // Disable logs services.AddSingleton(); + + // Noop StripePaymentService - this could be changed to integrate with our Stripe test account + var stripePaymentService = services.First(sd => sd.ServiceType == typeof(IPaymentService)); + services.Remove(stripePaymentService); + services.AddSingleton(Substitute.For()); }); foreach (var configureTestService in _configureTestServices)