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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()}"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
util/Migrator/DbScripts/2025-10-15_00_OrgUserConfirmById.sql
Normal file
28
util/Migrator/DbScripts/2025-10-15_00_OrgUserConfirmById.sql
Normal 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
|
||||||
Reference in New Issue
Block a user