1
0
mirror of https://github.com/bitwarden/server synced 2026-02-14 23:45:11 +00:00

[PM-28627] Create Default Collection Restore (#6879)

* Add default collection name to call stack for restore user command

* Committing feature flag and request model.

* Added tests

* fix for tests.

* added empty string to test

* figured out the mystery commit.

* added vnext onto method name.

* updating tests and command to include feature flag

* moved event call

* last few changes.

* opting for null instead of empty string.
This commit is contained in:
Jared McCannon
2026-01-28 09:05:29 -06:00
committed by GitHub
parent 369514c055
commit ddbaffad59
7 changed files with 496 additions and 37 deletions

View File

@@ -690,7 +690,16 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
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<ManageUsersRequirement>]
[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<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> 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")]

View File

@@ -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<Guid> Ids { get; set; }
public IEnumerable<Guid> Ids { get; set; } = new List<Guid>();
[EncryptedString]
[EncryptedStringLength(1000)]
public string? DefaultUserCollectionName { get; set; }
}
#nullable disable
public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel
{

View File

@@ -0,0 +1,13 @@
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationUserRestoreRequest
{
/// <summary>
/// This is the encrypted default collection name to be used for restored users if required
/// </summary>
[EncryptedString]
[EncryptedStringLength(1000)]
public string? DefaultUserCollectionName { get; set; }
}

View File

@@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand
/// </summary>
/// <param name="organizationUser">Revoked user to be restored.</param>
/// <param name="restoringUserId">UserId of the user performing the action.</param>
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string? defaultCollectionName);
/// <summary>
/// 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
/// <param name="userService">Passed in from caller to avoid circular dependency</param>
/// <returns>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.</returns>
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService, string? defaultCollectionName);
}

View File

@@ -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<OrganizationDataOwnershipPolicyRequirement>(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<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
IEnumerable<Guid> 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<Tuple<OrganizationUser, string>>();
var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(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)

View File

@@ -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";

View File

@@ -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<IOrganizationUserRepository>()
.Received(1)
@@ -81,7 +81,7 @@ public class RestoreOrganizationUserCommandTests
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<BadRequestException>(
() => 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<BadRequestException>(
() => 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<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IOrganizationUserRepository>()
.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<IOrganizationUserRepository>()
.Received(1)
@@ -395,7 +395,7 @@ public class RestoreOrganizationUserCommandTests
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IEnumerable<Guid>>(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<IOrganizationUserRepository>()
.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<BadRequestException>(
() => 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<IEnumerable<Guid>>(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<IEnumerable<Guid>>(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<IOrganizationUserRepository>()
.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<ICurrentContext>().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner);
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(requestingOrganizationUser != null && (requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin));
// Setup default disabled OrganizationDataOwnershipPolicyRequirement for any user
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(Arg.Any<Guid>())
.Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, []));
}
private static void SetupOrganizationDataOwnershipPolicy(
SutProvider<RestoreOrganizationUserCommand> sutProvider,
Guid userId,
Guid organizationId,
OrganizationUserStatusType orgUserStatus,
bool policyEnabled)
{
var policyDetails = policyEnabled
? new List<PolicyDetails>
{
new()
{
OrganizationId = organizationId,
OrganizationUserId = Guid.NewGuid(),
OrganizationUserStatus = orgUserStatus,
PolicyType = PolicyType.OrganizationDataOwnership
}
}
: new List<PolicyDetails>();
var policyRequirement = new OrganizationDataOwnershipPolicyRequirement(
policyEnabled ? OrganizationDataOwnershipState.Enabled : OrganizationDataOwnershipState.Disabled,
policyDetails);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(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<RestoreOrganizationUserCommand> sutProvider)
{
// Arrange
organizationUser.Email = null; // This causes user to restore to Confirmed status
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.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<ICollectionRepository>()
.Received(1)
.CreateDefaultCollectionsAsync(
organization.Id,
Arg.Is<IEnumerable<Guid>>(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<RestoreOrganizationUserCommand> sutProvider)
{
// Arrange
organizationUser.Email = null; // This causes user to restore to Confirmed status
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.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<ICollectionRepository>()
.DidNotReceive()
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task RestoreUser_WithNullDefaultCollectionName_DoesNotCreateDefaultCollection(
Organization organization,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
SutProvider<RestoreOrganizationUserCommand> sutProvider)
{
// Arrange
organizationUser.Email = null; // This causes user to restore to Confirmed status
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.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<ICollectionRepository>()
.DidNotReceive()
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[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<RestoreOrganizationUserCommand> sutProvider)
{
// Arrange
organizationUser.Email = null; // This causes user to restore to Confirmed status
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.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<ICollectionRepository>()
.DidNotReceive()
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[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<RestoreOrganizationUserCommand> 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<IFeatureService>()
.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<ICollectionRepository>()
.DidNotReceive()
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[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<RestoreOrganizationUserCommand> 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<IFeatureService>()
.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore)
.Returns(true);
// Act
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName);
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
#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<RestoreOrganizationUserCommand> sutProvider)
{
// Arrange
RestoreUser_Setup(organization, owner, orgUser1, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var userService = Substitute.For<IUserService>();
sutProvider.GetDependency<IFeatureService>()
.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<IEnumerable<Guid>>(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<IPolicyRequirementQuery>()
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organization.Id)
.Returns([orgUser1.Id]);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<Guid>>())
.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<ICollectionRepository>()
.Received(1)
.CreateDefaultCollectionsAsync(
organization.Id,
Arg.Is<IEnumerable<Guid>>(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<RestoreOrganizationUserCommand> sutProvider)
{
// Arrange
RestoreUser_Setup(organization, owner, orgUser1, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var userService = Substitute.For<IUserService>();
sutProvider.GetDependency<IFeatureService>()
.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<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id)))
.Returns([orgUser1, orgUser2]);
// Setup bulk policy query - only orgUser1 has policy enabled
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organization.Id)
.Returns([orgUser1.Id]);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<Guid>>())
.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<ICollectionRepository>()
.Received(1)
.CreateDefaultCollectionsAsync(
organization.Id,
Arg.Is<IEnumerable<Guid>>(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<RestoreOrganizationUserCommand> sutProvider)
{
// Arrange
RestoreUser_Setup(organization, owner, orgUser1, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var userService = Substitute.For<IUserService>();
sutProvider.GetDependency<IFeatureService>()
.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<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id)))
.Returns([orgUser1, orgUser2]);
// Setup bulk policy query - both users have policy enabled
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organization.Id)
.Returns([orgUser1.Id, orgUser2.Id]);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<Guid>>())
.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<ICollectionRepository>()
.DidNotReceive()
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
#endregion
}