Move new sproc to separate PR

This commit is contained in:
Thomas Rittson
2025-12-27 12:54:06 +10:00
parent 1f931bd615
commit ce7d727a9c
11 changed files with 60 additions and 412 deletions

View File

@@ -2,7 +2,9 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -77,10 +79,20 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
return;
}
await collectionRepository.UpsertDefaultCollectionAsync(
request.Organization!.Id,
request.OrganizationUser!.Id,
request.DefaultUserCollectionName);
await collectionRepository.CreateAsync(
new Collection
{
OrganizationId = request.Organization!.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection,
DefaultCollectionOwnerId = request.OrganizationUserId
},
groups: null,
[new CollectionAccessSelection
{
Id = request.OrganizationUser!.Id,
Manage = true
}]);
}
catch (Exception ex)
{

View File

@@ -12,6 +12,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -296,10 +297,22 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
await _collectionRepository.UpsertDefaultCollectionAsync(
organizationUser.OrganizationId,
organizationUser.Id,
defaultUserCollectionName);
var defaultCollection = new Collection
{
OrganizationId = organizationUser.OrganizationId,
Name = defaultUserCollectionName,
Type = CollectionType.DefaultUserCollection,
DefaultCollectionOwnerId = organizationUser.Id
};
var collectionUser = new CollectionAccessSelection
{
Id = organizationUser.Id,
ReadOnly = false,
HidePasswords = false,
Manage = true
};
await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
}
/// <summary>

View File

@@ -71,14 +71,4 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
/// <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 a default user collection for the specified organization user if they do not already have one.
/// This operation is idempotent - calling it multiple times will not create duplicate collections.
/// </summary>
/// <param name="organizationId">The Organization ID.</param>
/// <param name="organizationUserId">The Organization User ID to create/find a default collection for.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns>True if a new collection was created; false if the user already had a default collection.</returns>
Task<bool> UpsertDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName);
}

View File

@@ -396,30 +396,6 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
}
}
public async Task<bool> UpsertDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName)
{
using (var connection = new SqlConnection(ConnectionString))
{
var collectionId = CoreHelpers.GenerateComb();
var now = DateTime.UtcNow;
var parameters = new DynamicParameters();
parameters.Add("@CollectionId", collectionId);
parameters.Add("@OrganizationId", organizationId);
parameters.Add("@OrganizationUserId", organizationUserId);
parameters.Add("@Name", defaultCollectionName);
parameters.Add("@CreationDate", now);
parameters.Add("@RevisionDate", now);
parameters.Add("@WasCreated", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await connection.ExecuteAsync(
$"[{Schema}].[Collection_UpsertDefaultCollection]",
parameters,
commandType: CommandType.StoredProcedure);
return parameters.Get<bool>("@WasCreated");
}
}
private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(SqlConnection connection, SqlTransaction transaction, Guid organizationId)
{
const string sql = @"

View File

@@ -821,74 +821,6 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
await dbContext.SaveChangesAsync();
}
public async Task<bool> UpsertDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
try
{
// Create new default collection
var collectionId = CoreHelpers.GenerateComb();
var now = DateTime.UtcNow;
var collection = new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
ExternalId = null,
CreationDate = now,
RevisionDate = now,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null,
DefaultCollectionOwnerId = organizationUserId
};
var collectionUser = new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = organizationUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true
};
await dbContext.Collections.AddAsync(collection);
await dbContext.CollectionUsers.AddAsync(collectionUser);
await dbContext.SaveChangesAsync();
// Bump user account revision dates
await dbContext.UserBumpAccountRevisionDateByCollectionIdAsync(collectionId, organizationId);
await dbContext.SaveChangesAsync();
return true;
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
// Collection already exists, return false
return false;
}
}
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
switch (ex.InnerException)
{
// Check if the inner exception is a SQL Server unique constraint violation (error 2601 or 2627)
case Microsoft.Data.SqlClient.SqlException { Number: 2601 or 2627 }:
// Check if the inner exception is a PostgreSQL unique constraint violation (SQLSTATE 23505)
case Npgsql.PostgresException { SqlState: "23505" }:
// Check if the inner exception is a SQLite unique constraint violation (SQLITE_CONSTRAINT = 19)
case Microsoft.Data.Sqlite.SqliteException { SqliteErrorCode: 19 }:
// Check if the inner exception is a MySQL unique constraint violation (ER_DUP_ENTRY = 1062)
case MySqlConnector.MySqlException { ErrorCode: MySqlConnector.MySqlErrorCode.DuplicateKeyEntry }:
return true;
default:
return false;
}
}
private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId)
{
var results = await dbContext.OrganizationUsers

View File

@@ -1,87 +0,0 @@
-- This procedure prevents duplicate "My Items" collections for users using
-- a filtered unique constraint on (DefaultCollectionOwnerId, OrganizationId, Type) WHERE Type = 1.
CREATE PROCEDURE [dbo].[Collection_UpsertDefaultCollection]
@CollectionId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@OrganizationUserId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@WasCreated BIT OUTPUT
AS
BEGIN
SET NOCOUNT ON
BEGIN TRANSACTION
BEGIN TRY
SET @WasCreated = 1
-- Insert Collection with DefaultCollectionOwnerId populated for constraint enforcement
INSERT INTO [dbo].[Collection]
(
[Id],
[OrganizationId],
[Name],
[ExternalId],
[CreationDate],
[RevisionDate],
[DefaultUserCollectionEmail],
[Type],
[DefaultCollectionOwnerId]
)
VALUES
(
@CollectionId,
@OrganizationId,
@Name,
NULL, -- ExternalId
@CreationDate,
@RevisionDate,
NULL, -- DefaultUserCollectionEmail
1, -- CollectionType.DefaultUserCollection
@OrganizationUserId
)
-- Insert CollectionUser
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@CollectionId,
@OrganizationUserId,
0, -- ReadOnly = false
0, -- HidePasswords = false
1 -- Manage = true
)
-- Bump user account revision dates
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @CollectionId, @OrganizationId
COMMIT TRANSACTION
END TRY
BEGIN CATCH
-- Check if error is unique constraint violation (error 2601 or 2627)
IF ERROR_NUMBER() IN (2601, 2627)
BEGIN
-- Collection already exists, return gracefully
SET @WasCreated = 0
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION
END
ELSE
BEGIN
-- Unexpected error, rollback and re-throw
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION
THROW
END
END CATCH
END

