diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index afbfa50bb4..91d79542b5 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs index 6c983611ee..474557a9cb 100644 --- a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs index f391c93fe3..8b6c850c6f 100644 --- a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs @@ -1,6 +1,6 @@ using System.Text.Json; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index d78c462005..a380d2f0d9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -41,6 +41,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand; +using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; namespace Bit.Api.AdminConsole.Controllers; @@ -73,10 +75,11 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand; private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand; + private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; - private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; + private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand; private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand; public OrganizationUsersController(IOrganizationRepository organizationRepository, @@ -104,11 +107,12 @@ public class OrganizationUsersController : BaseAdminConsoleController IConfirmOrganizationUserCommand confirmOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IInitPendingOrganizationCommand initPendingOrganizationCommand, - IRevokeOrganizationUserCommand revokeOrganizationUserCommand, + V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand, IAdminRecoverAccountCommand adminRecoverAccountCommand, - IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand) + IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand, + V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -135,6 +139,7 @@ public class OrganizationUsersController : BaseAdminConsoleController _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand; _automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand; + _revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; @@ -642,7 +647,29 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); + if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)) + { + return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); + } + + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) + { + throw new UnauthorizedAccessException(); + } + + var results = await _revokeOrganizationUserCommandVNext.RevokeUsersAsync( + new V2_RevokeOrganizationUserCommand.RevokeOrganizationUsersRequest( + orgId, + model.Ids.ToArray(), + new StandardUser(currentUserId.Value, await _currentContext.OrganizationOwner(orgId)))); + + return new ListResponseModel(results + .Select(result => new OrganizationUserBulkResponseModel(result.Id, + result.Result.Match( + error => error.Message, + _ => string.Empty + )))); } [HttpPatch("revoke")] diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index 4e0accb9e8..b7a4db3acd 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel public class OrganizationUserBulkRequestModel { - [Required] + [Required, MinLength(1)] public IEnumerable Ids { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs similarity index 95% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs index 01ad2f05d2..7b5541c3ce 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; public interface IRevokeOrganizationUserCommand { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs similarity index 99% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs index f24e0ae265..7aa67f0813 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs @@ -7,7 +7,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; public class RevokeOrganizationUserCommand( IEventService eventService, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs new file mode 100644 index 0000000000..a30894c7d5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public record UserAlreadyRevoked() : BadRequestError("Already revoked."); +public record CannotRevokeYourself() : BadRequestError("You cannot revoke yourself."); +public record OnlyOwnersCanRevokeOwners() : BadRequestError("Only owners can revoke other owners."); +public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..e6471ad891 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public interface IRevokeOrganizationUserCommand +{ + Task> RevokeUsersAsync(RevokeOrganizationUsersRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs new file mode 100644 index 0000000000..1a5cfd2c46 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public interface IRevokeOrganizationUserValidator +{ + Task>> ValidateAsync(RevokeOrganizationUsersValidationRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..ca501277a7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs @@ -0,0 +1,114 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public class RevokeOrganizationUserCommand( + IOrganizationUserRepository organizationUserRepository, + IEventService eventService, + IPushNotificationService pushNotificationService, + IRevokeOrganizationUserValidator validator, + TimeProvider timeProvider, + ILogger logger) + : IRevokeOrganizationUserCommand +{ + public async Task> RevokeUsersAsync(RevokeOrganizationUsersRequest request) + { + var validationRequest = await CreateValidationRequestsAsync(request); + + var results = await validator.ValidateAsync(validationRequest); + + var validUsers = results.Where(r => r.IsValid).Select(r => r.Request).ToList(); + + await RevokeValidUsersAsync(validUsers); + + await Task.WhenAll( + LogRevokedOrganizationUsersAsync(validUsers, request.PerformedBy), + SendPushNotificationsAsync(validUsers) + ); + + return results.Select(r => r.Match( + error => new BulkCommandResult(r.Request.Id, error), + _ => new BulkCommandResult(r.Request.Id, new None()) + )); + } + + private async Task CreateValidationRequestsAsync( + RevokeOrganizationUsersRequest request) + { + var organizationUserToRevoke = await organizationUserRepository + .GetManyAsync(request.OrganizationUserIdsToRevoke); + + return new RevokeOrganizationUsersValidationRequest( + request.OrganizationId, + request.OrganizationUserIdsToRevoke, + request.PerformedBy, + organizationUserToRevoke); + } + + private async Task RevokeValidUsersAsync(ICollection validUsers) + { + if (validUsers.Count == 0) + { + return; + } + + await organizationUserRepository.RevokeManyByIdAsync(validUsers.Select(u => u.Id)); + } + + private async Task LogRevokedOrganizationUsersAsync( + ICollection revokedUsers, + IActingUser actingUser) + { + if (revokedUsers.Count == 0) + { + return; + } + + var eventDate = timeProvider.GetUtcNow().UtcDateTime; + + if (actingUser is SystemUser { SystemUserType: not null }) + { + var revokeEventsWithSystem = revokedUsers + .Select(user => (user, EventType.OrganizationUser_Revoked, actingUser.SystemUserType!.Value, + (DateTime?)eventDate)) + .ToList(); + await eventService.LogOrganizationUserEventsAsync(revokeEventsWithSystem); + } + else + { + var revokeEvents = revokedUsers + .Select(user => (user, EventType.OrganizationUser_Revoked, (DateTime?)eventDate)) + .ToList(); + await eventService.LogOrganizationUserEventsAsync(revokeEvents); + } + } + + private async Task SendPushNotificationsAsync(ICollection revokedUsers) + { + var userIdsToNotify = revokedUsers + .Where(user => user.UserId.HasValue) + .Select(user => user.UserId!.Value) + .Distinct() + .ToList(); + + foreach (var userId in userIdsToNotify) + { + try + { + await pushNotificationService.PushSyncOrgKeysAsync(userId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send push notification for user {UserId}.", userId); + } + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs new file mode 100644 index 0000000000..56996ffb53 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public record RevokeOrganizationUsersRequest( + Guid OrganizationId, + ICollection OrganizationUserIdsToRevoke, + IActingUser PerformedBy +); + +public record RevokeOrganizationUsersValidationRequest( + Guid OrganizationId, + ICollection OrganizationUserIdsToRevoke, + IActingUser PerformedBy, + ICollection OrganizationUsersToRevoke +) : RevokeOrganizationUsersRequest(OrganizationId, OrganizationUserIdsToRevoke, PerformedBy); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs new file mode 100644 index 0000000000..d2f47ed713 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs @@ -0,0 +1,39 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Entities; +using Bit.Core.Enums; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public class RevokeOrganizationUsersValidator(IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + : IRevokeOrganizationUserValidator +{ + public async Task>> ValidateAsync( + RevokeOrganizationUsersValidationRequest request) + { + var hasRemainingOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(request.OrganizationId, + request.OrganizationUsersToRevoke.Select(x => x.Id) // users excluded because they are going to be revoked + ); + + return request.OrganizationUsersToRevoke.Select(x => + { + return x switch + { + _ when request.PerformedBy is not SystemUser + && x.UserId is not null + && x.UserId == request.PerformedBy.UserId => + Invalid(x, new CannotRevokeYourself()), + { Status: OrganizationUserStatusType.Revoked } => + Invalid(x, new UserAlreadyRevoked()), + { Type: OrganizationUserType.Owner } when !hasRemainingOwner => + Invalid(x, new MustHaveConfirmedOwner()), + { Type: OrganizationUserType.Owner } when !request.PerformedBy.IsOrganizationOwnerOrProvider => + Invalid(x, new OnlyOwnersCanRevokeOwners()), + + _ => Valid(x) + }; + }).ToList(); + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ccc3555567..bb7f2b3dc1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -142,6 +142,7 @@ public static class FeatureFlagKeys public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; + public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 9cb9159ebb..b502cc6e4e 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -45,6 +45,9 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; +using V2_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + namespace Bit.Core.OrganizationFeatures; public static class OrganizationServiceCollectionExtensions @@ -133,7 +136,6 @@ public static class OrganizationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -143,6 +145,11 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index eaa3675c47..bd670347a9 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -625,7 +625,11 @@ public class OrganizationUserRepository : Repository, IO await connection.ExecuteAsync( "[dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray]", - new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), Status = OrganizationUserStatusType.Revoked }, + new + { + OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), + Status = OrganizationUserStatusType.Revoked + }, commandType: CommandType.StoredProcedure); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs new file mode 100644 index 0000000000..6645f29eae --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs @@ -0,0 +1,347 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Response; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Providers.Interfaces; +using Bit.Core.AdminConsole.Repositories; +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; + +public class OrganizationUserControllerBulkRevokeTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationUserControllerBulkRevokeTests(ApiApplicationFactory apiFactory) + { + _factory = apiFactory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2) + .Returns(true); + }); + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"org-user-bulk-revoke-test-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseMonthly, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task BulkRevoke_Success() + { + var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(ownerEmail); + + var (_, orgUser1) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + var (_, orgUser2) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + + var organizationUserRepository = _factory.GetService(); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUser1.Id, orgUser2.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.NotNull(content); + Assert.Equal(2, content.Data.Count()); + Assert.All(content.Data, r => Assert.Empty(r.Error)); + + var actualUsers = await organizationUserRepository.GetManyAsync([orgUser1.Id, orgUser2.Id]); + Assert.All(actualUsers, u => Assert.Equal(OrganizationUserStatusType.Revoked, u.Status)); + } + + [Fact] + public async Task BulkRevoke_AsAdmin_Success() + { + var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(adminEmail); + + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUser.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.NotNull(content); + Assert.Single(content.Data); + Assert.All(content.Data, r => Assert.Empty(r.Error)); + + var actualUser = await _factory.GetService().GetByIdAsync(orgUser.Id); + Assert.NotNull(actualUser); + Assert.Equal(OrganizationUserStatusType.Revoked, actualUser.Status); + } + + [Fact] + public async Task BulkRevoke_CannotRevokeSelf_ReturnsError() + { + var (userEmail, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(userEmail); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUser.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.NotNull(content); + Assert.Single(content.Data); + Assert.Contains(content.Data, r => r.Id == orgUser.Id && r.Error == "You cannot revoke yourself."); + + var actualUser = await _factory.GetService().GetByIdAsync(orgUser.Id); + Assert.NotNull(actualUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, actualUser.Status); + } + + [Fact] + public async Task BulkRevoke_AlreadyRevoked_ReturnsError() + { + var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(ownerEmail); + + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + + var organizationUserRepository = _factory.GetService(); + + await organizationUserRepository.RevokeAsync(orgUser.Id); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUser.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.NotNull(content); + Assert.Single(content.Data); + Assert.Contains(content.Data, r => r.Id == orgUser.Id && r.Error == "Already revoked."); + + var actualUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(actualUser); + Assert.Equal(OrganizationUserStatusType.Revoked, actualUser.Status); + } + + [Fact] + public async Task BulkRevoke_AdminCannotRevokeOwner_ReturnsError() + { + var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(adminEmail); + + var (_, ownerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.Owner); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [ownerOrgUser.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.NotNull(content); + Assert.Single(content.Data); + Assert.Contains(content.Data, r => r.Id == ownerOrgUser.Id && r.Error == "Only owners can revoke other owners."); + + var actualUser = await _factory.GetService().GetByIdAsync(ownerOrgUser.Id); + Assert.NotNull(actualUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, actualUser.Status); + } + + [Fact] + public async Task BulkRevoke_MixedResults() + { + var (ownerEmail, requestingOwner) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(ownerEmail); + + var (_, validOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + var (_, alreadyRevokedOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + + var organizationUserRepository = _factory.GetService(); + + await organizationUserRepository.RevokeAsync(alreadyRevokedOrgUser.Id); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [validOrgUser.Id, alreadyRevokedOrgUser.Id, requestingOwner.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.NotNull(content); + Assert.Equal(3, content.Data.Count()); + + Assert.Contains(content.Data, r => r.Id == validOrgUser.Id && r.Error == string.Empty); + Assert.Contains(content.Data, r => r.Id == alreadyRevokedOrgUser.Id && r.Error == "Already revoked."); + Assert.Contains(content.Data, r => r.Id == requestingOwner.Id && r.Error == "You cannot revoke yourself."); + + var actualUsers = await organizationUserRepository.GetManyAsync([validOrgUser.Id, alreadyRevokedOrgUser.Id, requestingOwner.Id]); + Assert.Equal(OrganizationUserStatusType.Revoked, actualUsers.First(u => u.Id == validOrgUser.Id).Status); + Assert.Equal(OrganizationUserStatusType.Revoked, actualUsers.First(u => u.Id == alreadyRevokedOrgUser.Id).Status); + Assert.Equal(OrganizationUserStatusType.Confirmed, actualUsers.First(u => u.Id == requestingOwner.Id).Status); + } + + [Theory] + [InlineData(OrganizationUserType.User)] + [InlineData(OrganizationUserType.Custom)] + public async Task BulkRevoke_WithoutManageUsersPermission_ReturnsForbidden(OrganizationUserType organizationUserType) + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, organizationUserType, new Permissions { ManageUsers = false }); + + await _loginHelper.LoginAsync(userEmail); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [Guid.NewGuid()] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + + Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); + } + + [Fact] + public async Task BulkRevoke_WithEmptyIds_ReturnsBadRequest() + { + await _loginHelper.LoginAsync(_ownerEmail); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + + Assert.Equal(HttpStatusCode.BadRequest, httpResponse.StatusCode); + } + + [Fact] + public async Task BulkRevoke_WithInvalidOrganizationId_ReturnsForbidden() + { + var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(ownerEmail); + + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + + var invalidOrgId = Guid.NewGuid(); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUser.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{invalidOrgId}/users/revoke", request); + + Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); + } + + [Fact] + public async Task BulkRevoke_ProviderRevokesOwner_ReturnsOk() + { + var providerEmail = $"provider-user{Guid.NewGuid()}@example.com"; + + // create user for provider + await _factory.LoginWithNewAccount(providerEmail); + + // create provider and provider user + await _factory.GetService() + .CreateBusinessUnitAsync( + new Provider + { + Name = "provider", + Type = ProviderType.BusinessUnit + }, + providerEmail, + PlanType.EnterpriseAnnually2023, + 10); + + await _loginHelper.LoginAsync(providerEmail); + + var providerUserUser = await _factory.GetService().GetByEmailAsync(providerEmail); + + var providerUserCollection = await _factory.GetService() + .GetManyByUserAsync(providerUserUser!.Id); + + var providerUser = providerUserCollection.First(); + + await _factory.GetService().CreateAsync(new ProviderOrganization + { + ProviderId = providerUser.ProviderId, + OrganizationId = _organization.Id, + Key = null, + Settings = null + }); + + var (_, ownerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [ownerOrgUser.Id] + }; + + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommandTests.cs index b16a80d7a2..3c2868d9e3 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommandTests.cs @@ -1,6 +1,6 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..a74135794f --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommandTests.cs @@ -0,0 +1,215 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +[SutProviderCustomize] +public class RevokeOrganizationUserCommandTests +{ + [Theory] + [BitAutoData] + public async Task RevokeUsersAsync_WithValidUsers_RevokesUsersAndLogsEvents( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) + { + // Arrange + orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; + orgUser1.UserId = Guid.NewGuid(); + orgUser2.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); + var request = new RevokeOrganizationUsersRequest( + organizationId, + [orgUser1.Id, orgUser2.Id], + actingUser); + + SetupRepositoryMocks(sutProvider, [orgUser1, orgUser2]); + SetupValidatorMock(sutProvider, [ + ValidationResultHelpers.Valid(orgUser1), + ValidationResultHelpers.Valid(orgUser2) + ]); + + // Act + var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList(); + + // Assert + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.True(r.Result.IsSuccess)); + + await sutProvider.GetDependency() + .Received(1) + .RevokeManyByIdAsync(Arg.Is>(ids => + ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>( + events => events.Count() == 2)); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(orgUser1.UserId!.Value); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(orgUser2.UserId!.Value); + } + + [Theory] + [BitAutoData] + public async Task RevokeUsersAsync_WithSystemUser_LogsEventsWithSystemUserType( + SutProvider sutProvider, + Guid organizationId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Arrange + orgUser.OrganizationId = organizationId; + orgUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(null, false, EventSystemUser.SCIM); + + var request = new RevokeOrganizationUsersRequest( + organizationId, + [orgUser.Id], + actingUser); + + SetupRepositoryMocks(sutProvider, [orgUser]); + SetupValidatorMock(sutProvider, [ValidationResultHelpers.Valid(orgUser)]); + + // Act + await sutProvider.Sut.RevokeUsersAsync(request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>( + events => events.All(e => e.Item3 == EventSystemUser.SCIM))); + } + + [Theory] + [BitAutoData] + public async Task RevokeUsersAsync_WithValidationErrors_ReturnsErrorResults( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) + { + // Arrange + orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; + + var actingUser = CreateActingUser(actingUserId, false, null); + + var request = new RevokeOrganizationUsersRequest( + organizationId, + [orgUser1.Id, orgUser2.Id], + actingUser); + + SetupRepositoryMocks(sutProvider, [orgUser1, orgUser2]); + SetupValidatorMock(sutProvider, [ + ValidationResultHelpers.Invalid(orgUser1, new UserAlreadyRevoked()), + ValidationResultHelpers.Valid(orgUser2) + ]); + + // Act + var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList(); + + // Assert + Assert.Equal(2, results.Count); + var result1 = results.Single(r => r.Id == orgUser1.Id); + var result2 = results.Single(r => r.Id == orgUser2.Id); + + Assert.True(result1.Result.IsError); + Assert.True(result2.Result.IsSuccess); + + // Only the valid user should be revoked + await sutProvider.GetDependency() + .Received(1) + .RevokeManyByIdAsync(Arg.Is>(ids => + ids.Count() == 1 && ids.Contains(orgUser2.Id))); + } + + [Theory] + [BitAutoData] + public async Task RevokeUsersAsync_WhenPushNotificationFails_ContinuesProcessing( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Arrange + orgUser.OrganizationId = organizationId; + orgUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); + + var request = new RevokeOrganizationUsersRequest( + organizationId, + [orgUser.Id], + actingUser); + + SetupRepositoryMocks(sutProvider, [orgUser]); + SetupValidatorMock(sutProvider, [ValidationResultHelpers.Valid(orgUser)]); + + sutProvider.GetDependency() + .PushSyncOrgKeysAsync(orgUser.UserId!.Value) + .Returns(Task.FromException(new Exception("Push notification failed"))); + + // Act + var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList(); + + // Assert + Assert.Single(results); + Assert.True(results[0].Result.IsSuccess); + + // Should log warning but continue + sutProvider.GetDependency>() + .Received() + .Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + private static IActingUser CreateActingUser(Guid? userId, bool isOwnerOrProvider, EventSystemUser? systemUserType) => + (userId, systemUserType) switch + { + ({ } id, _) => new StandardUser(id, isOwnerOrProvider), + (null, { } type) => new SystemUser(type) + }; + + private static void SetupRepositoryMocks( + SutProvider sutProvider, + ICollection organizationUsers) + { + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(organizationUsers); + } + + private static void SetupValidatorMock( + SutProvider sutProvider, + ICollection> validationResults) + { + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(validationResults); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidatorTests.cs new file mode 100644 index 0000000000..fe5802b00b --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidatorTests.cs @@ -0,0 +1,325 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +[SutProviderCustomize] +public class RevokeOrganizationUsersValidatorTests +{ + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithValidUsers_ReturnsSuccess( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) + { + // Arrange + orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; + orgUser1.UserId = Guid.NewGuid(); + orgUser2.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); + var request = CreateValidationRequest( + organizationId, + [orgUser1, orgUser2], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.True(r.IsValid)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithRevokedUser_ReturnsErrorForThatUser( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser) + { + // Arrange + revokedUser.OrganizationId = organizationId; + revokedUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); + var request = CreateValidationRequest( + organizationId, + [revokedUser], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Single(results); + Assert.True(results.First().IsError); + Assert.IsType(results.First().AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenRevokingSelf_ReturnsErrorForThatUser( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Arrange + orgUser.OrganizationId = organizationId; + orgUser.UserId = actingUserId; + + var actingUser = CreateActingUser(actingUserId, false, null); + var request = CreateValidationRequest( + organizationId, + [orgUser], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Single(results); + Assert.True(results.First().IsError); + Assert.IsType(results.First().AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenNonOwnerRevokesOwner_ReturnsErrorForThatUser( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser) + { + // Arrange + ownerUser.OrganizationId = organizationId; + ownerUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); + var request = CreateValidationRequest( + organizationId, + [ownerUser], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Single(results); + Assert.True(results.First().IsError); + Assert.IsType(results.First().AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOwnerRevokesOwner_ReturnsSuccess( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser) + { + // Arrange + ownerUser.OrganizationId = organizationId; + ownerUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, true, null); + var request = CreateValidationRequest( + organizationId, + [ownerUser], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Single(results); + Assert.True(results.First().IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithMultipleUsers_SomeValid_ReturnsMixedResults( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser validUser, + [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser) + { + // Arrange + validUser.OrganizationId = revokedUser.OrganizationId = organizationId; + validUser.UserId = Guid.NewGuid(); + revokedUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); + var request = CreateValidationRequest( + organizationId, + [validUser, revokedUser], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Equal(2, results.Count); + + var validResult = results.Single(r => r.Request.Id == validUser.Id); + var errorResult = results.Single(r => r.Request.Id == revokedUser.Id); + + Assert.True(validResult.IsValid); + Assert.True(errorResult.IsError); + Assert.IsType(errorResult.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithSystemUser_DoesNotRequireActingUserId( + SutProvider sutProvider, + Guid organizationId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Arrange + orgUser.OrganizationId = organizationId; + orgUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(null, false, EventSystemUser.SCIM); + var request = CreateValidationRequest( + organizationId, + [orgUser], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Single(results); + Assert.True(results.First().IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenRevokingLastOwner_ReturnsErrorForThatUser( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser lastOwner) + { + // Arrange + lastOwner.OrganizationId = organizationId; + lastOwner.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, true, null); // Is an owner + var request = CreateValidationRequest( + organizationId, + [lastOwner], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(false); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Single(results); + Assert.True(results.First().IsError); + Assert.IsType(results.First().AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithMultipleValidationErrors_ReturnsAllErrors( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser) + { + // Arrange + revokedUser.OrganizationId = ownerUser.OrganizationId = organizationId; + revokedUser.UserId = Guid.NewGuid(); + ownerUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); // Not an owner + var request = CreateValidationRequest( + organizationId, + [revokedUser, ownerUser], + actingUser); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + // Act + var results = (await sutProvider.Sut.ValidateAsync(request)).ToList(); + + // Assert + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.True(r.IsError)); + + Assert.Contains(results, r => r.AsError is UserAlreadyRevoked); + Assert.Contains(results, r => r.AsError is OnlyOwnersCanRevokeOwners); + } + + private static IActingUser CreateActingUser(Guid? userId, bool isOwnerOrProvider, EventSystemUser? systemUserType) => + (userId, systemUserType) switch + { + ({ } id, _) => new StandardUser(id, isOwnerOrProvider), + (null, { } type) => new SystemUser(type) + }; + + + private static RevokeOrganizationUsersValidationRequest CreateValidationRequest( + Guid organizationId, + ICollection organizationUsers, + IActingUser actingUser) + { + return new RevokeOrganizationUsersValidationRequest( + organizationId, + organizationUsers.Select(u => u.Id).ToList(), + actingUser, + organizationUsers + ); + } +}