From c189e4aaf55fda7dbcf7ce75b41bb1e02b470cdb Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 19 Aug 2025 14:12:34 -0400 Subject: [PATCH] [PM-22104] Migrate default collection when org user is removed (#6135) * migrate default collection to a shared collection when users are removed * remove redundant logic * fix test * fix tests * fix test * clean up * add migrations * run dotnet format * clean up, refactor duplicate logic to sproc, wip integration test * fix sql * add migration for new sproc * integration test wip * integration test wip * integration test wip * integration test wip * fix integration test LINQ expression * fix using wrong Id * wip integration test for DeleteManyAsync * fix LINQ * only set DefaultUserEmail when it is null in sproc * check for null * spelling, separate create and update request models * fix test * fix child class * refactor sproc * clean up * more cleanup * fix tests * fix user email * remove unneccesary test * add DefaultUserCollectionEmail to EF query * fix test * fix EF logic to match sprocs * clean up logic * cleanup --- src/Api/Controllers/CollectionsController.cs | 4 +- .../Models/Request/CollectionRequestModel.cs | 22 +- .../Response/CollectionResponseModel.cs | 1 + .../OrganizationUserRepository.cs | 210 +++++-- .../Repositories/CollectionRepository.cs | 12 +- .../Queries/CollectionAdminDetailsQuery.cs | 1 + .../OrganizationUser_DeleteById.sql | 7 +- .../OrganizationUser_DeleteByIds.sql | 3 + ...anizationUser_MigrateDefaultCollection.sql | 22 + .../Controllers/CollectionsControllerTests.cs | 179 +++++- .../OrganizationRepositoryTests.cs | 4 +- .../OrganizationUserRepositoryTests.cs | 547 ++++++++++++++---- .../Auth/Repositories/UserRepositoryTests.cs | 2 +- ...4-00_OrgUsers_MigrateDefaultCollection.sql | 22 + .../2025-08-04-01_OrgUsers_DeleteById.sql | 55 ++ .../2025-08-04-02_OrgUsers_DeleteByIds.sql | 105 ++++ 16 files changed, 1001 insertions(+), 195 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUser_MigrateDefaultCollection.sql create mode 100644 util/Migrator/DbScripts/2025-08-04-00_OrgUsers_MigrateDefaultCollection.sql create mode 100644 util/Migrator/DbScripts/2025-08-04-01_OrgUsers_DeleteById.sql create mode 100644 util/Migrator/DbScripts/2025-08-04-02_OrgUsers_DeleteByIds.sql diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 6708a66326..6d4e9c9fea 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -146,7 +146,7 @@ public class CollectionsController : Controller } [HttpPost("")] - public async Task Post(Guid orgId, [FromBody] CollectionRequestModel model) + public async Task Post(Guid orgId, [FromBody] CreateCollectionRequestModel model) { var collection = model.ToCollection(orgId); @@ -174,7 +174,7 @@ public class CollectionsController : Controller [HttpPut("{id}")] [HttpPost("{id}")] - public async Task Put(Guid orgId, Guid id, [FromBody] CollectionRequestModel model) + public async Task Put(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) { var collection = await _collectionRepository.GetByIdAsync(id); var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Update)).Succeeded; diff --git a/src/Api/Models/Request/CollectionRequestModel.cs b/src/Api/Models/Request/CollectionRequestModel.cs index 9aa80b859b..6e73c37db6 100644 --- a/src/Api/Models/Request/CollectionRequestModel.cs +++ b/src/Api/Models/Request/CollectionRequestModel.cs @@ -7,7 +7,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Models.Request; -public class CollectionRequestModel +public class CreateCollectionRequestModel { [Required] [EncryptedString] @@ -40,7 +40,7 @@ public class CollectionBulkDeleteRequestModel public IEnumerable Ids { get; set; } } -public class CollectionWithIdRequestModel : CollectionRequestModel +public class CollectionWithIdRequestModel : CreateCollectionRequestModel { public Guid? Id { get; set; } @@ -50,3 +50,21 @@ public class CollectionWithIdRequestModel : CollectionRequestModel return base.ToCollection(existingCollection); } } + +public class UpdateCollectionRequestModel : CreateCollectionRequestModel +{ + [EncryptedString] + [EncryptedStringLength(1000)] + public new string Name { get; set; } + + public override Collection ToCollection(Collection existingCollection) + { + if (string.IsNullOrEmpty(existingCollection.DefaultUserCollectionEmail) && !string.IsNullOrWhiteSpace(Name)) + { + existingCollection.Name = Name; + } + existingCollection.ExternalId = ExternalId; + return existingCollection; + } + +} diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index d679250f05..10d56481c4 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -49,6 +49,7 @@ public class CollectionDetailsResponseModel : CollectionResponseModel ReadOnly = collectionDetails.ReadOnly; HidePasswords = collectionDetails.HidePasswords; Manage = collectionDetails.Manage; + DefaultUserCollectionEmail = collectionDetails.DefaultUserCollectionEmail; } public bool ReadOnly { get; set; } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index c6dd621c28..fae0598c1c 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -5,6 +5,7 @@ using AutoMapper; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -73,53 +74,91 @@ public class OrganizationUserRepository : Repository u.Id).ToList(); } - public override async Task DeleteAsync(Core.Entities.OrganizationUser organizationUser) => await DeleteAsync(organizationUser.Id); - public async Task DeleteAsync(Guid organizationUserId) + public override async Task DeleteAsync(Core.Entities.OrganizationUser organizationUser) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdAsync(organizationUserId); var orgUser = await dbContext.OrganizationUsers - .Where(ou => ou.Id == organizationUserId) - .FirstAsync(); + .Where(ou => ou.Id == organizationUser.Id) + .Select(ou => new + { + ou.Id, + ou.UserId, + OrgEmail = ou.Email, + UserEmail = ou.User.Email + }) + .FirstOrDefaultAsync(); - var organizationId = orgUser?.OrganizationId; + if (orgUser == null) + { + throw new NotFoundException("User not found."); + } + + var email = !string.IsNullOrEmpty(orgUser.OrgEmail) + ? orgUser.OrgEmail + : orgUser.UserEmail; + var organizationId = organizationUser?.OrganizationId; var userId = orgUser?.UserId; + var utcNow = DateTime.UtcNow; - if (orgUser?.OrganizationId != null && orgUser?.UserId != null) + using var transaction = await dbContext.Database.BeginTransactionAsync(); + + try { - var ssoUsers = dbContext.SsoUsers - .Where(su => su.UserId == userId && su.OrganizationId == organizationId); - dbContext.SsoUsers.RemoveRange(ssoUsers); + await dbContext.Collections + .Where(c => c.Type == CollectionType.DefaultUserCollection + && c.CollectionUsers.Any(cu => cu.OrganizationUserId == organizationUser.Id)) + .ExecuteUpdateAsync(setters => setters + .SetProperty(c => c.Type, CollectionType.SharedCollection) + .SetProperty(c => c.RevisionDate, utcNow) + .SetProperty(c => c.DefaultUserCollectionEmail, + c => c.DefaultUserCollectionEmail == null ? email : c.DefaultUserCollectionEmail)); + + await dbContext.CollectionUsers + .Where(cu => cu.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.GroupUsers + .Where(gu => gu.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.SsoUsers + .Where(su => su.UserId == userId && su.OrganizationId == organizationId) + .ExecuteDeleteAsync(); + + await dbContext.UserProjectAccessPolicy + .Where(ap => ap.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.UserServiceAccountAccessPolicy + .Where(ap => ap.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.UserSecretAccessPolicy + .Where(ap => ap.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationSponsorships + .Where(os => os.SponsoringOrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.Users + .Where(u => u.Id == orgUser.UserId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(u => u.AccountRevisionDate, utcNow)); + + await dbContext.OrganizationUsers + .Where(ou => ou.Id == organizationUser.Id) + .ExecuteDeleteAsync(); + + await transaction.CommitAsync(); } - - var collectionUsers = dbContext.CollectionUsers - .Where(cu => cu.OrganizationUserId == organizationUserId); - dbContext.CollectionUsers.RemoveRange(collectionUsers); - - var groupUsers = dbContext.GroupUsers - .Where(gu => gu.OrganizationUserId == organizationUserId); - dbContext.GroupUsers.RemoveRange(groupUsers); - - dbContext.UserProjectAccessPolicy.RemoveRange( - dbContext.UserProjectAccessPolicy.Where(ap => ap.OrganizationUserId == organizationUserId)); - dbContext.UserServiceAccountAccessPolicy.RemoveRange( - dbContext.UserServiceAccountAccessPolicy.Where(ap => ap.OrganizationUserId == organizationUserId)); - dbContext.UserSecretAccessPolicy.RemoveRange( - dbContext.UserSecretAccessPolicy.Where(ap => ap.OrganizationUserId == organizationUserId)); - - var orgSponsorships = await dbContext.OrganizationSponsorships - .Where(os => os.SponsoringOrganizationUserId == organizationUserId) - .ToListAsync(); - - foreach (var orgSponsorship in orgSponsorships) + catch { - orgSponsorship.ToDelete = true; + await transaction.RollbackAsync(); + throw; } - - dbContext.OrganizationUsers.Remove(orgUser); - await dbContext.SaveChangesAsync(); } } @@ -130,31 +169,92 @@ public class OrganizationUserRepository : Repository targetOrganizationUserIds.Contains(cu.OrganizationUserId)) - .ExecuteDeleteAsync(); + try + { + await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(targetOrganizationUserIds); - await dbContext.GroupUsers - .Where(gu => targetOrganizationUserIds.Contains(gu.OrganizationUserId)) - .ExecuteDeleteAsync(); + var organizationUsersToDelete = await dbContext.OrganizationUsers + .Where(ou => targetOrganizationUserIds.Contains(ou.Id)) + .Include(ou => ou.User) + .ToListAsync(); - await dbContext.UserProjectAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); - await dbContext.UserServiceAccountAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); - await dbContext.UserSecretAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); + var collectionUsers = await dbContext.CollectionUsers + .Where(cu => targetOrganizationUserIds.Contains(cu.OrganizationUserId)) + .ToListAsync(); - await dbContext.OrganizationUsers - .Where(ou => targetOrganizationUserIds.Contains(ou.Id)).ExecuteDeleteAsync(); + var collectionIds = collectionUsers.Select(cu => cu.CollectionId).Distinct().ToList(); - await dbContext.SaveChangesAsync(); - await transaction.CommitAsync(); + var collections = await dbContext.Collections + .Where(c => collectionIds.Contains(c.Id)) + .ToListAsync(); + + var collectionsToUpdate = collections + .Where(c => c.Type == CollectionType.DefaultUserCollection) + .ToList(); + + var collectionUserLookup = collectionUsers.ToLookup(cu => cu.CollectionId); + + foreach (var collection in collectionsToUpdate) + { + var collectionUser = collectionUserLookup[collection.Id].FirstOrDefault(); + if (collectionUser != null) + { + var orgUser = organizationUsersToDelete.FirstOrDefault(ou => ou.Id == collectionUser.OrganizationUserId); + + if (orgUser?.User != null) + { + if (string.IsNullOrEmpty(collection.DefaultUserCollectionEmail)) + { + var emailToUse = !string.IsNullOrEmpty(orgUser.Email) + ? orgUser.Email + : orgUser.User.Email; + + if (!string.IsNullOrEmpty(emailToUse)) + { + collection.DefaultUserCollectionEmail = emailToUse; + } + } + collection.Type = CollectionType.SharedCollection; + } + } + } + + await dbContext.CollectionUsers + .Where(cu => targetOrganizationUserIds.Contains(cu.OrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.GroupUsers + .Where(gu => targetOrganizationUserIds.Contains(gu.OrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.UserProjectAccessPolicy + .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.UserServiceAccountAccessPolicy + .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.UserSecretAccessPolicy + .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationSponsorships + .Where(os => targetOrganizationUserIds.Contains(os.SponsoringOrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationUsers + .Where(ou => targetOrganizationUserIds.Contains(ou.Id)).ExecuteDeleteAsync(); + + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } } public async Task>> GetByIdWithCollectionsAsync(Guid id) diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 9f047e4653..569e541163 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -325,7 +325,8 @@ public class CollectionRepository : Repository new CollectionAdminDetails { Id = collectionGroup.Key.Id, @@ -339,7 +340,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), - Unmanaged = collectionGroup.Key.Unmanaged + Unmanaged = collectionGroup.Key.Unmanaged, + DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail }).ToList(); } else @@ -353,7 +355,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), - Unmanaged = collectionGroup.Key.Unmanaged + Unmanaged = collectionGroup.Key.Unmanaged, + DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail }).ToListAsync(); } diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs index c893bff15c..2b6e61d056 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs @@ -81,6 +81,7 @@ public class CollectionAdminDetailsQuery : IQuery ExternalId = x.c.ExternalId, CreationDate = x.c.CreationDate, RevisionDate = x.c.RevisionDate, + DefaultUserCollectionEmail = x.c.DefaultUserCollectionEmail, ReadOnly = (bool?)x.cu.ReadOnly ?? (bool?)x.cg.ReadOnly ?? false, HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false, Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql index d706bd4d75..fc95cb112a 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_DeleteById] +CREATE PROCEDURE [dbo].[OrganizationUser_DeleteById] @Id UNIQUEIDENTIFIER AS BEGIN @@ -17,6 +17,11 @@ BEGIN WHERE [Id] = @Id + -- Migrate DefaultUserCollection to SharedCollection + DECLARE @Ids [dbo].[GuidIdArray] + INSERT INTO @Ids (Id) VALUES (@Id) + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @Ids + IF @OrganizationId IS NOT NULL AND @UserId IS NOT NULL BEGIN EXEC [dbo].[SsoUser_Delete] @UserId, @OrganizationId diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql index ac9e75dd5e..79e060c323 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql @@ -6,6 +6,9 @@ BEGIN EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @Ids + -- Migrate DefaultCollection to SharedCollection + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @Ids + DECLARE @UserAndOrganizationIds [dbo].[TwoGuidIdArray] INSERT INTO @UserAndOrganizationIds diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_MigrateDefaultCollection.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_MigrateDefaultCollection.sql new file mode 100644 index 0000000000..f65cdc3983 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_MigrateDefaultCollection.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_MigrateDefaultCollection] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + DECLARE @UtcNow DATETIME2(7) = GETUTCDATE(); + + UPDATE c + SET + [DefaultUserCollectionEmail] = CASE WHEN c.[DefaultUserCollectionEmail] IS NULL THEN u.[Email] ELSE c.[DefaultUserCollectionEmail] END, + [RevisionDate] = @UtcNow, + [Type] = 0 + FROM + [dbo].[Collection] c + INNER JOIN [dbo].[CollectionUser] cu ON c.[Id] = cu.[CollectionId] + INNER JOIN [dbo].[OrganizationUser] ou ON cu.[OrganizationUserId] = ou.[Id] + INNER JOIN [dbo].[User] u ON ou.[UserId] = u.[Id] + INNER JOIN @Ids i ON ou.[Id] = i.[Id] + WHERE + c.[Type] = 1 +END +GO diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index 99e329b500..a3d34efb63 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -22,7 +22,7 @@ namespace Bit.Api.Test.Controllers; public class CollectionsControllerTests { [Theory, BitAutoData] - public async Task Post_Success(Organization organization, CollectionRequestModel collectionRequest, + public async Task Post_Success(Organization organization, CreateCollectionRequestModel collectionRequest, SutProvider sutProvider) { Collection ExpectedCollection() => Arg.Is(c => @@ -46,9 +46,10 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task Put_Success(Collection collection, CollectionRequestModel collectionRequest, + public async Task Put_Success(Collection collection, UpdateCollectionRequestModel collectionRequest, SutProvider sutProvider) { + collection.DefaultUserCollectionEmail = null; Collection ExpectedCollection() => Arg.Is(c => c.Id == collection.Id && c.Name == collectionRequest.Name && c.ExternalId == collectionRequest.ExternalId && c.OrganizationId == collection.OrganizationId); @@ -72,7 +73,7 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task Put_WithNoCollectionPermission_ThrowsNotFound(Collection collection, CollectionRequestModel collectionRequest, + public async Task Put_WithNoCollectionPermission_ThrowsNotFound(Collection collection, UpdateCollectionRequestModel collectionRequest, SutProvider sutProvider) { sutProvider.GetDependency() @@ -484,4 +485,176 @@ public class CollectionsControllerTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .AddAccessAsync(default, default, default); } + + [Theory, BitAutoData] + public async Task Put_With_NonNullName_DoesNotPreserveExistingName(Collection existingCollection, UpdateCollectionRequestModel collectionRequest, + SutProvider sutProvider) + { + // Arrange + var newName = "new name"; + var originalName = "original name"; + + existingCollection.Name = originalName; + existingCollection.DefaultUserCollectionEmail = null; + + collectionRequest.Name = newName; + + sutProvider.GetDependency() + .GetByIdAsync(existingCollection.Id) + .Returns(existingCollection); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + existingCollection, + Arg.Is>(r => r.Contains(BulkCollectionOperations.Update))) + .Returns(AuthorizationResult.Success()); + + // Act + await sutProvider.Sut.Put(existingCollection.OrganizationId, existingCollection.Id, collectionRequest); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync( + Arg.Is(c => c.Id == existingCollection.Id && c.Name == newName), + Arg.Any>(), + Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task Put_WithNullName_DoesPreserveExistingName(Collection existingCollection, UpdateCollectionRequestModel collectionRequest, + SutProvider sutProvider) + { + // Arrange + var originalName = "original name"; + + existingCollection.Name = originalName; + existingCollection.DefaultUserCollectionEmail = null; + + collectionRequest.Name = null; + + sutProvider.GetDependency() + .GetByIdAsync(existingCollection.Id) + .Returns(existingCollection); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + existingCollection, + Arg.Is>(r => r.Contains(BulkCollectionOperations.Update))) + .Returns(AuthorizationResult.Success()); + + // Act + await sutProvider.Sut.Put(existingCollection.OrganizationId, existingCollection.Id, collectionRequest); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync( + Arg.Is(c => c.Id == existingCollection.Id && c.Name == originalName), + Arg.Any>(), + Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task Put_WithDefaultUserCollectionEmail_DoesPreserveExistingName(Collection existingCollection, UpdateCollectionRequestModel collectionRequest, + SutProvider sutProvider) + { + // Arrange + var originalName = "original name"; + var defaultUserCollectionEmail = "user@email.com"; + + existingCollection.Name = originalName; + existingCollection.DefaultUserCollectionEmail = defaultUserCollectionEmail; + + collectionRequest.Name = "new name"; + + sutProvider.GetDependency() + .GetByIdAsync(existingCollection.Id) + .Returns(existingCollection); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + existingCollection, + Arg.Is>(r => r.Contains(BulkCollectionOperations.Update))) + .Returns(AuthorizationResult.Success()); + + // Act + await sutProvider.Sut.Put(existingCollection.OrganizationId, existingCollection.Id, collectionRequest); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync( + Arg.Is(c => c.Id == existingCollection.Id && c.Name == originalName && c.DefaultUserCollectionEmail == defaultUserCollectionEmail), + Arg.Any>(), + Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task Put_WithEmptyName_DoesPreserveExistingName(Collection existingCollection, UpdateCollectionRequestModel collectionRequest, + SutProvider sutProvider) + { + // Arrange + var originalName = "original name"; + + existingCollection.Name = originalName; + existingCollection.DefaultUserCollectionEmail = null; + + collectionRequest.Name = ""; // Empty string + + sutProvider.GetDependency() + .GetByIdAsync(existingCollection.Id) + .Returns(existingCollection); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + existingCollection, + Arg.Is>(r => r.Contains(BulkCollectionOperations.Update))) + .Returns(AuthorizationResult.Success()); + + // Act + await sutProvider.Sut.Put(existingCollection.OrganizationId, existingCollection.Id, collectionRequest); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync( + Arg.Is(c => c.Id == existingCollection.Id && c.Name == originalName), + Arg.Any>(), + Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task Put_WithWhitespaceOnlyName_DoesPreserveExistingName(Collection existingCollection, UpdateCollectionRequestModel collectionRequest, + SutProvider sutProvider) + { + // Arrange + var originalName = "original name"; + + existingCollection.Name = originalName; + existingCollection.DefaultUserCollectionEmail = null; + + collectionRequest.Name = " "; // Whitespace only + + sutProvider.GetDependency() + .GetByIdAsync(existingCollection.Id) + .Returns(existingCollection); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + existingCollection, + Arg.Is>(r => r.Contains(BulkCollectionOperations.Update))) + .Returns(AuthorizationResult.Success()); + + // Act + await sutProvider.Sut.Put(existingCollection.OrganizationId, existingCollection.Id, collectionRequest); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync( + Arg.Is(c => c.Id == existingCollection.Id && c.Name == originalName), + Arg.Any>(), + Arg.Any>()); + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs index ae30fb4bed..67e2c1910b 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -57,8 +57,8 @@ public class OrganizationRepositoryTests var organization = await organizationRepository.CreateAsync(new Organization { Name = $"Test Org {id}", - BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl - Plan = "Test", // TODO: EF does not enforce this being NOT NULl + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL PrivateKey = "privatekey", }); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index 612e8d1074..a07d5c934b 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -28,8 +28,8 @@ public class OrganizationUserRepositoryTests var organization = await organizationRepository.CreateAsync(new Organization { Name = "Test Org", - BillingEmail = user.Email, // TODO: EF does not enfore this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULl + BillingEmail = user.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL }); var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser @@ -37,6 +37,7 @@ public class OrganizationUserRepositoryTests OrganizationId = organization.Id, UserId = user.Id, Status = OrganizationUserStatusType.Confirmed, + Email = user.Email }); await organizationUserRepository.DeleteAsync(orgUser); @@ -46,6 +47,171 @@ public class OrganizationUserRepositoryTests Assert.NotEqual(newUser.AccountRevisionDate, user.AccountRevisionDate); } + [DatabaseTheory, DatabaseData] + public async Task DeleteManyAsync_Migrates_UserDefaultCollection(IUserRepository userRepository, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository + ) + { + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + Email = user1.Email + }); + + var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Confirmed, + Email = user2.Email + }); + + var defaultUserCollection1 = await collectionRepository.CreateAsync(new Collection + { + Name = "Test Collection 1", + Id = user1.Id, + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }); + + var defaultUserCollection2 = await collectionRepository.CreateAsync(new Collection + { + Name = "Test Collection 2", + Id = user2.Id, + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }); + + // Create the CollectionUser entry for the defaultUserCollection + await collectionRepository.UpdateUsersAsync(defaultUserCollection1.Id, new List() + { + new CollectionAccessSelection + { + Id = orgUser1.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }, + }); + + await collectionRepository.UpdateUsersAsync(defaultUserCollection2.Id, new List() + { + new CollectionAccessSelection + { + Id = orgUser2.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }, + }); + + await organizationUserRepository.DeleteManyAsync(new List { orgUser1.Id, orgUser2.Id }); + + var newUser = await userRepository.GetByIdAsync(user1.Id); + Assert.NotNull(newUser); + Assert.NotEqual(newUser.AccountRevisionDate, user1.AccountRevisionDate); + + var updatedCollection1 = await collectionRepository.GetByIdAsync(defaultUserCollection1.Id); + Assert.NotNull(updatedCollection1); + Assert.Equal(CollectionType.SharedCollection, updatedCollection1.Type); + Assert.Equal(user1.Email, updatedCollection1.DefaultUserCollectionEmail); + + var updatedCollection2 = await collectionRepository.GetByIdAsync(defaultUserCollection2.Id); + Assert.NotNull(updatedCollection2); + Assert.Equal(CollectionType.SharedCollection, updatedCollection2.Type); + Assert.Equal(user2.Email, updatedCollection2.DefaultUserCollectionEmail); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteAsync_Migrates_UserDefaultCollection(IUserRepository userRepository, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository + ) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = user.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Email = user.Email + }); + + var defaultUserCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Test Collection", + Id = user.Id, + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }); + + // Create the CollectionUser entry for the defaultUserCollection + await collectionRepository.UpdateUsersAsync(defaultUserCollection.Id, new List() + { + new CollectionAccessSelection + { + Id = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }, + }); + + await organizationUserRepository.DeleteAsync(orgUser); + + var newUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(newUser); + Assert.NotEqual(newUser.AccountRevisionDate, user.AccountRevisionDate); + + var updatedCollection = await collectionRepository.GetByIdAsync(defaultUserCollection.Id); + Assert.NotNull(updatedCollection); + Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type); + Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail); + } + + [DatabaseTheory, DatabaseData] public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationRepository organizationRepository, @@ -70,8 +236,8 @@ public class OrganizationUserRepositoryTests var organization = await organizationRepository.CreateAsync(new Organization { Name = "Test Org", - BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl - Plan = "Test", // TODO: EF does not enforce this being NOT NULl + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL }); var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser @@ -79,6 +245,7 @@ public class OrganizationUserRepositoryTests OrganizationId = organization.Id, UserId = user1.Id, Status = OrganizationUserStatusType.Confirmed, + Email = user1.Email }); var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser @@ -86,6 +253,7 @@ public class OrganizationUserRepositoryTests OrganizationId = organization.Id, UserId = user2.Id, Status = OrganizationUserStatusType.Confirmed, + Email = user2.Email }); await organizationUserRepository.DeleteManyAsync(new List @@ -135,8 +303,8 @@ public class OrganizationUserRepositoryTests var organization = await organizationRepository.CreateAsync(new Organization { Name = "Test Org", - BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl - Plan = "Test", // TODO: EF does not enforce this being NOT NULl + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL PrivateKey = "privatekey", }); @@ -291,8 +459,8 @@ public class OrganizationUserRepositoryTests var organization = await organizationRepository.CreateAsync(new Organization { Name = "Test Org", - BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl - Plan = "Test", // TODO: EF does not enforce this being NOT NULl + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL PrivateKey = "privatekey", }); @@ -354,6 +522,134 @@ public class OrganizationUserRepositoryTests Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies); } + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{id}@x-{domainName}", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user3 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{id}@{domainName}.example.com", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL + PrivateKey = "privatekey", + UsePolicies = false, + UseSso = false, + UseKeyConnector = false, + UseScim = false, + UseGroups = false, + UseDirectory = false, + UseEvents = false, + UseTotp = false, + Use2fa = false, + UseApi = false, + UseResetPassword = false, + UseSecretsManager = false, + SelfHost = false, + UsersGetPremium = false, + UseCustomPermissions = false, + Enabled = true, + UsePasswordManager = false, + LimitCollectionCreation = false, + LimitCollectionDeletion = false, + LimitItemDeletion = false, + AllowAdminAccessToAllCollectionItems = false, + UseRiskInsights = false, + UseAdminSponsoredFamilies = false + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user3.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false + }); + + var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); + + Assert.NotNull(responseModel); + Assert.Single(responseModel); + Assert.Equal(orgUser1.Id, responseModel.Single().Id); + } + [DatabaseTheory, DatabaseData] public async Task CreateManyAsync_NoId_Works(IOrganizationRepository organizationRepository, IUserRepository userRepository, @@ -369,7 +665,7 @@ public class OrganizationUserRepositoryTests { Name = $"test-{Guid.NewGuid()}", BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULl + Plan = "Test", // TODO: EF does not enforce this being NOT NULL }); var orgUsers = users.Select(u => new OrganizationUser @@ -403,7 +699,7 @@ public class OrganizationUserRepositoryTests { Name = $"test-{Guid.NewGuid()}", BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULl + Plan = "Test", // TODO: EF does not enforce this being NOT NULL }); var orgUsers = users.Select(u => new OrganizationUser @@ -435,8 +731,8 @@ public class OrganizationUserRepositoryTests var organization = await organizationRepository.CreateAsync(new Organization { Name = "Test Org", - BillingEmail = "billing@test.com", // TODO: EF does not enfore this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULl, + BillingEmail = "billing@test.com", // TODO: EF does not enforce this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULL, CreationDate = requestTime }); @@ -862,119 +1158,6 @@ public class OrganizationUserRepositoryTests Assert.DoesNotContain(user1Result.Collections, c => c.Id == defaultUserCollection.Id); } - [DatabaseTheory, DatabaseData] - public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationDomainRepository organizationDomainRepository) - { - var id = Guid.NewGuid(); - var domainName = $"{id}.example.com"; - var requestTime = DateTime.UtcNow; - - var user1 = await userRepository.CreateAsync(new User - { - Id = CoreHelpers.GenerateComb(), - Name = "Test User 1", - Email = $"test+{id}@{domainName}", - ApiKey = "TEST", - SecurityStamp = "stamp", - CreationDate = requestTime, - RevisionDate = requestTime, - AccountRevisionDate = requestTime - }); - - var user2 = await userRepository.CreateAsync(new User - { - Id = CoreHelpers.GenerateComb(), - Name = "Test User 2", - Email = $"test+{id}@x-{domainName}", // Different domain - ApiKey = "TEST", - SecurityStamp = "stamp", - CreationDate = requestTime, - RevisionDate = requestTime, - AccountRevisionDate = requestTime - }); - - var user3 = await userRepository.CreateAsync(new User - { - Id = CoreHelpers.GenerateComb(), - Name = "Test User 3", - Email = $"test+{id}@{domainName}.example.com", // Different domain - ApiKey = "TEST", - SecurityStamp = "stamp", - CreationDate = requestTime, - RevisionDate = requestTime, - AccountRevisionDate = requestTime - }); - - var organization = await organizationRepository.CreateAsync(new Organization - { - Id = CoreHelpers.GenerateComb(), - Name = $"Test Org {id}", - BillingEmail = user1.Email, - Plan = "Test", - Enabled = true, - CreationDate = requestTime, - RevisionDate = requestTime - }); - - var organizationDomain = new OrganizationDomain - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - DomainName = domainName, - Txt = "btw+12345", - CreationDate = requestTime - }; - organizationDomain.SetNextRunDate(12); - organizationDomain.SetVerifiedDate(); - organizationDomain.SetJobRunCount(); - await organizationDomainRepository.CreateAsync(organizationDomain); - - var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - UserId = user1.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Owner, - CreationDate = requestTime, - RevisionDate = requestTime - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - UserId = user2.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - CreationDate = requestTime, - RevisionDate = requestTime - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - UserId = user3.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - CreationDate = requestTime, - RevisionDate = requestTime - }); - - var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); - - Assert.NotNull(responseModel); - Assert.Single(responseModel); - Assert.Equal(orgUser1.Id, responseModel.Single().Id); - Assert.Equal(user1.Id, responseModel.Single().UserId); - Assert.Equal(organization.Id, responseModel.Single().OrganizationId); - } - [DatabaseTheory, DatabaseData] public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithNoVerifiedDomain_ReturnsEmpty( IUserRepository userRepository, @@ -1039,6 +1222,120 @@ public class OrganizationUserRepositoryTests Assert.Empty(responseModel); } + [DatabaseTheory, DatabaseData] + public async Task DeleteAsync_WithNullEmail_DoesNotSetDefaultUserCollectionEmail(IUserRepository userRepository, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository + ) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = user.Email, + Plan = "Test", + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Email = null + }); + + var defaultUserCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Test Collection", + Id = user.Id, + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }); + + await collectionRepository.UpdateUsersAsync(defaultUserCollection.Id, new List() + { + new CollectionAccessSelection + { + Id = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }, + }); + + await organizationUserRepository.DeleteAsync(orgUser); + + var updatedCollection = await collectionRepository.GetByIdAsync(defaultUserCollection.Id); + Assert.NotNull(updatedCollection); + Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type); + Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteAsync_WithEmptyEmail_DoesNotSetDefaultUserCollectionEmail(IUserRepository userRepository, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository + ) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = user.Email, + Plan = "Test", + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Email = "" // Empty string email + }); + + var defaultUserCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Test Collection", + Id = user.Id, + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }); + + await collectionRepository.UpdateUsersAsync(defaultUserCollection.Id, new List() + { + new CollectionAccessSelection + { + Id = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }, + }); + + await organizationUserRepository.DeleteAsync(orgUser); + + var updatedCollection = await collectionRepository.GetByIdAsync(defaultUserCollection.Id); + Assert.NotNull(updatedCollection); + Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type); + Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail); + } + [DatabaseTheory, DatabaseData] public async Task ReplaceAsync_PreservesDefaultCollections_WhenUpdatingCollectionAccess( IUserRepository userRepository, diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs index d4606ae632..0bf0909a0a 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs @@ -55,7 +55,7 @@ public class UserRepositoryTests var organization = await organizationRepository.CreateAsync(new Organization { Name = "Test Org", - BillingEmail = user3.Email, // TODO: EF does not enfore this being NOT NULL + BillingEmail = user3.Email, // TODO: EF does not enforce this being NOT NULL Plan = "Test", // TODO: EF does not enforce this being NOT NULl }); diff --git a/util/Migrator/DbScripts/2025-08-04-00_OrgUsers_MigrateDefaultCollection.sql b/util/Migrator/DbScripts/2025-08-04-00_OrgUsers_MigrateDefaultCollection.sql new file mode 100644 index 0000000000..5ad83967e0 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-04-00_OrgUsers_MigrateDefaultCollection.sql @@ -0,0 +1,22 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_MigrateDefaultCollection] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + DECLARE @UtcNow DATETIME2(7) = GETUTCDATE(); + + UPDATE c + SET + [DefaultUserCollectionEmail] = CASE WHEN c.[DefaultUserCollectionEmail] IS NULL THEN u.[Email] ELSE c.[DefaultUserCollectionEmail] END, + [RevisionDate] = @UtcNow, + [Type] = 0 + FROM + [dbo].[Collection] c + INNER JOIN [dbo].[CollectionUser] cu ON c.[Id] = cu.[CollectionId] + INNER JOIN [dbo].[OrganizationUser] ou ON cu.[OrganizationUserId] = ou.[Id] + INNER JOIN [dbo].[User] u ON ou.[UserId] = u.[Id] + INNER JOIN @Ids i ON ou.[Id] = i.[Id] + WHERE + c.[Type] = 1 +END +GO diff --git a/util/Migrator/DbScripts/2025-08-04-01_OrgUsers_DeleteById.sql b/util/Migrator/DbScripts/2025-08-04-01_OrgUsers_DeleteById.sql new file mode 100644 index 0000000000..b8447764a0 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-04-01_OrgUsers_DeleteById.sql @@ -0,0 +1,55 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserId] @Id + + DECLARE @OrganizationId UNIQUEIDENTIFIER + DECLARE @UserId UNIQUEIDENTIFIER + + SELECT + @OrganizationId = [OrganizationId], + @UserId = [UserId] + FROM + [dbo].[OrganizationUser] + WHERE + [Id] = @Id + + -- Migrate DefaultUserCollection to SharedCollection + DECLARE @Ids [dbo].[GuidIdArray] + INSERT INTO @Ids (Id) VALUES (@Id) + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @Ids + + IF @OrganizationId IS NOT NULL AND @UserId IS NOT NULL + BEGIN + EXEC [dbo].[SsoUser_Delete] @UserId, @OrganizationId + END + + DELETE + FROM + [dbo].[CollectionUser] + WHERE + [OrganizationUserId] = @Id + + DELETE + FROM + [dbo].[GroupUser] + WHERE + [OrganizationUserId] = @Id + + DELETE + FROM + [dbo].[AccessPolicy] + WHERE + [OrganizationUserId] = @Id + + EXEC [dbo].[OrganizationSponsorship_OrganizationUserDeleted] @Id + + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [Id] = @Id +END diff --git a/util/Migrator/DbScripts/2025-08-04-02_OrgUsers_DeleteByIds.sql b/util/Migrator/DbScripts/2025-08-04-02_OrgUsers_DeleteByIds.sql new file mode 100644 index 0000000000..9352416d30 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-04-02_OrgUsers_DeleteByIds.sql @@ -0,0 +1,105 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_DeleteByIds] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @Ids + + -- Migrate DefaultCollection to SharedCollection + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @Ids + + DECLARE @UserAndOrganizationIds [dbo].[TwoGuidIdArray] + + INSERT INTO @UserAndOrganizationIds + (Id1, Id2) + SELECT + UserId, + OrganizationId + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids OUIds ON OUIds.Id = OU.Id + WHERE + UserId IS NOT NULL AND + OrganizationId IS NOT NULL + + BEGIN + EXEC [dbo].[SsoUser_DeleteMany] @UserAndOrganizationIds + END + + DECLARE @BatchSize INT = 100 + + -- Delete CollectionUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionUser_DeleteMany_CUs + + DELETE TOP(@BatchSize) CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + @Ids I ON I.Id = CU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION CollectionUser_DeleteMany_CUs + END + + SET @BatchSize = 100; + + -- Delete GroupUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION GroupUser_DeleteMany_GroupUsers + + DELETE TOP(@BatchSize) GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + @Ids I ON I.Id = GU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION GroupUser_DeleteMany_GroupUsers + END + + SET @BatchSize = 100; + + -- Delete User Access Policies + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION AccessPolicy_DeleteMany_Users + + DELETE TOP(@BatchSize) AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + @Ids I ON I.Id = AP.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION AccessPolicy_DeleteMany_Users + END + + EXEC [dbo].[OrganizationSponsorship_OrganizationUsersDeleted] @Ids + + SET @BatchSize = 100; + + -- Delete OrganizationUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION OrganizationUser_DeleteMany_OUs + + DELETE TOP(@BatchSize) OU + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids I ON I.Id = OU.Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION OrganizationUser_DeleteMany_OUs + END +END +GO