mirror of
https://github.com/bitwarden/server
synced 2026-01-27 06:43:19 +00:00
First pass at semaphore
This commit is contained in:
8
src/Core/Entities/DefaultCollectionSemaphore.cs
Normal file
8
src/Core/Entities/DefaultCollectionSemaphore.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class DefaultCollectionSemaphore
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public Guid OrganizationUserId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -65,10 +65,21 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
|
||||
|
||||
/// <summary>
|
||||
/// Creates default user collections for the specified organization users if they do not already have one.
|
||||
/// Uses the stored procedure approach with semaphore-based duplicate prevention.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The Organization ID.</param>
|
||||
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
|
||||
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
|
||||
/// <returns></returns>
|
||||
Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
|
||||
|
||||
/// <summary>
|
||||
/// Creates default user collections for the specified organization users using bulk insert operations.
|
||||
/// Inserts semaphore entries before collections to prevent duplicates.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The Organization ID.</param>
|
||||
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
|
||||
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
|
||||
/// <returns></returns>
|
||||
Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
|
||||
}
|
||||
|
||||
@@ -368,6 +368,29 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var organizationUserIdsJson = JsonSerializer.Serialize(organizationUserIds);
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[Collection_UpsertDefaultCollections]",
|
||||
new
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
DefaultCollectionName = defaultCollectionName,
|
||||
OrganizationUserIdsJson = organizationUserIdsJson
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
|
||||
{
|
||||
organizationUserIds = organizationUserIds.ToList();
|
||||
if (!organizationUserIds.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
connection.Open();
|
||||
await using var transaction = connection.BeginTransaction();
|
||||
@@ -384,6 +407,17 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Insert semaphore entries BEFORE collections
|
||||
// TODO: this will result in a creation date of the semaphore AFTER that of the collection, which is weird
|
||||
var now = DateTime.UtcNow;
|
||||
var semaphores = collectionUsers.Select(c => new DefaultCollectionSemaphore
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
OrganizationUserId = c.OrganizationUserId,
|
||||
CreationDate = now
|
||||
}).ToList();
|
||||
|
||||
await BulkInsertDefaultCollectionSemaphoresAsync(connection, transaction, semaphores);
|
||||
await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections);
|
||||
await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers);
|
||||
|
||||
@@ -455,6 +489,56 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
return (collectionUsers, collections);
|
||||
}
|
||||
|
||||
private async Task BulkInsertDefaultCollectionSemaphoresAsync(SqlConnection connection, SqlTransaction transaction, List<DefaultCollectionSemaphore> semaphores)
|
||||
{
|
||||
if (!semaphores.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by composite key to reduce deadlocks
|
||||
var sortedSemaphores = semaphores
|
||||
.OrderBy(s => s.OrganizationId)
|
||||
.ThenBy(s => s.OrganizationUserId)
|
||||
.ToList();
|
||||
|
||||
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
|
||||
bulkCopy.DestinationTableName = "[dbo].[DefaultCollectionSemaphore]";
|
||||
bulkCopy.BatchSize = 500;
|
||||
bulkCopy.BulkCopyTimeout = 120;
|
||||
bulkCopy.EnableStreaming = true;
|
||||
|
||||
var dataTable = new DataTable("DefaultCollectionSemaphoreDataTable");
|
||||
|
||||
var organizationIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationId), typeof(Guid));
|
||||
dataTable.Columns.Add(organizationIdColumn);
|
||||
var organizationUserIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationUserId), typeof(Guid));
|
||||
dataTable.Columns.Add(organizationUserIdColumn);
|
||||
var creationDateColumn = new DataColumn(nameof(DefaultCollectionSemaphore.CreationDate), typeof(DateTime));
|
||||
dataTable.Columns.Add(creationDateColumn);
|
||||
|
||||
foreach (DataColumn col in dataTable.Columns)
|
||||
{
|
||||
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
|
||||
}
|
||||
|
||||
var keys = new DataColumn[2];
|
||||
keys[0] = organizationIdColumn;
|
||||
keys[1] = organizationUserIdColumn;
|
||||
dataTable.PrimaryKey = keys;
|
||||
|
||||
foreach (var semaphore in sortedSemaphores)
|
||||
{
|
||||
var row = dataTable.NewRow();
|
||||
row[organizationIdColumn] = semaphore.OrganizationId;
|
||||
row[organizationUserIdColumn] = semaphore.OrganizationUserId;
|
||||
row[creationDateColumn] = semaphore.CreationDate;
|
||||
dataTable.Rows.Add(row);
|
||||
}
|
||||
|
||||
await bulkCopy.WriteToServerAsync(dataTable);
|
||||
}
|
||||
|
||||
public class CollectionWithGroupsAndUsers : Collection
|
||||
{
|
||||
public CollectionWithGroupsAndUsers() { }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;
|
||||
|
||||
public class DefaultCollectionSemaphoreEntityTypeConfiguration : IEntityTypeConfiguration<DefaultCollectionSemaphore>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DefaultCollectionSemaphore> builder)
|
||||
{
|
||||
builder
|
||||
.HasKey(dcs => new { dcs.OrganizationId, dcs.OrganizationUserId });
|
||||
|
||||
// Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE)
|
||||
// Organization FK uses NoAction to avoid competing cascade paths
|
||||
builder
|
||||
.HasOne(dcs => dcs.Organization)
|
||||
.WithMany()
|
||||
.HasForeignKey(dcs => dcs.OrganizationId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// OrganizationUser FK cascades deletions to ensure automatic cleanup
|
||||
builder
|
||||
.HasOne(dcs => dcs.OrganizationUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(dcs => dcs.OrganizationUserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.ToTable(nameof(DefaultCollectionSemaphore));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using AutoMapper;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
|
||||
public class DefaultCollectionSemaphore : Core.Entities.DefaultCollectionSemaphore
|
||||
{
|
||||
public virtual Organization? Organization { get; set; }
|
||||
public virtual OrganizationUser? OrganizationUser { get; set; }
|
||||
}
|
||||
|
||||
public class DefaultCollectionSemaphoreMapperProfile : Profile
|
||||
{
|
||||
public DefaultCollectionSemaphoreMapperProfile()
|
||||
{
|
||||
CreateMap<Core.Entities.DefaultCollectionSemaphore, DefaultCollectionSemaphore>()
|
||||
.ForMember(dcs => dcs.Organization, opt => opt.Ignore())
|
||||
.ForMember(dcs => dcs.OrganizationUser, opt => opt.Ignore())
|
||||
.ReverseMap();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
@@ -815,12 +816,29 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Insert semaphore entries BEFORE collections
|
||||
// TODO: this will result in a creation date of the semaphore AFTER that of the collection, which is weird
|
||||
var now = DateTime.UtcNow;
|
||||
var semaphores = collectionUsers.Select(c => new DefaultCollectionSemaphore
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
OrganizationUserId = c.OrganizationUserId,
|
||||
CreationDate = now
|
||||
}).ToList();
|
||||
|
||||
await dbContext.BulkCopyAsync(semaphores);
|
||||
await dbContext.BulkCopyAsync(collections);
|
||||
await dbContext.BulkCopyAsync(collectionUsers);
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
|
||||
{
|
||||
// EF uses the same bulk copy approach as the main method
|
||||
await UpsertDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
|
||||
}
|
||||
|
||||
private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId)
|
||||
{
|
||||
var results = await dbContext.OrganizationUsers
|
||||
|
||||
@@ -17,8 +17,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using DP = Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
public class DatabaseContext : DbContext
|
||||
@@ -45,6 +43,7 @@ public class DatabaseContext : DbContext
|
||||
public DbSet<CollectionCipher> CollectionCiphers { get; set; }
|
||||
public DbSet<CollectionGroup> CollectionGroups { get; set; }
|
||||
public DbSet<CollectionUser> CollectionUsers { get; set; }
|
||||
public DbSet<DefaultCollectionSemaphore> DefaultCollectionSemaphores { get; set; }
|
||||
public DbSet<Device> Devices { get; set; }
|
||||
public DbSet<EmergencyAccess> EmergencyAccesses { get; set; }
|
||||
public DbSet<Event> Events { get; set; }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Creates default user collections for organization users
|
||||
-- Uses semaphore table to prevent duplicate default collections at database level
|
||||
-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE)
|
||||
-- Organization FK uses NoAction to avoid competing cascade paths
|
||||
CREATE PROCEDURE [dbo].[Collection_UpsertDefaultCollections]
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@DefaultCollectionName VARCHAR(MAX),
|
||||
@OrganizationUserIdsJson NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
-- Parse JSON once into table variable with pre-generated collection IDs
|
||||
DECLARE @OrganizationUserIds TABLE
|
||||
(
|
||||
[OrganizationUserId] UNIQUEIDENTIFIER,
|
||||
[CollectionId] UNIQUEIDENTIFIER
|
||||
);
|
||||
|
||||
INSERT INTO @OrganizationUserIds
|
||||
(
|
||||
[OrganizationUserId],
|
||||
[CollectionId]
|
||||
)
|
||||
SELECT
|
||||
CAST([value] AS UNIQUEIDENTIFIER),
|
||||
NEWID()
|
||||
FROM
|
||||
OPENJSON(@OrganizationUserIdsJson);
|
||||
|
||||
-- Insert semaphore entries first to obtain the "lock"
|
||||
INSERT INTO [dbo].[DefaultCollectionSemaphore]
|
||||
(
|
||||
[OrganizationId],
|
||||
[OrganizationUserId],
|
||||
[CreationDate]
|
||||
)
|
||||
SELECT
|
||||
@OrganizationId,
|
||||
ou.[OrganizationUserId],
|
||||
GETUTCDATE()
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
|
||||
-- Insert collections for users who obtained semaphore entries
|
||||
INSERT INTO [dbo].[Collection]
|
||||
(
|
||||
[Id],
|
||||
[OrganizationId],
|
||||
[Name],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[Type],
|
||||
[ExternalId],
|
||||
[DefaultUserCollectionEmail]
|
||||
)
|
||||
SELECT
|
||||
ou.[CollectionId],
|
||||
@OrganizationId,
|
||||
@DefaultCollectionName,
|
||||
GETUTCDATE(),
|
||||
GETUTCDATE(),
|
||||
1, -- CollectionType.DefaultUserCollection
|
||||
NULL,
|
||||
NULL
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
|
||||
-- Insert collection user mappings
|
||||
INSERT INTO [dbo].[CollectionUser]
|
||||
(
|
||||
[CollectionId],
|
||||
[OrganizationUserId],
|
||||
[ReadOnly],
|
||||
[HidePasswords],
|
||||
[Manage]
|
||||
)
|
||||
SELECT
|
||||
ou.[CollectionId],
|
||||
ou.[OrganizationUserId],
|
||||
0, -- ReadOnly = false
|
||||
0, -- HidePasswords = false
|
||||
1 -- Manage = true
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
END
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Semaphore table to prevent duplicate default collections per organization user
|
||||
-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE)
|
||||
-- OrganizationId FK has NO ACTION to avoid competing cascade paths
|
||||
CREATE TABLE [dbo].[DefaultCollectionSemaphore]
|
||||
(
|
||||
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[OrganizationUserId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationId] ASC, [OrganizationUserId] ASC),
|
||||
CONSTRAINT [FK_DefaultCollectionSemaphore_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), -- NO ACTION to avoid competing cascades
|
||||
CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE -- Cascades from OrganizationUser deletion
|
||||
);
|
||||
@@ -0,0 +1,378 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
|
||||
|
||||
public class CollectionRepositoryUpsertDefaultCollectionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test that UpsertDefaultCollectionsAsync successfully creates default collections for new users
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsAsync_CreatesDefaultCollections_Success(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationUserRepository organizationUserRepository)
|
||||
{
|
||||
// Arrange
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 1",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user1.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
});
|
||||
|
||||
var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user2.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
});
|
||||
|
||||
// Act
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
new[] { orgUser1.Id, orgUser2.Id },
|
||||
"My Items");
|
||||
|
||||
// Assert
|
||||
var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id);
|
||||
var defaultCollections = collections.Where(c => c.Type == CollectionType.DefaultUserCollection).ToList();
|
||||
|
||||
Assert.Equal(2, defaultCollections.Count);
|
||||
Assert.All(defaultCollections, c => Assert.Equal("My Items", c.Name));
|
||||
Assert.All(defaultCollections, c => Assert.Equal(organization.Id, c.OrganizationId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that calling UpsertDefaultCollectionsAsync multiple times does NOT create duplicates
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsAsync_CalledMultipleTimes_DoesNotCreateDuplicates(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationUserRepository organizationUserRepository)
|
||||
{
|
||||
// Arrange
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
});
|
||||
|
||||
// Act - Call twice
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
new[] { orgUser.Id },
|
||||
"My Items");
|
||||
|
||||
// Second call should not create duplicate
|
||||
await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
new[] { orgUser.Id },
|
||||
"My Items"));
|
||||
|
||||
// Assert - Only one collection should exist
|
||||
var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id);
|
||||
var defaultCollections = collections.Where(c => c.Type == CollectionType.DefaultUserCollection).ToList();
|
||||
|
||||
Assert.Single(defaultCollections);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that UpsertDefaultCollectionsBulkAsync creates semaphores before collections
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsBulkAsync_CreatesSemaphoresBeforeCollections_Success(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
DatabaseContext databaseContext)
|
||||
{
|
||||
// Arrange
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
});
|
||||
|
||||
// Act
|
||||
await collectionRepository.UpsertDefaultCollectionsBulkAsync(
|
||||
organization.Id,
|
||||
new[] { orgUser.Id },
|
||||
"My Items");
|
||||
|
||||
// Assert - Verify semaphore was created
|
||||
var semaphore = await databaseContext.DefaultCollectionSemaphores
|
||||
.FirstOrDefaultAsync(s => s.OrganizationId == organization.Id && s.OrganizationUserId == orgUser.Id);
|
||||
|
||||
Assert.NotNull(semaphore);
|
||||
Assert.Equal(organization.Id, semaphore.OrganizationId);
|
||||
Assert.Equal(orgUser.Id, semaphore.OrganizationUserId);
|
||||
|
||||
// Verify collection was created
|
||||
var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id);
|
||||
var defaultCollections = collections.Where(c => c.Type == CollectionType.DefaultUserCollection).ToList();
|
||||
|
||||
Assert.Single(defaultCollections);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that deleting an OrganizationUser cascades to DefaultCollectionSemaphore
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task DeleteOrganizationUser_CascadesToSemaphore_Success(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
DatabaseContext databaseContext)
|
||||
{
|
||||
// Arrange
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
});
|
||||
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
new[] { orgUser.Id },
|
||||
"My Items");
|
||||
|
||||
// Verify semaphore exists
|
||||
var semaphoreBefore = await databaseContext.DefaultCollectionSemaphores
|
||||
.FirstOrDefaultAsync(s => s.OrganizationUserId == orgUser.Id);
|
||||
Assert.NotNull(semaphoreBefore);
|
||||
|
||||
// Act - Delete organization user
|
||||
await organizationUserRepository.DeleteAsync(orgUser);
|
||||
|
||||
// Assert - Semaphore should be cascade deleted
|
||||
var semaphoreAfter = await databaseContext.DefaultCollectionSemaphores
|
||||
.FirstOrDefaultAsync(s => s.OrganizationUserId == orgUser.Id);
|
||||
Assert.Null(semaphoreAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that deleting an Organization cascades through OrganizationUser to DefaultCollectionSemaphore
|
||||
/// Note: Cascade path is Organization -> OrganizationUser -> DefaultCollectionSemaphore (not direct)
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task DeleteOrganization_CascadesThroughOrganizationUser_Success(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
DatabaseContext databaseContext)
|
||||
{
|
||||
// Arrange
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
});
|
||||
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
new[] { orgUser.Id },
|
||||
"My Items");
|
||||
|
||||
// Verify semaphore exists
|
||||
var semaphoreBefore = await databaseContext.DefaultCollectionSemaphores
|
||||
.FirstOrDefaultAsync(s => s.OrganizationId == organization.Id);
|
||||
Assert.NotNull(semaphoreBefore);
|
||||
|
||||
// Act - Delete organization (which cascades to OrganizationUser, which cascades to semaphore)
|
||||
await organizationRepository.DeleteAsync(organization);
|
||||
|
||||
// Assert - Semaphore should be cascade deleted via OrganizationUser
|
||||
var semaphoreAfter = await databaseContext.DefaultCollectionSemaphores
|
||||
.FirstOrDefaultAsync(s => s.OrganizationId == organization.Id);
|
||||
Assert.Null(semaphoreAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that UpsertDefaultCollectionsAsync with empty user list does nothing
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsAsync_WithEmptyList_DoesNothing(
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
// Arrange
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
Array.Empty<Guid>(),
|
||||
"My Items");
|
||||
|
||||
// Assert - No collections should be created
|
||||
var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id);
|
||||
Assert.Empty(collections);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that UpsertDefaultCollectionsAsync creates CollectionUser entries with correct permissions
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsAsync_CreatesCollectionUsersWithCorrectPermissions(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationUserRepository organizationUserRepository)
|
||||
{
|
||||
// Arrange
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Org",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Plan = "Test Plan",
|
||||
BillingEmail = "billing@email.com"
|
||||
});
|
||||
|
||||
var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
});
|
||||
|
||||
// Act
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
new[] { orgUser.Id },
|
||||
"My Items");
|
||||
|
||||
// Assert
|
||||
var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id);
|
||||
var defaultCollection = collections.First(c => c.Type == CollectionType.DefaultUserCollection);
|
||||
|
||||
var collectionUsers = await collectionRepository.GetManyUsersByIdAsync(defaultCollection.Id);
|
||||
var collectionUser = collectionUsers.Single();
|
||||
|
||||
Assert.Equal(orgUser.Id, collectionUser.Id);
|
||||
Assert.False(collectionUser.ReadOnly);
|
||||
Assert.False(collectionUser.HidePasswords);
|
||||
Assert.True(collectionUser.Manage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Create DefaultCollectionSemaphore table
|
||||
-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE)
|
||||
-- OrganizationId FK has NO ACTION to avoid competing cascade paths
|
||||
IF OBJECT_ID('[dbo].[DefaultCollectionSemaphore]') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE [dbo].[DefaultCollectionSemaphore]
|
||||
(
|
||||
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[OrganizationUserId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[CreationDate] DATETIME2(7) NOT NULL,
|
||||
CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED
|
||||
(
|
||||
[OrganizationId] ASC,
|
||||
[OrganizationUserId] ASC
|
||||
),
|
||||
CONSTRAINT [FK_DefaultCollectionSemaphore_Organization] FOREIGN KEY ([OrganizationId])
|
||||
REFERENCES [dbo].[Organization] ([Id]), -- NO ACTION to avoid competing cascades
|
||||
CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId])
|
||||
REFERENCES [dbo].[OrganizationUser] ([Id])
|
||||
ON DELETE CASCADE -- Cascades from OrganizationUser deletion
|
||||
);
|
||||
END
|
||||
GO
|
||||
@@ -0,0 +1,83 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Collection_UpsertDefaultCollections]
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@DefaultCollectionName VARCHAR(MAX),
|
||||
@OrganizationUserIdsJson NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
-- Parse JSON once into table variable with pre-generated collection IDs
|
||||
DECLARE @OrganizationUserIds TABLE
|
||||
(
|
||||
[OrganizationUserId] UNIQUEIDENTIFIER,
|
||||
[CollectionId] UNIQUEIDENTIFIER
|
||||
);
|
||||
|
||||
INSERT INTO @OrganizationUserIds
|
||||
(
|
||||
[OrganizationUserId],
|
||||
[CollectionId]
|
||||
)
|
||||
SELECT
|
||||
CAST([value] AS UNIQUEIDENTIFIER),
|
||||
NEWID()
|
||||
FROM
|
||||
OPENJSON(@OrganizationUserIdsJson);
|
||||
|
||||
-- Insert semaphore entries first to obtain the "lock"
|
||||
INSERT INTO [dbo].[DefaultCollectionSemaphore]
|
||||
(
|
||||
[OrganizationId],
|
||||
[OrganizationUserId],
|
||||
[CreationDate]
|
||||
)
|
||||
SELECT
|
||||
@OrganizationId,
|
||||
ou.[OrganizationUserId],
|
||||
GETUTCDATE()
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
|
||||
-- Insert collections for users who obtained semaphore entries
|
||||
INSERT INTO [dbo].[Collection]
|
||||
(
|
||||
[Id],
|
||||
[OrganizationId],
|
||||
[Name],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[Type],
|
||||
[ExternalId],
|
||||
[DefaultUserCollectionEmail]
|
||||
)
|
||||
SELECT
|
||||
ou.[CollectionId],
|
||||
@OrganizationId,
|
||||
@DefaultCollectionName,
|
||||
GETUTCDATE(),
|
||||
GETUTCDATE(),
|
||||
1, -- CollectionType.DefaultUserCollection
|
||||
NULL,
|
||||
NULL
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
|
||||
-- Insert collection user mappings
|
||||
INSERT INTO [dbo].[CollectionUser]
|
||||
(
|
||||
[CollectionId],
|
||||
[OrganizationUserId],
|
||||
[ReadOnly],
|
||||
[HidePasswords],
|
||||
[Manage]
|
||||
)
|
||||
SELECT
|
||||
ou.[CollectionId],
|
||||
ou.[OrganizationUserId],
|
||||
0, -- ReadOnly = false
|
||||
0, -- HidePasswords = false
|
||||
1 -- Manage = true
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
END
|
||||
GO
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Populate DefaultCollectionSemaphore from existing Type=1 (DefaultUserCollection) collections
|
||||
-- This migration is idempotent and can be run multiple times safely
|
||||
INSERT INTO [dbo].[DefaultCollectionSemaphore]
|
||||
(
|
||||
[OrganizationId],
|
||||
[OrganizationUserId],
|
||||
[CreationDate]
|
||||
)
|
||||
SELECT DISTINCT
|
||||
c.[OrganizationId],
|
||||
cu.[OrganizationUserId],
|
||||
c.[CreationDate]
|
||||
FROM
|
||||
[dbo].[Collection] c
|
||||
INNER JOIN
|
||||
[dbo].[CollectionUser] cu ON c.[Id] = cu.[CollectionId]
|
||||
WHERE
|
||||
c.[Type] = 1 -- CollectionType.DefaultUserCollection
|
||||
AND NOT EXISTS
|
||||
(
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
[dbo].[DefaultCollectionSemaphore] dcs
|
||||
WHERE
|
||||
dcs.[OrganizationId] = c.[OrganizationId]
|
||||
AND dcs.[OrganizationUserId] = cu.[OrganizationUserId]
|
||||
);
|
||||
GO
|
||||
Reference in New Issue
Block a user