1
0
mirror of https://github.com/bitwarden/server synced 2026-02-27 18:03:17 +00:00
Files
server/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs
Jared d17d43cf7b [PM-19232] Implement externalId handling in PatchUserCommand with validation (#6998)
* Implement externalId handling in PatchUserCommand with validation and tests

* Change back for testing because we don't want to potentially stop code flow...

* Refactor PatchUserCommand and related tests to log warnings for unsupported operations instead of throwing exceptions. Update method names for clarity and adjust assertions in test cases accordingly.

* Refactor PatchUserCommand to streamline handling of active and externalId properties from value objects, consolidating logic for improved clarity and maintainability.

* Update bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* Fix formatting issue in PatchUserCommandTests.cs by removing invisible characters and ensuring proper code structure.

* Enhance PatchUserCommand to re-fetch user status after restore/revoke operations, ensuring accurate updates. Add corresponding test case to verify behavior when restoring users and updating externalId.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-26 17:44:11 -05:00

469 lines
19 KiB
C#

using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Models;
using Bit.Scim.Users;
using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Scim.Test.Users;
[SutProviderCustomize]
public class PatchUserCommandTests
{
[Theory]
[BitAutoData]
public async Task PatchUser_RestorePath_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
organizationUser.Status = Core.Enums.OrganizationUserStatusType.Revoked;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "active",
Value = JsonDocument.Parse("true").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchUser_RestoreValue_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
organizationUser.Status = Core.Enums.OrganizationUserStatusType.Revoked;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse("{\"active\":true}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchUser_RevokePath_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
organizationUser.Status = Core.Enums.OrganizationUserStatusType.Confirmed;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "active",
Value = JsonDocument.Parse("false").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchUser_RevokeValue_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
organizationUser.Status = Core.Enums.OrganizationUserStatusType.Confirmed;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse("{\"active\":false}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchUser_NoAction_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchUser_NotFound_Throws(SutProvider<PatchUserCommand> sutProvider, Guid organizationId, Guid organizationUserId)
{
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchUserAsync(organizationId, organizationUserId, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchUser_MismatchingOrganizationId_Throws(SutProvider<PatchUserCommand> sutProvider, Guid organizationId, Guid organizationUserId)
{
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUserId)
.Returns(new OrganizationUser
{
Id = organizationUserId,
OrganizationId = Guid.NewGuid()
});
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchUserAsync(organizationId, organizationUserId, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchUser_ExternalIdFromPath_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
var newExternalId = "new-external-id-123";
organizationUser.ExternalId = "old-external-id";
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(organizationUser.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>());
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "externalId",
Value = JsonDocument.Parse($"\"{newExternalId}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).ReplaceAsync(
Arg.Is<OrganizationUser>(ou => ou.ExternalId == newExternalId));
}
[Theory]
[BitAutoData]
public async Task PatchUser_ExternalIdFromValue_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
var newExternalId = "new-external-id-456";
organizationUser.ExternalId = null;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(organizationUser.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>());
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"externalId\":\"{newExternalId}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).ReplaceAsync(
Arg.Is<OrganizationUser>(ou => ou.ExternalId == newExternalId));
}
[Theory]
[BitAutoData]
public async Task PatchUser_ExternalIdDuplicate_ThrowsConflict(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser, OrganizationUserUserDetails existingUser)
{
var duplicateExternalId = "duplicate-id";
organizationUser.ExternalId = "old-id";
existingUser.ExternalId = duplicateExternalId;
existingUser.Id = Guid.NewGuid(); // Different user
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(organizationUser.OrganizationId)
.Returns(new List<OrganizationUserUserDetails> { existingUser });
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "externalId",
Value = JsonDocument.Parse($"\"{duplicateExternalId}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<ConflictException>(async () =>
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchUser_ExternalIdTooLong_ThrowsBadRequest(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
var tooLongExternalId = new string('a', 301); // Exceeds 300 character limit
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "externalId",
Value = JsonDocument.Parse($"\"{tooLongExternalId}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchUser_ExternalIdNull_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
organizationUser.ExternalId = "existing-id";
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(organizationUser.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>());
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "externalId",
Value = JsonDocument.Parse("null").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).ReplaceAsync(
Arg.Is<OrganizationUser>(ou => ou.ExternalId == null));
}
[Theory]
[BitAutoData]
public async Task PatchUser_UnsupportedOperation_LogsWarningAndSucceeds(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = "displayName",
Value = JsonDocument.Parse("\"John Doe\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
// Should not throw - unsupported operations are logged as warnings but don't fail the request
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
// Verify no restore or revoke operations were called
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchUser_ActiveAndExternalIdFromValue_Success(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
var newExternalId = "combined-test-id";
organizationUser.Status = OrganizationUserStatusType.Confirmed;
organizationUser.ExternalId = "old-id";
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(organizationUser.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>());
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"active\":false,\"externalId\":\"{newExternalId}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
// Verify both operations were processed
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).ReplaceAsync(
Arg.Is<OrganizationUser>(ou => ou.ExternalId == newExternalId));
}
[Theory]
[BitAutoData]
public async Task PatchUser_RestoreAndExternalIdFromValue_DoesNotRevertRestore(SutProvider<PatchUserCommand> sutProvider, OrganizationUser organizationUser)
{
var newExternalId = "combined-restore-id";
organizationUser.Status = OrganizationUserStatusType.Revoked;
organizationUser.ExternalId = "old-id";
// Simulate the re-fetch after restore returning a user with a non-revoked status
var restoredOrgUser = new OrganizationUser
{
Id = organizationUser.Id,
OrganizationId = organizationUser.OrganizationId,
Status = OrganizationUserStatusType.Confirmed,
ExternalId = organizationUser.ExternalId,
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser, restoredOrgUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(organizationUser.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>());
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"active\":true,\"externalId\":\"{newExternalId}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
// ReplaceAsync must use the re-fetched (restored) user, not the stale revoked state
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).ReplaceAsync(
Arg.Is<OrganizationUser>(ou => ou.ExternalId == newExternalId && ou.Status != OrganizationUserStatusType.Revoked));
}
}