diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index ea763f7bcf47..baa601ceb1b9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -451,9 +451,11 @@ public async Task Put([BindOrganization] Organization organization, Gui model.AccessSecretsManager, collectionsToSave, groupsToSave, + model.Email, + model.Name, + model.DefaultUserCollectionName, new StandardUser(userId, await _currentContext.OrganizationOwner(organization.Id)), - savingOrganizationUser, - model.DefaultUserCollectionName); + savingOrganizationUser); var result = await _updateOrganizationUserCommandVNext.UpdateUserAsync(request); return Handle(result); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/OrganizationDomainAllowEmailChangeQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/OrganizationDomainAllowEmailChangeQuery.cs index a7bed24e15ae..a9838cf40bd3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/OrganizationDomainAllowEmailChangeQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/OrganizationDomainAllowEmailChangeQuery.cs @@ -11,6 +11,11 @@ public class OrganizationDomainAllowEmailChangeQuery( IOrganizationDomainRepository organizationDomainRepository) : IOrganizationDomainAllowEmailChangeQuery { + public const string EmailNotOnVerifiedDomainError = + "Your account is managed by an organization, and this email address isn't on one of the organization's verified domains."; + public const string EmailClaimedByOrganizationError = + "This email address is claimed by an organization using Bitwarden."; + /// public async Task ValidateAllowedAsync(User user, string newEmail) { @@ -33,8 +38,7 @@ public async Task ValidateAllowedAsync(User user, string newEmail) if (!verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain)) { - throw new BadRequestException( - "Your account is managed by an organization, and this email address isn't on one of the organization's verified domains."); + throw new BadRequestException(EmailNotOnVerifiedDomainError); } return; @@ -45,8 +49,7 @@ public async Task ValidateAllowedAsync(User user, string newEmail) .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(newDomain); if (isDomainBlocked) { - throw new BadRequestException( - "This email address is claimed by an organization using Bitwarden."); + throw new BadRequestException(EmailClaimedByOrganizationError); } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs index ecef96d95657..3fba6c5054cd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Utilities.v2; +using Bit.Core.AdminConsole.Utilities.v2.Validation; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; @@ -18,3 +19,21 @@ public record CustomPermissionsNotEnabled() : BadRequestError("To enable custom public record CannotAssignDefaultCollection() : BadRequestError("Default collections cannot be assigned to a member."); public record CannotAutoscaleSecretsManagerSeatsOnSelfHost() : BadRequestError("Cannot autoscale on a self-hosted instance."); public record CouldNotIncreaseSeatsOfSecretManager(string Message) : BadRequestError(Message); + +public abstract record EmailValidationError(string Message, string Type) : BadRequestError(Message), IValidationError +{ + public string PropertyName => "email"; +} + +public record MemberHasMasterPasswordError() + : EmailValidationError("Cannot change the email of a member who has a master password.", "member_has_master_password"); +public record MemberNotClaimedError() + : EmailValidationError("Cannot change the email of a member who is not claimed by the organization.", "member_not_claimed"); +public record NewEmailDomainNotClaimedError() + : EmailValidationError("The new email address must be on a domain claimed by the organization.", "new_email_domain_not_claimed"); +public record EmailAlreadyInUseError() + : EmailValidationError("Email already in use.", "email_already_in_use"); +public record EmailClaimedByAnotherOrganizationError() + : EmailValidationError("This email address is claimed by an organization using Bitwarden.", "email_claimed_by_another_organization"); +public record EmailChangeFailedError(string Message) + : EmailValidationError(Message, "email_change_failed"); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index 27b807a37549..9ea137da441c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -1,12 +1,15 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Auth.UserFeatures.UserEmail; using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -26,11 +29,16 @@ public class UpdateOrganizationUserCommand( IGlobalSettings globalSettings, ICollectionRepository collectionRepository, IPolicyRequirementQuery policyRequirementQuery, + IUserRepository userRepository, + IChangeEmailCommand changeEmailCommand, + IPushNotificationService pushNotificationService, TimeProvider timeProvider) : IUpdateOrganizationUserCommand { public async Task UpdateUserAsync(UpdateOrganizationUserRequest request) { + request = await LoadUserToUpdateAsync(request); + var validationResult = await validator.ValidateAsync(request); if (validationResult.IsError) @@ -38,15 +46,7 @@ public async Task UpdateUserAsync(UpdateOrganizationUserRequest r return validationResult.AsError; } - var wasDemotedFromPrivilegedRole = IsDemotingFromPrivilegedRole(request); - var enablingSecretsManager = IsEnablingSecretsManager(request); - - var organizationUser = request.OrganizationUserToUpdate; - organizationUser.Type = request.NewType; - organizationUser.Permissions = CoreHelpers.ClassToJsonData(request.NewPermissions); - organizationUser.AccessSecretsManager = request.NewAccessSecretsManager; - - if (enablingSecretsManager) + if (IsEnablingSecretsManager(request)) { var commandError = await TryEnablingSecretsManagerAsync(request); if (commandError is not null) @@ -55,6 +55,23 @@ public async Task UpdateUserAsync(UpdateOrganizationUserRequest r } } + var isEmailChanging = IsEmailChanging(request); + if (isEmailChanging || IsNameChanging(request)) + { + var commandError = await TryApplyAccountChangesAsync(request, isEmailChanging); + if (commandError is not null) + { + return commandError; + } + } + + var wasDemotedFromPrivilegedRole = IsDemotingFromPrivilegedRole(request); + + var organizationUser = request.OrganizationUserToUpdate; + organizationUser.Type = request.NewType; + organizationUser.Permissions = CoreHelpers.ClassToJsonData(request.NewPermissions); + organizationUser.AccessSecretsManager = request.NewAccessSecretsManager; + await organizationUserRepository.ReplaceAsync(organizationUser, request.NewCollections?.ToList() ?? []); if (request.NewGroups != null) @@ -76,6 +93,64 @@ await collectionRepository.CreateDefaultCollectionsAsync( return new None(); } + private async Task TryApplyAccountChangesAsync(UpdateOrganizationUserRequest request, + bool isEmailChanging) + { + if (request.UserToUpdate is null) + { + return null; + } + + var userToUpdate = request.UserToUpdate; + userToUpdate.Name = string.IsNullOrWhiteSpace(request.NewName) ? null : request.NewName; + + try + { + if (isEmailChanging) + { + await changeEmailCommand.ChangeEmailAsync(request.UserToUpdate, request.NewEmail!); + } + else + { + userToUpdate.RevisionDate = userToUpdate.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime; + await userRepository.ReplaceAsync(userToUpdate); + } + + // Notify the member's devices that their account state changed so clients re-sync. + await pushNotificationService.PushSyncSettingsAsync(userToUpdate.Id); + return null; + } + catch (BadRequestException ex) + { + return MapEmailChangeError(ex); + } + } + + // Map known errors and passthrough unknown errors. + private static CommandError MapEmailChangeError(BadRequestException ex) => ex.Message switch + { + ChangeEmailCommand.EmailAlreadyInUseError => new EmailAlreadyInUseError(), + OrganizationDomainAllowEmailChangeQuery.EmailClaimedByOrganizationError => new EmailClaimedByAnotherOrganizationError(), + OrganizationDomainAllowEmailChangeQuery.EmailNotOnVerifiedDomainError => new NewEmailDomainNotClaimedError(), + _ => new EmailChangeFailedError(ex.Message) + }; + + private static bool IsEmailChanging(UpdateOrganizationUserRequest request) => + !string.IsNullOrWhiteSpace(request.NewEmail) + && request.UserToUpdate is not null + && !string.Equals(request.UserToUpdate.Email, request.NewEmail, StringComparison.InvariantCultureIgnoreCase); + + private static bool IsNameChanging(UpdateOrganizationUserRequest request) + { + if (request.NewName is null || request.UserToUpdate is null) + { + return false; + } + + var normalizedName = string.IsNullOrWhiteSpace(request.NewName) ? null : request.NewName; + return !string.Equals(request.UserToUpdate.Name, normalizedName, StringComparison.Ordinal); + } + private static bool IsEnablingSecretsManager(UpdateOrganizationUserRequest request) => !request.OrganizationUserToUpdate.AccessSecretsManager && request.NewAccessSecretsManager; @@ -86,8 +161,7 @@ request.OrganizationUserToUpdate.Type is OrganizationUserType.Admin or Organizat private async Task TryEnablingSecretsManagerAsync(UpdateOrganizationUserRequest request) { var organization = request.Organization; - var additionalSmSeatsRequired = - await countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, 1); + var additionalSmSeatsRequired = await countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, 1); if (additionalSmSeatsRequired > 0) { // Self-hosted instances can't autoscale their Stripe subscription. @@ -112,6 +186,18 @@ request.OrganizationUserToUpdate.Type is OrganizationUserType.Admin or Organizat return null; } + private async Task LoadUserToUpdateAsync(UpdateOrganizationUserRequest request) + { + var wantsAccountChange = !string.IsNullOrWhiteSpace(request.NewEmail) || request.NewName is not null; + if (!wantsAccountChange || !request.OrganizationUserToUpdate.UserId.HasValue) + { + return request; + } + + var userToUpdate = await userRepository.GetByIdAsync(request.OrganizationUserToUpdate.UserId.Value); + return request with { UserToUpdate = userToUpdate }; + } + private async Task ShouldCreateDefaultCollectionAsync(UpdateOrganizationUserRequest request, bool wasDemotedFromPrivilegedRole) => wasDemotedFromPrivilegedRole diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs index 1f1eb8d9b595..ae6e1711ea7a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserRequest.cs @@ -16,8 +16,11 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUse /// The requested custom permissions (used when is Custom). /// The updated collection access; null removes all collection access. /// The updated group access; null leaves groups unchanged. -/// The actor's own membership; null when the actor is not an organization member (e.g. a provider). +/// The requested new email address; null or blank leaves the member's email unchanged. +/// The requested new account name; null leaves the member's name unchanged, blank clears it. /// Default collection name used when applicable +/// The actor's own membership; null when the actor is not an organization member (e.g. a provider). +/// The member's loaded account; populated by the command before validation, null when no email change is requested or the member has no account. public record UpdateOrganizationUserRequest( OrganizationUser OrganizationUserToUpdate, Organization Organization, @@ -29,6 +32,9 @@ public record UpdateOrganizationUserRequest( bool NewAccessSecretsManager, List? NewCollections, IEnumerable? NewGroups, + string? NewEmail, + string? NewName, + string? DefaultUserCollectionName, IActingUser PerformedBy, OrganizationUser? PerformedByOrganizationUser, - string? DefaultUserCollectionName); + User? UserToUpdate = null); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs index 287ae522df4d..4db9f6a00db1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidator.cs @@ -10,6 +10,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Utilities; using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; @@ -18,7 +19,9 @@ public class UpdateOrganizationUserValidator( IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IOrganizationUserValidationService organizationUserValidationService) + IOrganizationUserValidationService organizationUserValidationService, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, + IOrganizationDomainRepository organizationDomainRepository) : IUpdateOrganizationUserValidator { public async Task> ValidateAsync( @@ -98,9 +101,62 @@ public async Task> ValidateAsync return Invalid(request, new ManageMutuallyExclusive()); } + var emailChangeError = await ValidateEmailChangeAsync(request); + if (emailChangeError is not null) + { + return Invalid(request, emailChangeError); + } + return Valid(request); } + /// + /// A member's email may only be changed when they are claimed by the organization, have no master + /// password, and the new email is on a domain the organization has verified. Returns null when no + /// email change is requested or the email is unchanged. + /// + private async Task ValidateEmailChangeAsync(UpdateOrganizationUserRequest request) + { + if (string.IsNullOrWhiteSpace(request.NewEmail)) + { + return null; + } + + var organizationUser = request.OrganizationUserToUpdate; + + if (request.UserToUpdate is null || !organizationUser.UserId.HasValue) + { + return new MemberNotClaimedError(); + } + + if (string.Equals(request.UserToUpdate.Email, request.NewEmail, StringComparison.InvariantCultureIgnoreCase)) + { + return null; + } + + if (request.UserToUpdate.HasMasterPassword()) + { + return new MemberHasMasterPasswordError(); + } + + var claimedStatus = await getOrganizationUsersClaimedStatusQuery + .GetUsersOrganizationClaimedStatusAsync(request.Organization.Id, [organizationUser.Id]); + if (!claimedStatus.TryGetValue(organizationUser.Id, out var isClaimed) || !isClaimed) + { + return new MemberNotClaimedError(); + } + + var newDomain = EmailValidation.GetDomain(request.NewEmail); + var verifiedDomains = await organizationDomainRepository + .GetVerifiedDomainsByOrganizationIdsAsync([request.Organization.Id]); + if (!verifiedDomains.Any(d => string.Equals(d.DomainName, newDomain, StringComparison.InvariantCultureIgnoreCase))) + { + return new NewEmailDomainNotClaimedError(); + } + + return null; + } + private async Task IsValidFreeOrganizationAdminAsync(OrganizationUser organizationUser, OrganizationUserType newType, Organization organization) { if (organization.PlanType != PlanType.Free) diff --git a/src/Core/Auth/UserFeatures/UserEmail/ChangeEmailCommand.cs b/src/Core/Auth/UserFeatures/UserEmail/ChangeEmailCommand.cs index a506ff94466c..bbf519d7e7e4 100644 --- a/src/Core/Auth/UserFeatures/UserEmail/ChangeEmailCommand.cs +++ b/src/Core/Auth/UserFeatures/UserEmail/ChangeEmailCommand.cs @@ -21,6 +21,8 @@ public class ChangeEmailCommand( private readonly TimeProvider _timeProvider = timeProvider; private readonly ILogger _logger = logger; + public const string EmailAlreadyInUseError = "Email already in use."; + /// public async Task ChangeEmailAsync(User user, string newEmail) { @@ -29,7 +31,7 @@ public async Task ChangeEmailAsync(User user, string newEmail) var existingUser = await _userRepository.GetByEmailAsync(newEmail); if (existingUser != null && existingUser.Id != user.Id) { - throw new BadRequestException("Email already in use."); + throw new BadRequestException(EmailAlreadyInUseError); } var previousEmail = user.Email; diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerVNextTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerVNextTests.cs new file mode 100644 index 000000000000..4d5623811c36 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerVNextTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +/// +/// Integration tests for the v2 UpdateOrganizationUser flow, exercised through the +/// PUT organizations/{orgId}/users/{id} endpoint with the +/// flag enabled (which is what routes the controller to the v2 command). +/// +public class OrganizationUserControllerVNextTests : IAsyncLifetime +{ + private readonly ApiApplicationFactory _factory; + private readonly HttpClient _client; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationUserControllerVNextTests() + { + _factory = new ApiApplicationFactory(); + // The controller only routes to the v2 command when this flag is enabled. + _factory.SubstituteService(featureService => + featureService.IsEnabled(FeatureFlagKeys.ChangeMemberEmailNoMp).Returns(true)); + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"org-user-vnext-test-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return _factory.DisposeAsync().AsTask(); + } + + [Fact] + public async Task Put_WhenChangingRoleAndName_ReturnsNoContentAndPersistsBoth() + { + // Arrange + await _loginHelper.LoginAsync(_ownerEmail); + + var (_, organizationUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var request = new OrganizationUserUpdateRequestModel + { + Type = OrganizationUserType.Admin, + Permissions = new Permissions(), + Collections = [], + Groups = [], + Name = "Updated Name" + }; + + // Act + var response = await _client.PutAsJsonAsync( + $"organizations/{_organization.Id}/users/{organizationUser.Id}", request); + + // Assert: the v2 command returns 204 No Content on success (v1 returns 200 OK). + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + // The org-user role change is persisted. + var organizationUserRepository = _factory.GetService(); + var updatedOrgUser = await organizationUserRepository.GetByIdAsync(organizationUser.Id); + Assert.NotNull(updatedOrgUser); + Assert.Equal(OrganizationUserType.Admin, updatedOrgUser.Type); + + // The account name change is persisted. + var userRepository = _factory.GetService(); + var updatedUser = await userRepository.GetByIdAsync(organizationUser.UserId!.Value); + Assert.NotNull(updatedUser); + Assert.Equal("Updated Name", updatedUser.Name); + } + + [Fact] + public async Task Put_WhenChangingEmailForUnclaimedMember_ReturnsNotClaimedProblemDetails() + { + // Arrange + await _loginHelper.LoginAsync(_ownerEmail); + + // A member with no master password (so the master-password guard is not what trips first) who is not + // claimed by the organization (no verified domain was created), so the email change is rejected as + // "member not claimed". + var memberEmail = $"unclaimed-{Guid.NewGuid()}@bitwarden.com"; + var (_, organizationUser) = await OrganizationTestHelpers.CreateUserWithoutMasterPasswordAsync( + _factory, memberEmail, _organization.Id); + + var request = new OrganizationUserUpdateRequestModel + { + Type = OrganizationUserType.User, + Permissions = new Permissions(), + Collections = [], + Groups = [], + Email = $"new-{Guid.NewGuid()}@bitwarden.com" + }; + + // Act + var response = await _client.PutAsJsonAsync( + $"organizations/{_organization.Id}/users/{organizationUser.Id}", request); + + // Assert: a 400 RFC 7807 validation problem keyed on "email". + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var problem = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = problem.RootElement; + Assert.Equal("validation_error", root.GetProperty("type").GetString()); + Assert.Equal("One or more validation errors occurred.", root.GetProperty("title").GetString()); + Assert.Equal(400, root.GetProperty("status").GetInt32()); + + var emailErrors = root.GetProperty("errors").GetProperty("email"); + Assert.Equal(1, emailErrors.GetArrayLength()); + + var error = emailErrors[0]; + Assert.Equal("member_not_claimed", error.GetProperty("type").GetString()); + Assert.Equal( + "Cannot change the email of a member who is not claimed by the organization.", + error.GetProperty("detail").GetString()); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index c784a2222f1e..db91a645cba7 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -853,6 +853,27 @@ await sutProvider.GetDependency() .UpdateUserAsync(default, default, default, default, default); } + [Theory] + [BitAutoData] + public async Task Put_WhenFeatureFlagEnabled_PassesRequestedEmailToV2Request( + Organization organization, OrganizationUserUpdateRequestModel model, Guid userId, OrganizationAbility organizationAbility, + OrganizationUser organizationUser, SutProvider sutProvider) + { + PutSetup(sutProvider, organization, organizationUser, organizationAbility, userId, featureEnabled: true); + model.Email = "new@claimed.example.com"; + + V2_UpdateUserCommand.UpdateOrganizationUserRequest captured = null; + sutProvider.GetDependency() + .UpdateUserAsync(Arg.Do(r => captured = r)) + .Returns(new CommandResult(new None())); + + var result = await sutProvider.Sut.Put(organization, organizationUser.Id, model); + + Assert.IsType(result); + Assert.NotNull(captured); + Assert.Equal("new@claimed.example.com", captured.NewEmail); + } + [Theory] [BitAutoData] public async Task Put_WhenFeatureFlagEnabledAndCommandFails_MapsErrorToStatus( diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index 98e35a21bd5b..aeccd1dee662 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -1,15 +1,20 @@ using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Auth.UserFeatures.UserEmail; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -17,6 +22,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; using Organization = Bit.Core.AdminConsole.Entities.Organization; @@ -41,13 +47,13 @@ public async Task UpdateUserAsync_WhenValidatorReturnsError_ReturnsErrorAndDoesN await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .ReplaceAsync(default, default(IEnumerable)); + .ReplaceAsync(Arg.Any(), Arg.Any>()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateGroupsAsync(default, default, default); + .UpdateGroupsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(default(OrganizationUser), default); + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory] @@ -93,7 +99,7 @@ await sutProvider.GetDependency() .ReplaceAsync(organizationUser, Arg.Any>()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateGroupsAsync(default, default, default); + .UpdateGroupsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory] @@ -137,7 +143,7 @@ await sutProvider.GetDependency() .CountNewSmSeatsRequiredAsync(organization.Id, 1); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateSubscriptionAsync(default); + .UpdateSubscriptionAsync(Arg.Any()); } [Theory] @@ -156,7 +162,7 @@ public async Task UpdateUserAsync_WhenSecretsManagerAccessUnchanged_DoesNotCheck await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CountNewSmSeatsRequiredAsync(default, default); + .CountNewSmSeatsRequiredAsync(Arg.Any(), Arg.Any()); } [Theory] @@ -167,8 +173,14 @@ public async Task UpdateUserAsync_WhenEnablingSecretsManagerRequiresSeatsOnSelfH [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) { organizationUser.AccessSecretsManager = false; - var request = Setup(sutProvider, organization, organizationUser, targetAccessSecretsManager: true); - + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com" }; + var request = Setup(sutProvider, organization, organizationUser, targetAccessSecretsManager: true, + newEmail: "new@claimed.example.com"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); sutProvider.GetDependency().SelfHosted.Returns(true); sutProvider.GetDependency() .CountNewSmSeatsRequiredAsync(organization.Id, 1) @@ -182,13 +194,18 @@ public async Task UpdateUserAsync_WhenEnablingSecretsManagerRequiresSeatsOnSelfH // A self-hosted instance must never attempt a subscription update, and nothing should be persisted. await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpdateSubscriptionAsync(default); + .UpdateSubscriptionAsync(Arg.Any()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .ReplaceAsync(default, default(IEnumerable)); + .ReplaceAsync(Arg.Any(), Arg.Any>()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(default(OrganizationUser), default); + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any()); + + // Autoscale runs before the email change, so a failed autoscale must not have altered the member's email. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ChangeEmailAsync(Arg.Any(), Arg.Any()); } [Theory] @@ -235,7 +252,7 @@ public async Task UpdateUserAsync_WhenDemotingPrivilegedUser_WithUseMyItemsDisab Assert.True(result.IsSuccess); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory] @@ -258,7 +275,7 @@ public async Task UpdateUserAsync_WhenDemotingPrivilegedUser_WithPolicyDisabled_ Assert.True(result.IsSuccess); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory] @@ -280,7 +297,7 @@ public async Task UpdateUserAsync_WhenExistingUserIsNotPrivileged_DoesNotCreateD Assert.True(result.IsSuccess); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory] @@ -300,7 +317,285 @@ public async Task UpdateUserAsync_WhenDemotingPrivilegedUser_WithoutName_DoesNot Assert.True(result.IsSuccess); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenEmailChanging_LoadsUserAndCallsChangeEmail( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com" }; + var request = Setup(sutProvider, organization, organizationUser, newEmail: "new@claimed.example.com"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organizationUser.UserId!.Value); + await sutProvider.GetDependency() + .Received(1) + .ChangeEmailAsync(userToUpdate, "new@claimed.example.com"); + await sutProvider.GetDependency() + .Received(1) + .PushSyncSettingsAsync(userToUpdate.Id); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(organizationUser, Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNoEmailRequested_DoesNotLoadUserOrChangeEmail( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + var request = Setup(sutProvider, organization, organizationUser, newEmail: null); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ChangeEmailAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncSettingsAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenEmailUnchanged_DoesNotCallChangeEmail( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com" }; + var request = Setup(sutProvider, organization, organizationUser, newEmail: "MEMBER@claimed.example.com"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ChangeEmailAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncSettingsAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(ChangeEmailCommand.EmailAlreadyInUseError, typeof(EmailAlreadyInUseError))] + [BitAutoData(OrganizationDomainAllowEmailChangeQuery.EmailClaimedByOrganizationError, typeof(EmailClaimedByAnotherOrganizationError))] + [BitAutoData(OrganizationDomainAllowEmailChangeQuery.EmailNotOnVerifiedDomainError, typeof(NewEmailDomainNotClaimedError))] + [BitAutoData("Something unexpected went wrong.", typeof(EmailChangeFailedError))] + public async Task UpdateUserAsync_WhenChangeEmailThrowsBadRequest_MapsToTypedErrorAndDoesNotPersist( + string thrownMessage, + Type expectedError, + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com" }; + var request = Setup(sutProvider, organization, organizationUser, newEmail: "new@claimed.example.com"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + sutProvider.GetDependency() + .ChangeEmailAsync(userToUpdate, "new@claimed.example.com") + .ThrowsAsync(new BadRequestException(thrownMessage)); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsError); + Assert.IsType(expectedError, result.AsError); + + // The email change fails before any role/collection changes are persisted. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any(), Arg.Any>()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncSettingsAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameChanging_LoadsUserPersistsNameAndPushesSync( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Old Name" }; + var request = Setup(sutProvider, organization, organizationUser, newName: "New Name"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + Assert.Equal("New Name", userToUpdate.Name); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(userToUpdate); + await sutProvider.GetDependency() + .Received(1) + .PushSyncSettingsAsync(userToUpdate.Id); + // A name-only change never touches the email command. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ChangeEmailAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameBlank_ClearsNameToNull( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Old Name" }; + var request = Setup(sutProvider, organization, organizationUser, newName: " "); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + Assert.Null(userToUpdate.Name); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(userToUpdate); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameUnchanged_DoesNotPersistUserOrPushSync( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Same Name" }; + var request = Setup(sutProvider, organization, organizationUser, newName: "Same Name"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncSettingsAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameNull_DoesNotLoadUserOrPersistName( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + var request = Setup(sutProvider, organization, organizationUser, newName: null); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameAndEmailChanging_WritesAccountOnceViaChangeEmailAndPushesOnce( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com", Name = "Old Name" }; + var request = Setup(sutProvider, organization, organizationUser, + newEmail: "new@claimed.example.com", newName: "New Name"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + Assert.Equal("New Name", userToUpdate.Name); + // The email command persists the account (name included); we must not also call ReplaceAsync directly. + await sutProvider.GetDependency() + .Received(1) + .ChangeEmailAsync(userToUpdate, "new@claimed.example.com"); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .PushSyncSettingsAsync(userToUpdate.Id); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameRequestedButMemberHasNoAccount_SkipsNameChange( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = null; + var request = Setup(sutProvider, organization, organizationUser, newName: "New Name"); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncSettingsAsync(Arg.Any()); } private static UpdateOrganizationUserRequest Setup( @@ -312,7 +607,9 @@ private static UpdateOrganizationUserRequest Setup( IEnumerable groups = null, bool valid = true, bool targetAccessSecretsManager = false, - string defaultUserCollectionName = null) + string defaultUserCollectionName = null, + string newEmail = null, + string newName = null) { organization.PlanType = PlanType.EnterpriseAnnually; organizationUser.OrganizationId = organization.Id; @@ -334,9 +631,11 @@ private static UpdateOrganizationUserRequest Setup( targetAccessSecretsManager, collections, groups, + newEmail, + newName, + defaultUserCollectionName, new StandardUser(organizationUser.UserId ?? Guid.NewGuid(), true), - new OrganizationUser { Type = OrganizationUserType.Owner }, - defaultUserCollectionName); + new OrganizationUser { Type = OrganizationUserType.Owner }); } private static void SetupDataOwnershipPolicy(SutProvider sutProvider, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs index 8247fda2d19d..9f77f256e6ee 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserValidatorTests.cs @@ -386,6 +386,131 @@ public async Task ValidateAsync_WhenOwnerGrantsPermissionsBeyondAnyMember_Return Assert.True(result.IsValid); } + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenEmailUnchanged_ReturnsValidAndSkipsEmailChecks( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + orgUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = orgUser.UserId!.Value, Email = "member@claimed.example.com" }; + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, + newEmail: "MEMBER@claimed.example.com", userToUpdate: userToUpdate); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsValid); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetUsersOrganizationClaimedStatusAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenChangingEmailButUserNotLoaded_ReturnsMemberNotClaimed( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // No backing account was loaded (e.g. an invited-but-unconfirmed member), so the email cannot be changed. + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, + newEmail: "new@claimed.example.com", userToUpdate: null); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenChangingEmailForMemberWithMasterPassword_ReturnsMemberHasMasterPassword( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + orgUser.UserId = Guid.NewGuid(); + var userToUpdate = new User + { + Id = orgUser.UserId!.Value, + Email = "member@claimed.example.com", + MasterPassword = "hashed-master-password" + }; + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, + newEmail: "new@claimed.example.com", userToUpdate: userToUpdate); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenChangingEmailForUnclaimedMember_ReturnsMemberNotClaimed( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + orgUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = orgUser.UserId!.Value, Email = "member@claimed.example.com" }; + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, + newEmail: "new@claimed.example.com", userToUpdate: userToUpdate); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Any>()) + .Returns(new Dictionary { [orgUser.Id] = false }); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenNewEmailDomainNotVerified_ReturnsNewEmailDomainNotClaimed( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + orgUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = orgUser.UserId!.Value, Email = "member@claimed.example.com" }; + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, + newEmail: "new@unclaimed.example.com", userToUpdate: userToUpdate); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Any>()) + .Returns(new Dictionary { [orgUser.Id] = true }); + sutProvider.GetDependency() + .GetVerifiedDomainsByOrganizationIdsAsync(Arg.Any>()) + .Returns(new List { new() { DomainName = "claimed.example.com" } }); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenChangingEmailForClaimedNoMasterPasswordMemberOnVerifiedDomain_ReturnsValid( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + orgUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = orgUser.UserId!.Value, Email = "member@claimed.example.com" }; + var request = CreateRequest(sutProvider, orgUser, OrganizationUserType.User, + newEmail: "new@claimed.example.com", userToUpdate: userToUpdate); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Any>()) + .Returns(new Dictionary { [orgUser.Id] = true }); + sutProvider.GetDependency() + .GetVerifiedDomainsByOrganizationIdsAsync(Arg.Any>()) + .Returns(new List { new() { DomainName = "claimed.example.com" } }); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.True(result.IsValid); + } + private static UpdateOrganizationUserRequest CreateRequest( SutProvider sutProvider, OrganizationUser organizationUser, @@ -398,7 +523,9 @@ private static UpdateOrganizationUserRequest CreateRequest( IEnumerable groups = null, HashSet currentAccessIds = null, ICollection postedCollections = null, - Permissions newPermissions = null) + Permissions newPermissions = null, + string newEmail = null, + User userToUpdate = null) { // IOrganizationUserValidationService is auto-mocked; an unstubbed CanManage returns null ("allowed"), // so the escalation check passes by default. Its role rules have their own unit tests. @@ -435,9 +562,12 @@ private static UpdateOrganizationUserRequest CreateRequest( false, collections ?? [], groups, + newEmail, + null, + null, actingUser, actingMembership, - null); + userToUpdate); } // The acting user's own membership. Custom users are given ManageUsers by default, since that is the