1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-26632] - Adding Idempotent Confirm User (#6459)

* Added repo call for idempotent user confirm. PLUS TESTS!

* Code review changes
This commit is contained in:
Jared McCannon
2025-10-16 11:19:48 -05:00
committed by GitHub
parent 132db95fb7
commit 449603d180
7 changed files with 284 additions and 7 deletions

View File

@@ -87,4 +87,13 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection); Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
/// <summary>
/// It will only confirm if the user is in the `Accepted` state.
///
/// This is an idempotent operation.
/// </summary>
/// <param name="organizationUser">Accepted OrganizationUser to confirm</param>
/// <returns>True, if the user was updated. False, if not performed.</returns>
Task<bool> ConfirmOrganizationUserAsync(OrganizationUser organizationUser);
} }

View File

@@ -15,8 +15,6 @@ using Dapper;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
#nullable enable
namespace Bit.Infrastructure.Dapper.Repositories; namespace Bit.Infrastructure.Dapper.Repositories;
public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IOrganizationUserRepository public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IOrganizationUserRepository
@@ -672,4 +670,20 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<bool> ConfirmOrganizationUserAsync(OrganizationUser organizationUser)
{
await using var connection = new SqlConnection(_marsConnectionString);
var rowCount = await connection.ExecuteScalarAsync<int>(
$"[{Schema}].[OrganizationUser_ConfirmById]",
new
{
organizationUser.Id,
organizationUser.UserId,
RevisionDate = DateTime.UtcNow.Date
});
return rowCount > 0;
}
} }

View File

@@ -942,4 +942,24 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
} }
public async Task<bool> ConfirmOrganizationUserAsync(Core.Entities.OrganizationUser organizationUser)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var dbContext = GetDatabaseContext(scope);
var result = await dbContext.OrganizationUsers
.Where(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted)
.ExecuteUpdateAsync(x =>
x.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed));
if (result <= 0)
{
return false;
}
await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdAsync(organizationUser.Id);
return true;
}
} }

View File

@@ -0,0 +1,28 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ConfirmById]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@RevisionDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
DECLARE @RowCount INT;
UPDATE
[dbo].[OrganizationUser]
SET
[Status] = 2, -- Set to Confirmed
[RevisionDate] = @RevisionDate
WHERE
[Id] = @Id
AND [Status] = 1 -- Only update if status is Accepted
SET @RowCount = @@ROWCOUNT;
IF @RowCount > 0
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
SELECT @RowCount;
END

View File

@@ -69,6 +69,42 @@ public static class OrganizationTestHelpers
Type = OrganizationUserType.Owner Type = OrganizationUserType.Owner
}); });
public static Task<OrganizationUser> CreateAcceptedTestOrganizationUserAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization,
User user)
=> organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Accepted,
Type = OrganizationUserType.Owner
});
public static Task<OrganizationUser> CreateRevokedTestOrganizationUserAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization,
User user)
=> organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Revoked,
Type = OrganizationUserType.Owner
});
public static Task<OrganizationUser> CreateConfirmedTestOrganizationUserAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization,
User user)
=> organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner
});
public static Task<Group> CreateTestGroupAsync( public static Task<Group> CreateTestGroupAsync(
this IGroupRepository groupRepository, this IGroupRepository groupRepository,
Organization organization, Organization organization,
@@ -81,9 +117,9 @@ public static class OrganizationTestHelpers
this ICollectionRepository collectionRepository, this ICollectionRepository collectionRepository,
Organization organization, Organization organization,
string identifier = "test") string identifier = "test")
=> collectionRepository.CreateAsync(new Collection => collectionRepository.CreateAsync(new Collection
{ {
OrganizationId = organization.Id, OrganizationId = organization.Id,
Name = $"{identifier} {Guid.NewGuid()}" Name = $"{identifier} {Guid.NewGuid()}"
}); });
} }

View File

@@ -1417,4 +1417,146 @@ public class OrganizationUserRepositoryTests
// Regular collection should be removed // Regular collection should be removed
Assert.DoesNotContain(actualCollections, c => c.Id == regularCollection.Id); Assert.DoesNotContain(actualCollections, c => c.Id == regularCollection.Id);
} }
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsAccepted_ReturnsTrue(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.True(result);
var updatedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(updatedUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsInvited_ReturnsFalse(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.False(result);
var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(unchangedUser);
Assert.Equal(OrganizationUserStatusType.Invited, unchangedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsAlreadyConfirmed_ReturnsFalse(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.False(result);
var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(unchangedUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, unchangedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsRevoked_ReturnsFalse(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.False(result);
var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(unchangedUser);
Assert.Equal(OrganizationUserStatusType.Revoked, unchangedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_IsIdempotent_WhenCalledMultipleTimes(
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
// Act - First call should confirm
var firstResult = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
var secondResult = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.True(firstResult);
Assert.False(secondResult);
var finalUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(finalUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, finalUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserDoesNotExist_ReturnsFalse(
IOrganizationUserRepository organizationUserRepository)
{
// Arrange
var nonExistentUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Email = "nonexistent@bitwarden.com",
Status = OrganizationUserStatusType.Accepted,
Type = OrganizationUserType.Owner
};
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(nonExistentUser);
// Assert
Assert.False(result);
}
} }

View File

@@ -0,0 +1,28 @@
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ConfirmById]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@RevisionDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
DECLARE @RowCount INT;
UPDATE
[dbo].[OrganizationUser]
SET
[Status] = 2, -- Set to Confirmed
[RevisionDate] = @RevisionDate
WHERE
[Id] = @Id
AND [Status] = 1 -- Only update if status is Accepted
SET @RowCount = @@ROWCOUNT;
IF @RowCount > 0
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
SELECT @RowCount;
END