diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 550644a3c4..024c54a48e 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -690,7 +690,16 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task RestoreAsync(Guid orgId, Guid id) { - await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); + await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, null)); + } + + + [HttpPut("{id}/restore/vnext")] + [Authorize] + [RequireFeature(FeatureFlagKeys.DefaultUserCollectionRestore)] + public async Task RestoreAsync_vNext(Guid orgId, Guid id, [FromBody] OrganizationUserRestoreRequest request) + { + await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, request.DefaultUserCollectionName)); } [HttpPatch("{id}/restore")] @@ -705,7 +714,9 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); + return await RestoreOrRevokeUsersAsync(orgId, model, + (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, + restoringUserId, _userService, model.DefaultUserCollectionName)); } [HttpPatch("restore")] diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index b7a4db3acd..06fe654b73 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -116,12 +116,17 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel public string ResetPasswordKey { get; set; } public string MasterPasswordHash { get; set; } } - +#nullable enable public class OrganizationUserBulkRequestModel { [Required, MinLength(1)] - public IEnumerable Ids { get; set; } + public IEnumerable Ids { get; set; } = new List(); + + [EncryptedString] + [EncryptedStringLength(1000)] + public string? DefaultUserCollectionName { get; set; } } +#nullable disable public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel { diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRestoreRequest.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRestoreRequest.cs new file mode 100644 index 0000000000..ff5f877b3a --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRestoreRequest.cs @@ -0,0 +1,13 @@ +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class OrganizationUserRestoreRequest +{ + /// + /// This is the encrypted default collection name to be used for restored users if required + /// + [EncryptedString] + [EncryptedStringLength(1000)] + public string? DefaultUserCollectionName { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs index e5e5bfb482..82ea0a1c11 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs @@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand /// /// Revoked user to be restored. /// UserId of the user performing the action. - Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId); + Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string? defaultCollectionName); /// /// Validates that the requesting user can perform the action. There is also a check done to ensure the organization @@ -50,5 +50,5 @@ public interface IRestoreOrganizationUserCommand /// Passed in from caller to avoid circular dependency /// List of organization user Ids and strings. A successful restoration will have an empty string. /// If an error occurs, the error message will be provided. - Task>> RestoreUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService); + Task>> RestoreUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService, string? defaultCollectionName); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index c5b7314730..a764410e51 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -31,9 +31,10 @@ public class RestoreOrganizationUserCommand( IOrganizationService organizationService, IFeatureService featureService, IPolicyRequirementQuery policyRequirementQuery, + ICollectionRepository collectionRepository, IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand { - public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) + public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) { if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value) { @@ -46,7 +47,7 @@ public class RestoreOrganizationUserCommand( throw new BadRequestException("Only owners can restore other owners."); } - await RepositoryRestoreUserAsync(organizationUser); + await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName); await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); if (organizationUser.UserId.HasValue) @@ -57,7 +58,7 @@ public class RestoreOrganizationUserCommand( public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser) { - await RepositoryRestoreUserAsync(organizationUser); + await RepositoryRestoreUserAsync(organizationUser, null); // users stored by a system user will not get a default collection at this point. await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser); @@ -67,7 +68,7 @@ public class RestoreOrganizationUserCommand( } } - private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser) + private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName) { if (organizationUser.Status != OrganizationUserStatusType.Revoked) { @@ -104,7 +105,17 @@ public class RestoreOrganizationUserCommand( await organizationUserRepository.RestoreAsync(organizationUser.Id, status); - organizationUser.Status = status; + if (organizationUser.UserId.HasValue + && (await policyRequirementQuery.GetAsync(organizationUser.UserId + .Value)).State == OrganizationDataOwnershipState.Enabled + && status == OrganizationUserStatusType.Confirmed + && featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + && !string.IsNullOrWhiteSpace(defaultCollectionName)) + { + await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId, + [organizationUser.Id], + defaultCollectionName); + } } private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser) @@ -156,7 +167,8 @@ public class RestoreOrganizationUserCommand( } public async Task>> RestoreUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService) + IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService, + string defaultCollectionName) { var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) @@ -187,6 +199,9 @@ public class RestoreOrganizationUserCommand( var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers); var result = new List>(); + var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery + .GetManyByOrganizationIdAsync(organizationId)) + .ToList(); foreach (var organizationUser in filteredUsers) { @@ -223,13 +238,24 @@ public class RestoreOrganizationUserCommand( var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser); await organizationUserRepository.RestoreAsync(organizationUser.Id, status); - organizationUser.Status = status; - await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + if (organizationUser.UserId.HasValue) { + if (organizationUsersDataOwnershipEnabled.Contains(organizationUser.Id) + && status == OrganizationUserStatusType.Confirmed + && !string.IsNullOrWhiteSpace(defaultCollectionName) + && featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore)) + { + await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId, + [organizationUser.Id], + defaultCollectionName); + } + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); } + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + result.Add(Tuple.Create(organizationUser, "")); } catch (BadRequestException e) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index d9a72582ab..67f3e051af 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 DefaultUserCollectionRestore = "pm-30883-my-items-restored-users"; public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface"; public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance"; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index a75345a05d..29c996cee9 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -37,7 +37,7 @@ public class RestoreOrganizationUserCommandTests Sponsored = 0, Users = 1 }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -81,7 +81,7 @@ public class RestoreOrganizationUserCommandTests RestoreUser_Setup(organization, owner, organizationUser, sutProvider); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("you cannot restore yourself", exception.Message.ToLowerInvariant()); @@ -107,7 +107,7 @@ public class RestoreOrganizationUserCommandTests RestoreUser_Setup(organization, restoringUser, organizationUser, sutProvider); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id, null)); Assert.Contains("only owners can restore other owners", exception.Message.ToLowerInvariant()); @@ -133,7 +133,7 @@ public class RestoreOrganizationUserCommandTests RestoreUser_Setup(organization, owner, organizationUser, sutProvider); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("already active", exception.Message.ToLowerInvariant()); @@ -172,7 +172,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); @@ -216,7 +216,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); @@ -272,7 +272,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); @@ -309,7 +309,7 @@ public class RestoreOrganizationUserCommandTests Sponsored = 0, Users = 1 }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -349,7 +349,7 @@ public class RestoreOrganizationUserCommandTests } ])); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -395,7 +395,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the single organization policy", exception.Message.ToLowerInvariant()); @@ -447,7 +447,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); @@ -509,7 +509,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); @@ -548,7 +548,7 @@ public class RestoreOrganizationUserCommandTests .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -599,7 +599,7 @@ public class RestoreOrganizationUserCommandTests .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Equal("User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.", exception.Message); } @@ -651,7 +651,7 @@ public class RestoreOrganizationUserCommandTests .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await organizationUserRepository .Received(1) @@ -707,7 +707,7 @@ public class RestoreOrganizationUserCommandTests .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await organizationUserRepository .Received(1) @@ -735,7 +735,7 @@ public class RestoreOrganizationUserCommandTests Users = 1 }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, ""); await sutProvider.GetDependency() .Received(1) @@ -782,7 +782,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, new[] { orgUser1.Id, orgUser2.Id }, owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, new[] { orgUser1.Id, orgUser2.Id }, owner.Id, userService, null); // Assert Assert.Equal(2, result.Count); @@ -843,7 +843,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService, null); // Assert Assert.Equal(3, result.Count); @@ -914,7 +914,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService, null); // Assert Assert.Equal(3, result.Count); @@ -992,7 +992,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService, null); // Assert Assert.Equal(3, result.Count); @@ -1056,7 +1056,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null); // Assert Assert.Single(result); @@ -1107,7 +1107,7 @@ public class RestoreOrganizationUserCommandTests .Returns([new OrganizationUserPolicyDetails { OrganizationId = organization.Id, PolicyType = PolicyType.TwoFactorAuthentication }]); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null); Assert.Single(result); Assert.Equal(string.Empty, result[0].Item2); @@ -1138,5 +1138,408 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner); sutProvider.GetDependency().ManageUsers(organization.Id).Returns(requestingOrganizationUser != null && (requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin)); + + // Setup default disabled OrganizationDataOwnershipPolicyRequirement for any user + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); } + + private static void SetupOrganizationDataOwnershipPolicy( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUserStatusType orgUserStatus, + bool policyEnabled) + { + var policyDetails = policyEnabled + ? new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = Guid.NewGuid(), + OrganizationUserStatus = orgUserStatus, + PolicyType = PolicyType.OrganizationDataOwnership + } + } + : new List(); + + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + policyEnabled ? OrganizationDataOwnershipState.Enabled : OrganizationDataOwnershipState.Disabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + } + + #region Single User Restore - Default Collection Tests + + [Theory, BitAutoData] + public async Task RestoreUser_WithDataOwnershipPolicyEnabled_AndConfirmedUser_CreatesDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == organizationUser.Id), + defaultCollectionName); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithDataOwnershipPolicyDisabled_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: false); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithNullDefaultCollectionName_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + public async Task RestoreUser_WithEmptyOrWhitespaceDefaultCollectionName_DoesNotCreateDefaultCollection( + string defaultCollectionName, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_UserRestoredToInvitedStatus_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; // Non-Free plan to avoid ownership check requiring UserId + organizationUser.Email = "test@example.com"; // Non-null email means user restores to Invited status + organizationUser.UserId = null; // User not linked to account yet + organizationUser.Key = null; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert - User was restored to Invited status, so no collection should be created + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithNoUserId_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; // Non-Free plan to avoid ownership check requiring UserId + organizationUser.UserId = null; // No linked user account + organizationUser.Email = "test@example.com"; + organizationUser.Key = null; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + #endregion + + #region Bulk User Restore - Default Collection Tests + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_WithDataOwnershipPolicy_CreatesCollectionsForEligibleUsers( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // orgUser1: Will restore to Confirmed (Email = null) + orgUser1.Email = null; + orgUser1.OrganizationId = organization.Id; + + // orgUser2: Will restore to Invited (Email not null) + orgUser2.Email = "test@example.com"; + orgUser2.UserId = null; + orgUser2.Key = null; + orgUser2.OrganizationId = organization.Id; + + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) + .Returns([orgUser1, orgUser2]); + + // Setup bulk policy query - returns org user IDs with policy enabled + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns([orgUser1.Id]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync( + organization.Id, + [orgUser1.Id, orgUser2.Id], + owner.Id, + userService, + defaultCollectionName); + + // Assert - Only orgUser1 should have a collection created (Confirmed with policy enabled) + await sutProvider.GetDependency() + .Received(1) + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == orgUser1.Id), + defaultCollectionName); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_WithMixedPolicyStates_OnlyCreatesForEnabledPolicy( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Both users will restore to Confirmed + orgUser1.Email = null; + orgUser1.OrganizationId = organization.Id; + orgUser2.Email = null; + orgUser2.OrganizationId = organization.Id; + + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) + .Returns([orgUser1, orgUser2]); + + // Setup bulk policy query - only orgUser1 has policy enabled + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns([orgUser1.Id]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true), + (orgUser2.UserId!.Value, true) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync( + organization.Id, + [orgUser1.Id, orgUser2.Id], + owner.Id, + userService, + defaultCollectionName); + + // Assert - Only orgUser1 should have a collection created (policy enabled) + await sutProvider.GetDependency() + .Received(1) + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == orgUser1.Id), + defaultCollectionName); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_WithNullCollectionName_DoesNotCreateAnyCollections( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Both users will restore to Confirmed + orgUser1.Email = null; + orgUser1.OrganizationId = organization.Id; + orgUser2.Email = null; + orgUser2.OrganizationId = organization.Id; + + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) + .Returns([orgUser1, orgUser2]); + + // Setup bulk policy query - both users have policy enabled + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns([orgUser1.Id, orgUser2.Id]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true), + (orgUser2.UserId!.Value, true) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync( + organization.Id, + [orgUser1.Id, orgUser2.Id], + owner.Id, + userService, + null); // Null collection name + + // Assert - No collections should be created + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + #endregion }