View File

@@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -202,10 +203,15 @@ public class AutomaticallyConfirmUsersCommandTests
await sutProvider.GetDependency<ICollectionRepository>()
.Received(1)
.UpsertDefaultCollectionAsync(
organization.Id,
organizationUser.Id,
defaultCollectionName);
.CreateAsync(
Arg.Is<Collection>(c =>
c.OrganizationId == organization.Id &&
c.Name == defaultCollectionName &&
c.Type == CollectionType.DefaultUserCollection &&
c.DefaultCollectionOwnerId == request.OrganizationUserId),
Arg.Is<IEnumerable<CollectionAccessSelection>>(groups => groups == null),
Arg.Is<IEnumerable<CollectionAccessSelection>>(access =>
access.FirstOrDefault(x => x.Id == organizationUser.Id && x.Manage) != null));
}
[Theory]
@@ -247,10 +253,9 @@ public class AutomaticallyConfirmUsersCommandTests
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.UpsertDefaultCollectionAsync(
Arg.Any<Guid>(),
Arg.Any<Guid>(),
Arg.Any<string>());
.CreateAsync(Arg.Any<Collection>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>());
}
[Theory]
@@ -286,10 +291,9 @@ public class AutomaticallyConfirmUsersCommandTests
var collectionException = new Exception("Collection creation failed");
sutProvider.GetDependency<ICollectionRepository>()
.UpsertDefaultCollectionAsync(
Arg.Any<Guid>(),
Arg.Any<Guid>(),
Arg.Any<string>())
.CreateAsync(Arg.Any<Collection>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>())
.ThrowsAsync(collectionException);
// Act

View File

@@ -12,6 +12,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
@@ -493,10 +494,16 @@ public class ConfirmOrganizationUserCommandTests
await sutProvider.GetDependency<ICollectionRepository>()
.Received(1)
.UpsertDefaultCollectionAsync(
organization.Id,
orgUser.Id,
collectionName);
.CreateAsync(
Arg.Is<Collection>(c =>
c.Name == collectionName &&
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection &&
c.DefaultCollectionOwnerId == orgUser.Id),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Is<IEnumerable<CollectionAccessSelection>>(cu =>
cu.Single().Id == orgUser.Id &&
cu.Single().Manage));
}
[Theory, BitAutoData]

View File

@@ -1,110 +0,0 @@
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
public class UpsertDefaultCollectionTests
{
[Theory, DatabaseData]
public async Task UpsertDefaultCollectionAsync_ShouldCreateCollection_WhenUserDoesNotHaveDefaultCollection(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
var defaultCollectionName = $"My Items - {organization.Id}";
// Act
var wasCreated = await collectionRepository.UpsertDefaultCollectionAsync(
organization.Id,
orgUser.Id,
defaultCollectionName);
// Assert
Assert.True(wasCreated);
var collectionDetails = await collectionRepository.GetManyByUserIdAsync(user.Id);
var defaultCollection = collectionDetails.SingleOrDefault(c =>
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection);
Assert.NotNull(defaultCollection);
Assert.Equal(defaultCollectionName, defaultCollection.Name);
Assert.True(defaultCollection.Manage);
Assert.False(defaultCollection.ReadOnly);
Assert.False(defaultCollection.HidePasswords);
}
[Theory, DatabaseData]
public async Task UpsertDefaultCollectionAsync_ShouldReturnFalse_WhenUserAlreadyHasDefaultCollection(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
var defaultCollectionName = $"My Items - {organization.Id}";
// Create initial collection
var firstWasCreated = await collectionRepository.UpsertDefaultCollectionAsync(
organization.Id,
orgUser.Id,
defaultCollectionName);
// Act - Call again with same parameters
var secondWasCreated = await collectionRepository.UpsertDefaultCollectionAsync(
organization.Id,
orgUser.Id,
defaultCollectionName);
// Assert
Assert.True(firstWasCreated);
Assert.False(secondWasCreated);
// Verify only one default collection exists
var collectionDetails = await collectionRepository.GetManyByUserIdAsync(user.Id);
var defaultCollections = collectionDetails.Where(c =>
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection).ToList();
Assert.Single(defaultCollections);
}
[Theory, DatabaseData]
public async Task UpsertDefaultCollectionAsync_ShouldBeIdempotent_WhenCalledMultipleTimes(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
var defaultCollectionName = $"My Items - {organization.Id}";
// Act - Call method 5 times
var tasks = Enumerable.Range(1, 5).Select(i => collectionRepository.UpsertDefaultCollectionAsync(
organization.Id,
orgUser.Id,
defaultCollectionName));
var results = await Task.WhenAll(tasks);
// Assert
Assert.Single(results, r => r); // First call should create successfully; all other results are implicitly false
// Verify only one collection exists
var collectionDetails = await collectionRepository.GetManyByUserIdAsync(user.Id);
Assert.Single(collectionDetails, c =>
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection);
}
}

View File

@@ -1,89 +0,0 @@
-- Create the idempotent stored procedure for creating default collections
-- This procedure prevents duplicate "My Items" collections for users using
-- a filtered unique constraint on (DefaultCollectionOwnerId, OrganizationId, Type) WHERE Type = 1.
CREATE OR ALTER PROCEDURE [dbo].[Collection_UpsertDefaultCollection]
@CollectionId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@OrganizationUserId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@WasCreated BIT OUTPUT
AS
BEGIN
SET NOCOUNT ON
BEGIN TRANSACTION
BEGIN TRY
SET @WasCreated = 1
-- Insert Collection with DefaultCollectionOwnerId populated for constraint enforcement
INSERT INTO [dbo].[Collection]
(
[Id],
[OrganizationId],
[Name],
[ExternalId],
[CreationDate],
[RevisionDate],
[DefaultUserCollectionEmail],
[Type],
[DefaultCollectionOwnerId]
)
VALUES
(
@CollectionId,
@OrganizationId,
@Name,
NULL, -- ExternalId
@CreationDate,
@RevisionDate,
NULL, -- DefaultUserCollectionEmail
1, -- CollectionType.DefaultUserCollection
@OrganizationUserId
)
-- Insert CollectionUser
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@CollectionId,
@OrganizationUserId,
0, -- ReadOnly = false
0, -- HidePasswords = false
1 -- Manage = true
)
-- Bump user account revision dates
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @CollectionId, @OrganizationId
COMMIT TRANSACTION
END TRY
BEGIN CATCH
-- Check if error is unique constraint violation (error 2601 or 2627)
IF ERROR_NUMBER() IN (2601, 2627)
BEGIN
-- Collection already exists, return gracefully
SET @WasCreated = 0
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION
END
ELSE
BEGIN
-- Unexpected error, rollback and re-throw
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION
THROW
END
END CATCH
END
GO