From 8569a17a2326a2d7c90f18130c45ec2ad0130894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Mon, 12 Oct 2026 21:57:34 +0400 Subject: [PATCH 01/34] feat: add built-in roles and user role assignment --- README.md | 14 ++ .../Users/Create/CreateUserCommand.cs | 3 +- .../Create/CreateUserCommandValidator.cs | 8 ++ .../Users/Create/CreateUserHandler.cs | 15 ++- .../Users/GetDetails/UserDetailsReadModel.cs | 2 + .../Users/Update/UpdateUserCommand.cs | 3 +- .../Update/UpdateUserCommandValidator.cs | 8 ++ .../Users/Update/UpdateUserHandler.cs | 18 ++- .../Roles/RoleNames.cs | 10 ++ .../Users/Abstractions/IUsersRepository.cs | 3 +- ...tityProviderServiceCollectionExtensions.cs | 1 + ...yProviderApplicationSeederHostedService.cs | 2 + .../Seeding/IdentityRoleSeeder.cs | 35 +++++ .../Services/UserCredentialsService.cs | 16 +++ .../GetDetails/UserDetailsReadModelMapper.cs | 5 +- .../Features/Users/Write/UsersRepository.cs | 72 ++++++++++- .../Users/Create/CreateUserRequest.cs | 3 +- .../GetDetails/GetUserDetailsResponse.cs | 5 +- .../Users/Update/UpdateUserRequest.cs | 3 +- .../Register/RegisterUserEndpointTests.cs | 1 + .../Roles/GetList/GetRolesEndpointTests.cs | 4 +- .../Features/Users/UserDatabaseTestHelper.cs | 2 + .../Users/UserRoleAssignmentEndpointTests.cs | 121 ++++++++++++++++++ .../Presentation/FunctionalTestFixture.cs | 2 + .../Seeding/IdentityRoleSeederTests.cs | 89 +++++++++++++ .../Services/UserCredentialsServiceTests.cs | 31 +++++ .../Users/Write/UserRoleAssignmentTests.cs | 119 +++++++++++++++++ .../Create/CreateUserCommandValidatorTests.cs | 27 ++++ .../Users/Create/CreateUserHandlerTests.cs | 49 +++++++ .../Update/UpdateUserCommandValidatorTests.cs | 27 ++++ .../Users/Update/UpdateUserHandlerTests.cs | 55 +++++++- 31 files changed, 736 insertions(+), 17 deletions(-) create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs diff --git a/README.md b/README.md index 51c88940..9a8d56c7 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,20 @@ is separate work. Existing assignments need explicit section grants for UI visib The old `/api/v1/permissions` route is replaced by `/api/v1/claims/permissions`. Frontend adaptation is separate; no database migration is required. +## User Roles + +Identity seeds `developer`, `moderator`, and `player` at startup using the existing +lowercase role-name convention. Seeding preserves existing role IDs and claims; +new roles have no permission/section claims. Public registration assigns Developer. +Existing users are not reassigned automatically. + +User create/update requests accept optional `roleIds` (GUID array); get-details +returns the selected IDs. On update, an omitted/null field preserves roles and `[]` +clears them. On administrative creation, omitted/null means no roles. Empty GUIDs, +duplicates, and unknown role IDs are rejected before persistence. Use the existing +roles list endpoint to populate selection options. Role changes rotate the security +stamp and revoke the user's tokens. No database migration or new configuration is required. + ## Request Validation Request DTO constructors and nullable annotations define the JSON contract and diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs index 321e1188..450c68b8 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs @@ -8,4 +8,5 @@ public sealed record CreateUserCommand( string Password, bool IsConfirmed, IReadOnlyCollection Claims, - string Status) : ICommand>; + string Status, + IReadOnlyCollection? RoleIds = null) : ICommand>; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs index d80c00e6..072089bf 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs @@ -1,3 +1,4 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; @@ -32,6 +33,13 @@ public CreateUserCommandValidator() }) .SetValidator(new ClaimCatalogValidator()); + RuleForEach(command => command.RoleIds) + .MustBeValidDomainValue(RoleId.Create); + + RuleFor(command => command.RoleIds) + .Must(roleIds => roleIds is null || roleIds.Distinct().Count() == roleIds.Count) + .WithMessage("Role IDs must be unique."); + RuleFor(command => command.Status) .MustBeValidDomainValue(UserStatus.Create); } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs index fc8b103e..19717b3a 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs @@ -1,3 +1,4 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims; @@ -40,13 +41,25 @@ public Task> HandleAsync( claims.Add(UserClaim.Create(type: typeResult.Value, value: valueResult.Value)); } + var roleIds = new List(); + foreach (var roleId in command.RoleIds ?? []) + { + var roleIdResult = RoleId.Create(value: roleId); + if (roleIdResult.IsFailure) + { + return Task.FromResult(Result.Failure(errors: roleIdResult.Errors)); + } + + roleIds.Add(roleIdResult.Value); + } + var user = User.Create( id: UserId.New(), email: emailResult.Value, userName: userNameResult.Value, status: statusResult.Value, confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed), - roleIds: [], + roleIds: roleIds, claims: claims); return usersRepository.AddAsync( diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs index cf51c7f1..d515db56 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs @@ -13,6 +13,8 @@ public sealed record UserDetailsReadModel( string StatusDisplayName, IReadOnlyCollection Claims) : IReadModel { + public IReadOnlyCollection RoleIds { get; init; } = []; + public bool IsBlocked => string.Equals( Status, UserStatus.Blocked.Name, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs index 2f60dba2..790b1de7 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs @@ -8,4 +8,5 @@ public sealed record UpdateUserCommand( string UserName, bool IsConfirmed, IReadOnlyCollection Claims, - string Status) : ICommand; + string Status, + IReadOnlyCollection? RoleIds = null) : ICommand; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs index 6dfe3c2e..ff71c11a 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs @@ -1,3 +1,4 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; @@ -33,6 +34,13 @@ public UpdateUserCommandValidator() }) .SetValidator(new ClaimCatalogValidator()); + RuleForEach(command => command.RoleIds) + .MustBeValidDomainValue(RoleId.Create); + + RuleFor(command => command.RoleIds) + .Must(roleIds => roleIds is null || roleIds.Distinct().Count() == roleIds.Count) + .WithMessage("Role IDs must be unique."); + RuleFor(command => command.Status) .MustBeValidDomainValue(UserStatus.Create); } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs index 6ea748f2..11cc20c8 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs @@ -1,3 +1,4 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims; @@ -41,17 +42,30 @@ public Task HandleAsync( claims.Add(UserClaim.Create(type: typeResult.Value, value: valueResult.Value)); } + var roleIds = new List(); + foreach (var roleId in command.RoleIds ?? []) + { + var roleIdResult = RoleId.Create(value: roleId); + if (roleIdResult.IsFailure) + { + return Task.FromResult(Result.Failure(errors: roleIdResult.Errors)); + } + + roleIds.Add(roleIdResult.Value); + } + var user = User.Create( id: idResult.Value, email: emailResult.Value, userName: userNameResult.Value, status: statusResult.Value, confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed), - roleIds: [], + roleIds: roleIds, claims: claims); return usersRepository.UpdateAsync( user: user, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken, + replaceRoles: command.RoleIds is not null); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs new file mode 100644 index 00000000..6c2d7075 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs @@ -0,0 +1,10 @@ +namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles; + +public static class RoleNames +{ + public const string Developer = "developer"; + public const string Moderator = "moderator"; + public const string Player = "player"; + + public static IReadOnlyList All { get; } = Array.AsReadOnly([Developer, Moderator, Player]); +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs index a1b374d9..fd47a065 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs @@ -9,7 +9,8 @@ Task> AddAsync( Task UpdateAsync( User user, - CancellationToken cancellationToken); + CancellationToken cancellationToken, + bool replaceRoles = true); Task DeleteAsync( Guid id, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs index 31fe7c3e..750dd36e 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ public static IServiceCollection AddIdentityProvider( .ValidateOnStart(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddHostedService(); serviceCollection.AddDataProtection() diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs index 6ae043da..02833f74 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs @@ -10,6 +10,8 @@ internal sealed class IdentityProviderApplicationSeederHostedService( public async Task StartAsync(CancellationToken cancellationToken) { await using var scope = serviceScopeFactory.CreateAsyncScope(); + var roleSeeder = scope.ServiceProvider.GetRequiredService(); + await roleSeeder.SeedAsync(cancellationToken); var seeder = scope.ServiceProvider.GetRequiredService(); await seeder.SeedAsync(cancellationToken); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs new file mode 100644 index 00000000..b78e4f35 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; + +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; + +namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; + +internal sealed class IdentityRoleSeeder(RoleManager roleManager, TimeProvider timeProvider) +{ + public async Task SeedAsync(CancellationToken cancellationToken) + { + foreach (var name in RoleNames.All) + { + cancellationToken.ThrowIfCancellationRequested(); + if (await roleManager.FindByNameAsync(name) is not null) + { + continue; + } + + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; + var result = await roleManager.CreateAsync(new ApplicationRole + { + Id = Guid.CreateVersion7(), + Name = name, + CreatedAt = nowUtc, + UpdatedAt = nowUtc + }); + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(error => error.Description)); + throw new InvalidOperationException($"Could not seed role '{name}': {errors}"); + } + } + } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs index b36f1ede..1d372657 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs @@ -4,12 +4,14 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Login; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Claims; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Mappers; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Options.IdentityProvider; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.Mappers; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.Queries; @@ -18,6 +20,7 @@ namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Servi public sealed class UserCredentialsService( UserManager userManager, + RoleManager roleManager, IOpenIddictTokenManager tokenManager, IOptions options, IAggregateTracker aggregateTracker, @@ -39,7 +42,20 @@ public async Task> RegisterAsync( return Result.Failure(errors: validationResult.Errors); } + var developerRole = await roleManager.FindByNameAsync(RoleNames.Developer); + if (developerRole is null) + { + return Result.Failure(error: Error.NotFound(message: "Developer role was not found.")); + } + + var roleIdResult = RoleId.Create(value: developerRole.Id); + if (roleIdResult.IsFailure) + { + return Result.Failure(errors: roleIdResult.Errors); + } + var user = User.Register(email: emailResult.Value, userName: userNameResult.Value); + user.AssignRole(roleIdResult.Value); var nowUtc = timeProvider.GetUtcNow().UtcDateTime; var applicationUser = ApplicationUserMapper.ToDbModel( user: user, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs index 2bf9bd48..3df3317a 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs @@ -23,6 +23,9 @@ public static IQueryable ProjectTo( .Select(claim => new Claim( type: claim.ClaimType!, value: claim.ClaimValue!)) - .ToArray())); + .ToArray()) + { + RoleIds = user.Roles.Select(role => role.RoleId).ToArray() + }); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs index ad3bbe56..e2f5ed96 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs @@ -6,6 +6,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Mappers; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.Mappers; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.Queries; @@ -25,6 +26,12 @@ public async Task> AddAsync( string password, CancellationToken cancellationToken) { + var rolesResult = await ValidateRolesAsync(user, cancellationToken); + if (rolesResult.IsFailure) + { + return Result.Failure(errors: rolesResult.Errors); + } + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; var applicationUser = ApplicationUserMapper.ToDbModel( user: user, @@ -45,7 +52,8 @@ public async Task> AddAsync( public async Task UpdateAsync( User user, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool replaceRoles = true) { var applicationUser = await userManager.Users .WithAuthorizationGraph() @@ -56,6 +64,16 @@ public async Task UpdateAsync( return UserNotFound(); } + if (replaceRoles) + { + var rolesResult = await ValidateRolesAsync(user, cancellationToken); + if (rolesResult.IsFailure) + { + return rolesResult; + } + } + + var rolesChanged = replaceRoles && SyncRoles(applicationUser, user); ApplicationUserMapper.MapToDbModel( user: user, dbModel: applicationUser, @@ -64,12 +82,14 @@ public async Task UpdateAsync( applicationUser: applicationUser, user: user); - if (user.Status.IsBlocked) + if (user.Status.IsBlocked || rolesChanged) { await RevokeAllTokensAsync(user.Id.Value, cancellationToken); } - var identityResult = await userManager.UpdateAsync(applicationUser); + var identityResult = rolesChanged + ? await userManager.UpdateSecurityStampAsync(applicationUser) + : await userManager.UpdateAsync(applicationUser); if (!identityResult.Succeeded) { @@ -164,6 +184,52 @@ private async Task RevokeAllTokensAsync( await tokenManager.RevokeBySubjectAsync(id.ToString(), cancellationToken); } + private async Task ValidateRolesAsync(User user, CancellationToken cancellationToken) + { + var roleIds = user.RoleIds.Select(roleId => roleId.Value).ToArray(); + if (roleIds.Length == 0) + { + return Result.Success(); + } + + var count = await dbContext.Set() + .CountAsync(role => roleIds.Contains(role.Id), cancellationToken); + if (count != roleIds.Length) + { + return Result.Failure(error: Error.Validation(message: "One or more roles were not found.") + .WithField(nameof(User.RoleIds))); + } + + return Result.Success(); + } + + private bool SyncRoles(ApplicationUser applicationUser, User user) + { + var roleIds = user.RoleIds.Select(roleId => roleId.Value).ToHashSet(); + if (roleIds.SetEquals(applicationUser.Roles.Select(role => role.RoleId))) + { + return false; + } + + foreach (var currentRole in applicationUser.Roles.ToArray()) + { + if (!roleIds.Contains(currentRole.RoleId)) + { + dbContext.Set().Remove(currentRole); + } + } + + foreach (var roleId in roleIds) + { + if (applicationUser.Roles.All(role => role.RoleId != roleId)) + { + applicationUser.Roles.Add(new ApplicationUserRole { UserId = applicationUser.Id, RoleId = roleId }); + } + } + + return true; + } + private void SyncClaims( ApplicationUser applicationUser, User user) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs index 8b27aaf7..d1cde26b 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs @@ -8,4 +8,5 @@ public sealed record CreateUserRequest( string Password, bool IsConfirmed, IReadOnlyCollection Claims, - string Status); + string Status, + IReadOnlyCollection? RoleIds = null); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs index cde0e43f..3f02daf7 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs @@ -9,4 +9,7 @@ public sealed record GetUserDetailsResponse( bool IsConfirmed, string Status, string StatusDisplayName, - IReadOnlyCollection Claims); + IReadOnlyCollection Claims) +{ + public IReadOnlyCollection RoleIds { get; init; } = []; +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs index 902204f9..2c5def62 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs @@ -7,4 +7,5 @@ public sealed record UpdateUserRequest( string UserName, bool IsConfirmed, IReadOnlyCollection Claims, - string Status); + string Status, + IReadOnlyCollection? RoleIds = null); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs index 02bd819a..61b348bb 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs @@ -34,5 +34,6 @@ public async Task PostRegister_Should_PersistUnconfirmedUser_When_RequestIsValid user.Email.ShouldBe("registered@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); + user.Roles.ShouldHaveSingleItem().Role!.Name.ShouldBe("developer"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs index 80f4c0f5..f600a596 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs @@ -15,7 +15,7 @@ public async Task GetRoles_Should_ReturnSortedPage_When_RolesExist() var response = await client.GetListAsync(cancellationToken); - response.TotalCount.ShouldBe(2); - response.Items.Select(role => role.Name).ShouldBe(["admin", "viewer"]); + response.TotalCount.ShouldBe(5); + response.Items.Select(role => role.Name).ShouldBe(["admin", "developer", "moderator", "player", "viewer"]); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs index 476c86a7..aab8b103 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs @@ -18,6 +18,8 @@ internal static class UserDatabaseTestHelper return await dbContext.Set() .Include(user => user.Claims) + .Include(user => user.Roles) + .ThenInclude(role => role.Role) .AsNoTracking() .SingleOrDefaultAsync(user => user.Id == userId, cancellationToken); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs new file mode 100644 index 00000000..ac6250c2 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs @@ -0,0 +1,121 @@ +using System.Text.Json; + +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Users.Update; + +namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; + +public sealed class UserRoleAssignmentEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) +{ + [Fact(DisplayName = "User creation should assign multiple roles when roles are selected")] + public async Task PostUsers_Should_AssignMultipleRoles_When_RolesAreSelected() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; + Guid[] selected = + [ + roles.Single(role => role.Name == "developer").Id, + roles.Single(role => role.Name == "moderator").Id + ]; + var client = new UserApiTestClient(Fixture); + + var created = await client.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = selected }); + var details = await client.GetDetailsAsync(created.Id, cancellationToken); + + details.RoleIds.ShouldBe(selected, ignoreOrder: true); + var user = await UserDatabaseTestHelper.FindAsync(Fixture, created.Id, cancellationToken); + user.ShouldNotBeNull(); + user.Roles.Select(role => role.RoleId).ShouldBe(selected, ignoreOrder: true); + } + + [Theory(DisplayName = "User edits should replace clear or preserve roles when role selections vary")] + [InlineData("replace")] + [InlineData("clear")] + [InlineData("omit")] + public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(string mode) + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; + var developerId = roles.Single(role => role.Name == "developer").Id; + var moderatorId = roles.Single(role => role.Name == "moderator").Id; + var client = new UserApiTestClient(Fixture); + var created = await client.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); + IReadOnlyCollection? selected = mode == "omit" ? null : mode == "clear" ? [] : [moderatorId]; + var payload = JsonSerializer.SerializeToNode(new UpdateUserRequest( + Email: "edited@example.com", + UserName: "edited", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: selected), JsonOptions)! + .AsObject(); + if (mode == "omit") + { + payload.Remove("roleIds"); + } + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{created.Id}") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var details = await client.GetDetailsAsync(created.Id, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.NoContent); + details.Email.ShouldBe("edited@example.com"); + details.RoleIds.ShouldBe(mode == "omit" ? [developerId] : selected!, ignoreOrder: true); + } + + [Theory(DisplayName = "User requests should reject invalid role selections when role selection is invalid")] + [InlineData(false, "missing")] + [InlineData(true, "missing")] + [InlineData(false, "empty")] + [InlineData(true, "empty")] + [InlineData(false, "duplicate")] + [InlineData(true, "duplicate")] + public async Task SaveUser_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(bool update, string invalid) + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; + var developerId = roles.Single(role => role.Name == "developer").Id; + Guid[] roleIds = invalid switch + { + "empty" => [Guid.Empty], + "duplicate" => [developerId, developerId], + _ => [developerId, Guid.CreateVersion7()] + }; + var payload = JsonSerializer.SerializeToNode( + UserApiTestClient.CreateDefaultRequest() with { RoleIds = roleIds }, JsonOptions)! + .AsObject(); + var path = "/api/v1/users"; + Guid? existingId = null; + if (update) + { + var client = new UserApiTestClient(Fixture); + var user = await client.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); + existingId = user.Id; + path = $"{path}/{user.Id}"; + payload.Remove("password"); + payload["email"] = "rejected@example.com"; + } + using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + if (existingId is not null) + { + var details = await new UserApiTestClient(Fixture).GetDetailsAsync(existingId.Value, cancellationToken); + details.Email.ShouldBe("hero@example.com"); + details.RoleIds.ShouldBe([developerId]); + } + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs index 87f5bf31..2efced91 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs @@ -98,6 +98,8 @@ private void CreateCurrentClient() private async Task SeedIdentityProviderAsync(CancellationToken cancellationToken) { await using var scope = _factory.Services.CreateAsyncScope(); + var roleSeeder = scope.ServiceProvider.GetRequiredService(); + await roleSeeder.SeedAsync(cancellationToken); var seeder = scope.ServiceProvider.GetRequiredService(); await seeder.SeedAsync(cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs new file mode 100644 index 00000000..85d2f866 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs @@ -0,0 +1,89 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; + +namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.IdentityProvider.Seeding; + +public sealed class IdentityRoleSeederTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact(DisplayName = "Role seeding should create predefined roles when roles are missing")] + public async Task SeedAsync_Should_CreatePredefinedRoles_When_RolesAreMissing() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await seeder.SeedAsync(cancellationToken); + + var roles = await dbContext.Set() + .Include(role => role.Claims) + .AsNoTracking() + .ToArrayAsync(cancellationToken); + roles.Select(role => role.Name).ShouldBe(RoleNames.All, ignoreOrder: true); + roles.ShouldAllBe(role => role.Id != Guid.Empty && role.CreatedAt != default && role.UpdatedAt != default); + roles.ShouldAllBe(role => role.NormalizedName == role.Name!.ToUpperInvariant()); + roles.ShouldAllBe(role => role.Claims.Count == 0); + } + + [Fact(DisplayName = "Role seeding should preserve IDs and claims when roles already exist")] + public async Task SeedAsync_Should_PreserveIdsAndClaims_When_RolesAlreadyExist() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + var manager = scope.ServiceProvider.GetRequiredService>(); + await seeder.SeedAsync(cancellationToken); + var role = await manager.FindByNameAsync(RoleNames.Developer); + role.ShouldNotBeNull(); + var id = role.Id; + (await manager.AddClaimAsync(role, new Claim("permission", "identity.users.read"))).Succeeded.ShouldBeTrue(); + + await seeder.SeedAsync(cancellationToken); + + var existing = await manager.FindByNameAsync(RoleNames.Developer); + existing.ShouldNotBeNull(); + existing.Id.ShouldBe(id); + (await manager.GetClaimsAsync(existing)).ShouldHaveSingleItem().Value.ShouldBe("identity.users.read"); + (await manager.Roles.CountAsync(cancellationToken)).ShouldBe(3); + } + + [Fact(DisplayName = "Role seeding should stop when cancellation is requested")] + public async Task SeedAsync_Should_Throw_When_CancellationIsRequested() + { + await using var scope = Fixture.CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Should.ThrowAsync(() => seeder.SeedAsync(cancellation.Token)); + } + + [Fact(DisplayName = "Role seeding should fail startup when role creation fails")] + public async Task SeedAsync_Should_Throw_When_RoleCreationFails() + { + await using var scope = Fixture.CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + var manager = scope.ServiceProvider.GetRequiredService>(); + manager.RoleValidators.Add(new RejectingRoleValidator()); + + var exception = await Should.ThrowAsync( + () => seeder.SeedAsync(TestContext.Current.CancellationToken)); + + exception.Message.ShouldContain("Rejected role"); + } + + private sealed class RejectingRoleValidator : IRoleValidator + { + public Task ValidateAsync(RoleManager manager, ApplicationRole role) => + Task.FromResult(IdentityResult.Failed( + new IdentityError { Code = "Rejected", Description = "Rejected role" })); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index 369c547a..565422c7 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs @@ -8,10 +8,12 @@ using OpenIddict.EntityFrameworkCore.Models; using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Abstractions; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Events; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Claims; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; @@ -32,6 +34,8 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr var service = scope.ServiceProvider.GetRequiredService(); var userManager = scope.ServiceProvider.GetRequiredService>(); + await scope.ServiceProvider.GetRequiredService().SeedAsync(cancellationToken); + var result = await service.RegisterAsync( " REGISTER@Example.COM ", " registered-hero ", @@ -44,6 +48,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr user.Email.ShouldBe("register@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); + (await userManager.GetRolesAsync(user)).ShouldBe([RoleNames.Developer]); var aggregateTracker = scope.ServiceProvider.GetRequiredService(); var trackedUser = aggregateTracker.GetAll() @@ -84,6 +89,32 @@ public async Task RegisterAsync_Should_ReturnValidationFailuresWithoutPersisting aggregateTracker.GetAll().ShouldBeEmpty(); } + [Theory(DisplayName = "Registration should reject invalid default role configuration when default role is invalid")] + [InlineData(false)] + [InlineData(true)] + public async Task RegisterAsync_Should_RejectInvalidDefaultRole_When_DefaultRoleIsInvalid(bool emptyRoleId) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + if (emptyRoleId) + { + dbContext.Set().Add(new ApplicationRole + { + Id = Guid.Empty, + Name = RoleNames.Developer, + NormalizedName = RoleNames.Developer.ToUpperInvariant() + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + var service = scope.ServiceProvider.GetRequiredService(); + + var result = await service.RegisterAsync("hero@example.com", "hero", Password, cancellationToken); + + result.IsFailure.ShouldBeTrue(); + (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); + } + [Fact(DisplayName = "LoginAsync should load user and all claims in one read query when credentials are valid")] public async Task LoginAsync_Should_LoadUserAndAllClaimsInOneReadQuery_When_CredentialsAreValid() { diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs new file mode 100644 index 00000000..1bd883cf --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -0,0 +1,119 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using OpenIddict.Abstractions; +using OpenIddict.EntityFrameworkCore.Models; + +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Domain.Users; +using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; +using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; +using PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Users; + +namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persistence.Features.Users.Write; + +public sealed class UserRoleAssignmentTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) +{ + [Theory(DisplayName = "User updates should synchronize roles and sessions when role assignments vary")] + [InlineData("omit")] + [InlineData("same")] + [InlineData("replace")] + [InlineData("add")] + [InlineData("clear")] + public async Task UpdateAsync_Should_SynchronizeRolesAndSessions_When_RoleAssignmentsVary(string mode) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService(); + await scope.ServiceProvider.GetRequiredService().SeedAsync(cancellationToken); + var roles = await dbContext.Set().ToDictionaryAsync( + role => role.Name!, role => role.Id, cancellationToken); + var userId = Guid.CreateVersion7(); + var created = await repository.AddAsync( + CreateUser(userId, [roles[RoleNames.Developer]]), "StrongPassword1!", cancellationToken); + created.IsSuccess.ShouldBeTrue(); + var originalStamp = (await dbContext.Set() + .SingleAsync(user => user.Id == userId, cancellationToken)).SecurityStamp; + var targetRoles = mode switch + { + "same" => new[] { roles[RoleNames.Developer] }, + "replace" => [roles[RoleNames.Moderator]], + "add" => [roles[RoleNames.Developer], roles[RoleNames.Player]], + _ => [] + }; + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var otherId = Guid.CreateVersion7(); + foreach (var subject in new[] { userId, otherId }) + { + await tokenManager.CreateAsync(new OpenIddictTokenDescriptor + { + Subject = subject.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken + }, cancellationToken); + } + + var result = await repository.UpdateAsync( + user: CreateUser(userId, targetRoles), + cancellationToken: cancellationToken, + replaceRoles: mode != "omit"); + + result.IsSuccess.ShouldBeTrue(); + var user = await dbContext.Set() + .Include(item => item.Roles) + .AsNoTracking() + .SingleAsync(item => item.Id == userId, cancellationToken); + var expectedRoles = mode == "omit" ? [roles[RoleNames.Developer]] : targetRoles; + user.Roles.Select(role => role.RoleId).ShouldBe(expectedRoles, ignoreOrder: true); + var changed = mode is "replace" or "add" or "clear"; + (user.SecurityStamp != originalStamp).ShouldBe(changed); + var tokens = await dbContext.Set>() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + tokens.Single(token => token.Subject == userId.ToString()).Status.ShouldBe(changed + ? OpenIddictConstants.Statuses.Revoked + : OpenIddictConstants.Statuses.Valid); + tokens.Single(token => token.Subject == otherId.ToString()).Status.ShouldBe(OpenIddictConstants.Statuses.Valid); + } + + [Theory(DisplayName = "User persistence should reject unknown roles before writes when role does not exist")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveAsync_Should_RejectRolesBeforeWrites_When_RoleDoesNotExist(bool update) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var id = Guid.CreateVersion7(); + if (update) + { + var created = await repository.AddAsync(CreateUser(id, []), "StrongPassword1!", cancellationToken); + created.IsSuccess.ShouldBeTrue(); + } + var invalidUser = CreateUser(id, [Guid.CreateVersion7()]); + + var succeeded = update + ? (await repository.UpdateAsync(invalidUser, cancellationToken)).IsSuccess + : (await repository.AddAsync(invalidUser, "StrongPassword1!", cancellationToken)).IsSuccess; + + succeeded.ShouldBeFalse(); + (await dbContext.Set().CountAsync(cancellationToken)).ShouldBe(update ? 1 : 0); + (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); + } + + private static User CreateUser(Guid id, IReadOnlyCollection roleIds) => + IntegrationTestData.CreateUser( + id: id, + email: "hero@example.com", + isConfirmed: true, + roleIds: roleIds, + claims: [], + userName: "hero", + status: UserStatus.Active); +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs index 7ff59681..aa0242ce 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs @@ -40,4 +40,31 @@ public void Validate_Should_ReturnErrors_When_CommandIsInvalid() .ShouldBe(2); result.Errors.ShouldContain(error => error.PropertyName == nameof(CreateUserCommand.Status)); } + + [Theory(DisplayName = "User validator should validate role identifiers when role IDs are provided")] + [InlineData(false, false, true)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + public void Validate_Should_CheckRoleIds_When_RoleIdsAreProvided(bool emptyId, bool duplicate, bool expectedValid) + { + var validator = new CreateUserCommandValidator(); + var id = emptyId ? Guid.Empty : Guid.CreateVersion7(); + Guid[] roleIds = duplicate ? [id, id] : [id]; + + var result = validator.Validate( + new CreateUserCommand( + Email: "hero@example.com", + UserName: "hero", + Password: "StrongPassword1!", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: roleIds)); + + result.IsValid.ShouldBe(expectedValid); + if (!expectedValid) + { + result.Errors.ShouldContain(error => error.PropertyName.StartsWith("RoleIds", StringComparison.Ordinal)); + } + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs index a578a795..889bb7ab 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs @@ -81,4 +81,53 @@ [new Claim(claimType, claimValue)], result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); await repository.DidNotReceiveWithAnyArgs().AddAsync(null!, string.Empty, cancellationToken); } + + [Fact(DisplayName = "User handler should pass assigned role IDs to persistence when role IDs are provided")] + public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roleId = Guid.CreateVersion7(); + var repository = Substitute.For(); + repository.AddAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success(Guid.CreateVersion7())); + var handler = new CreateUserHandler(repository); + + var result = await handler.HandleAsync( + new CreateUserCommand( + Email: "hero@example.com", + UserName: "hero", + Password: "StrongPassword1!", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: [roleId]), + cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + await repository.Received(1).AddAsync(Arg.Is(user => user.RoleIds.Single().Value == roleId), + "StrongPassword1!", cancellationToken); + } + + [Fact(DisplayName = "User handler should reject an empty role ID when role ID is invalid")] + public async Task HandleAsync_Should_ReturnValidationFailure_When_RoleIdIsInvalid() + { + var cancellationToken = TestContext.Current.CancellationToken; + var repository = Substitute.For(); + var handler = new CreateUserHandler(repository); + + var result = await handler.HandleAsync( + new CreateUserCommand( + Email: "hero@example.com", + UserName: "hero", + Password: "StrongPassword1!", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: [Guid.Empty]), + cancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); + repository.ReceivedCalls().ShouldBeEmpty(); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs index 2ffa58fc..d983f0be 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs @@ -46,4 +46,31 @@ [new Claim("", "")], .ShouldBe(2); result.Errors.ShouldContain(error => error.PropertyName == nameof(UpdateUserCommand.Status)); } + + [Theory(DisplayName = "User validator should validate role identifiers when role IDs are provided")] + [InlineData(false, false, true)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + public void Validate_Should_CheckRoleIds_When_RoleIdsAreProvided(bool emptyId, bool duplicate, bool expectedValid) + { + var validator = new UpdateUserCommandValidator(); + var id = emptyId ? Guid.Empty : Guid.CreateVersion7(); + Guid[] roleIds = duplicate ? [id, id] : [id]; + + var result = validator.Validate( + new UpdateUserCommand( + Id: Guid.CreateVersion7(), + Email: "hero@example.com", + UserName: "hero", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: roleIds)); + + result.IsValid.ShouldBe(expectedValid); + if (!expectedValid) + { + result.Errors.ShouldContain(error => error.PropertyName.StartsWith("RoleIds", StringComparison.Ordinal)); + } + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs index 84506d03..4cb80c3d 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs @@ -22,7 +22,8 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() && user.Status == UserStatus.Blocked && user.ConfirmationStatus.IsConfirmed && user.Claims.Any(claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), - Arg.Any()) + Arg.Any(), + replaceRoles: false) .Returns(Result.Success()); var handler = new UpdateUserHandler(repository); var cancellationToken = TestContext.Current.CancellationToken; @@ -45,7 +46,8 @@ await repository.Received(1).UpdateAsync( && user.Status == UserStatus.Blocked && user.ConfirmationStatus.IsConfirmed && user.Claims.Any(claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), - cancellationToken); + cancellationToken, + replaceRoles: false); } [Theory( @@ -79,4 +81,53 @@ [new Claim(claimType, claimValue)], result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); await repository.DidNotReceiveWithAnyArgs().UpdateAsync(null!, cancellationToken); } + + [Fact(DisplayName = "User handler should pass assigned role IDs to persistence when role IDs are provided")] + public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roleId = Guid.CreateVersion7(); + var repository = Substitute.For(); + repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + var handler = new UpdateUserHandler(repository); + + var result = await handler.HandleAsync( + new UpdateUserCommand( + Id: Guid.CreateVersion7(), + Email: "hero@example.com", + UserName: "hero", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: [roleId]), + cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + await repository.Received(1).UpdateAsync(Arg.Is(user => user.RoleIds.Single().Value == roleId), + cancellationToken, replaceRoles: true); + } + + [Fact(DisplayName = "User handler should reject an empty role ID when role ID is invalid")] + public async Task HandleAsync_Should_ReturnValidationFailure_When_RoleIdIsInvalid() + { + var cancellationToken = TestContext.Current.CancellationToken; + var repository = Substitute.For(); + var handler = new UpdateUserHandler(repository); + + var result = await handler.HandleAsync( + new UpdateUserCommand( + Id: Guid.CreateVersion7(), + Email: "hero@example.com", + UserName: "hero", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: [Guid.Empty]), + cancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); + repository.ReceivedCalls().ShouldBeEmpty(); + } } From 07794628a1a46233e6e833472fea03c031ba3b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Mon, 12 Oct 2026 22:04:53 +0400 Subject: [PATCH 02/34] refactor: validate role assignments through domain factory --- .../Create/CreateUserCommandValidator.cs | 8 +--- .../Users/Create/CreateUserHandler.cs | 18 ++------ .../Update/UpdateUserCommandValidator.cs | 7 +-- .../Users/Update/UpdateUserHandler.cs | 18 ++------ .../Roles/RoleNames.cs | 4 +- .../Users/UserRoleAssignments.cs | 30 +++++++++++++ .../Features/Users/UserDatabaseTestHelper.cs | 2 +- .../Users/UserRoleAssignmentEndpointTests.cs | 16 ++++--- .../Seeding/IdentityRoleSeederTests.cs | 4 +- .../Users/Write/UserRoleAssignmentTests.cs | 14 +++--- .../Create/CreateUserCommandValidatorTests.cs | 2 +- .../Users/Create/CreateUserHandlerTests.cs | 7 +-- .../Update/UpdateUserCommandValidatorTests.cs | 2 +- .../Users/Update/UpdateUserHandlerTests.cs | 7 +-- .../Domain/Users/UserRoleAssignmentsTests.cs | 44 +++++++++++++++++++ 15 files changed, 117 insertions(+), 66 deletions(-) create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs index 072089bf..d6aaf41b 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs @@ -1,5 +1,5 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Application.Claims; +using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; @@ -33,12 +33,8 @@ public CreateUserCommandValidator() }) .SetValidator(new ClaimCatalogValidator()); - RuleForEach(command => command.RoleIds) - .MustBeValidDomainValue(RoleId.Create); - RuleFor(command => command.RoleIds) - .Must(roleIds => roleIds is null || roleIds.Distinct().Count() == roleIds.Count) - .WithMessage("Role IDs must be unique."); + .MustBeValidDomainValue(UserRoleAssignments.Create); RuleFor(command => command.Status) .MustBeValidDomainValue(UserStatus.Create); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs index 19717b3a..c40dc3f3 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs @@ -1,4 +1,3 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims; @@ -18,7 +17,8 @@ public Task> HandleAsync( var emailResult = Email.Create(value: command.Email); var userNameResult = UserName.Create(value: command.UserName); var statusResult = UserStatus.Create(value: command.Status); - var validationResult = Result.Combine(emailResult, userNameResult, statusResult); + var roleIdsResult = UserRoleAssignments.Create(command.RoleIds); + var validationResult = Result.Combine(emailResult, userNameResult, statusResult, roleIdsResult); if (validationResult.IsFailure) { @@ -41,25 +41,13 @@ public Task> HandleAsync( claims.Add(UserClaim.Create(type: typeResult.Value, value: valueResult.Value)); } - var roleIds = new List(); - foreach (var roleId in command.RoleIds ?? []) - { - var roleIdResult = RoleId.Create(value: roleId); - if (roleIdResult.IsFailure) - { - return Task.FromResult(Result.Failure(errors: roleIdResult.Errors)); - } - - roleIds.Add(roleIdResult.Value); - } - var user = User.Create( id: UserId.New(), email: emailResult.Value, userName: userNameResult.Value, status: statusResult.Value, confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed), - roleIds: roleIds, + roleIds: roleIdsResult.Value, claims: claims); return usersRepository.AddAsync( diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs index ff71c11a..8ea03ffd 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs @@ -1,4 +1,3 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; @@ -34,12 +33,8 @@ public UpdateUserCommandValidator() }) .SetValidator(new ClaimCatalogValidator()); - RuleForEach(command => command.RoleIds) - .MustBeValidDomainValue(RoleId.Create); - RuleFor(command => command.RoleIds) - .Must(roleIds => roleIds is null || roleIds.Distinct().Count() == roleIds.Count) - .WithMessage("Role IDs must be unique."); + .MustBeValidDomainValue(UserRoleAssignments.Create); RuleFor(command => command.Status) .MustBeValidDomainValue(UserStatus.Create); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs index 11cc20c8..7c3208b5 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs @@ -1,4 +1,3 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims; @@ -19,7 +18,8 @@ public Task HandleAsync( var emailResult = Email.Create(value: command.Email); var userNameResult = UserName.Create(value: command.UserName); var statusResult = UserStatus.Create(value: command.Status); - var validationResult = Result.Combine(idResult, emailResult, userNameResult, statusResult); + var roleIdsResult = UserRoleAssignments.Create(command.RoleIds); + var validationResult = Result.Combine(idResult, emailResult, userNameResult, statusResult, roleIdsResult); if (validationResult.IsFailure) { @@ -42,25 +42,13 @@ public Task HandleAsync( claims.Add(UserClaim.Create(type: typeResult.Value, value: valueResult.Value)); } - var roleIds = new List(); - foreach (var roleId in command.RoleIds ?? []) - { - var roleIdResult = RoleId.Create(value: roleId); - if (roleIdResult.IsFailure) - { - return Task.FromResult(Result.Failure(errors: roleIdResult.Errors)); - } - - roleIds.Add(roleIdResult.Value); - } - var user = User.Create( id: idResult.Value, email: emailResult.Value, userName: userNameResult.Value, status: statusResult.Value, confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed), - roleIds: roleIds, + roleIds: roleIdsResult.Value, claims: claims); return usersRepository.UpdateAsync( diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs index 6c2d7075..2a05f784 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; + namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles; public static class RoleNames @@ -6,5 +8,5 @@ public static class RoleNames public const string Moderator = "moderator"; public const string Player = "player"; - public static IReadOnlyList All { get; } = Array.AsReadOnly([Developer, Moderator, Player]); + public static ImmutableArray All { get; } = [Developer, Moderator, Player]; } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs new file mode 100644 index 00000000..e74d4c0a --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs @@ -0,0 +1,30 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; + +namespace PANiXiDA.TacticalHeroes.Identity.Domain.Users; + +public static class UserRoleAssignments +{ + public static Result> Create(IReadOnlyCollection? values) + { + var roleIds = new List(); + var seen = new HashSet(); + foreach (var value in values ?? []) + { + if (!seen.Add(value)) + { + return Result.Failure>( + error: Error.Validation(message: "Role IDs must be unique.").WithField(nameof(User.RoleIds))); + } + + var result = RoleId.Create(value); + if (result.IsFailure) + { + return Result.Failure>(errors: result.Errors); + } + + roleIds.Add(result.Value); + } + + return Result.Success>(value: roleIds); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs index aab8b103..e84fbe07 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs @@ -19,7 +19,7 @@ internal static class UserDatabaseTestHelper return await dbContext.Set() .Include(user => user.Claims) .Include(user => user.Roles) - .ThenInclude(role => role.Role) + .ThenInclude(role => role.Role) .AsNoTracking() .SingleOrDefaultAsync(user => user.Id == userId, cancellationToken); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs index ac6250c2..6705fb48 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs @@ -44,13 +44,15 @@ public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(stri var created = await client.CreateAsync( cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); IReadOnlyCollection? selected = mode == "omit" ? null : mode == "clear" ? [] : [moderatorId]; - var payload = JsonSerializer.SerializeToNode(new UpdateUserRequest( - Email: "edited@example.com", - UserName: "edited", - IsConfirmed: true, - Claims: [], - Status: "Active", - RoleIds: selected), JsonOptions)! + var payload = JsonSerializer.SerializeToNode( + new UpdateUserRequest( + Email: "edited@example.com", + UserName: "edited", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: selected), + JsonOptions)! .AsObject(); if (mode == "omit") { diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs index 85d2f866..a5ae8d59 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs @@ -63,7 +63,9 @@ public async Task SeedAsync_Should_Throw_When_CancellationIsRequested() using var cancellation = new CancellationTokenSource(); await cancellation.CancelAsync(); - await Should.ThrowAsync(() => seeder.SeedAsync(cancellation.Token)); + var exception = await Should.ThrowAsync(() => seeder.SeedAsync(cancellation.Token)); + + exception.CancellationToken.ShouldBe(cancellation.Token); } [Fact(DisplayName = "Role seeding should fail startup when role creation fails")] diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs index 1bd883cf..7350e570 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -50,12 +50,14 @@ public async Task UpdateAsync_Should_SynchronizeRolesAndSessions_When_RoleAssign var otherId = Guid.CreateVersion7(); foreach (var subject in new[] { userId, otherId }) { - await tokenManager.CreateAsync(new OpenIddictTokenDescriptor - { - Subject = subject.ToString(), - Status = OpenIddictConstants.Statuses.Valid, - Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken - }, cancellationToken); + await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = subject.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken + }, + cancellationToken); } var result = await repository.UpdateAsync( diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs index aa0242ce..7c9e41b6 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs @@ -41,7 +41,7 @@ public void Validate_Should_ReturnErrors_When_CommandIsInvalid() result.Errors.ShouldContain(error => error.PropertyName == nameof(CreateUserCommand.Status)); } - [Theory(DisplayName = "User validator should validate role identifiers when role IDs are provided")] + [Theory(DisplayName = "User validator should validate role identifiers when role ids are provided")] [InlineData(false, false, true)] [InlineData(true, false, false)] [InlineData(false, true, false)] diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs index 889bb7ab..83f245de 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs @@ -82,7 +82,7 @@ [new Claim(claimType, claimValue)], await repository.DidNotReceiveWithAnyArgs().AddAsync(null!, string.Empty, cancellationToken); } - [Fact(DisplayName = "User handler should pass assigned role IDs to persistence when role IDs are provided")] + [Fact(DisplayName = "User handler should pass assigned role IDs to persistence when role ids are provided")] public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() { var cancellationToken = TestContext.Current.CancellationToken; @@ -104,11 +104,12 @@ public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() cancellationToken); result.IsSuccess.ShouldBeTrue(); - await repository.Received(1).AddAsync(Arg.Is(user => user.RoleIds.Single().Value == roleId), + await repository.Received(1).AddAsync( + Arg.Is(user => user.RoleIds.Single().Value == roleId), "StrongPassword1!", cancellationToken); } - [Fact(DisplayName = "User handler should reject an empty role ID when role ID is invalid")] + [Fact(DisplayName = "User handler should reject an empty role ID when role id is invalid")] public async Task HandleAsync_Should_ReturnValidationFailure_When_RoleIdIsInvalid() { var cancellationToken = TestContext.Current.CancellationToken; diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs index d983f0be..aedf5159 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs @@ -47,7 +47,7 @@ [new Claim("", "")], result.Errors.ShouldContain(error => error.PropertyName == nameof(UpdateUserCommand.Status)); } - [Theory(DisplayName = "User validator should validate role identifiers when role IDs are provided")] + [Theory(DisplayName = "User validator should validate role identifiers when role ids are provided")] [InlineData(false, false, true)] [InlineData(true, false, false)] [InlineData(false, true, false)] diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs index 4cb80c3d..e629dec8 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs @@ -82,7 +82,7 @@ [new Claim(claimType, claimValue)], await repository.DidNotReceiveWithAnyArgs().UpdateAsync(null!, cancellationToken); } - [Fact(DisplayName = "User handler should pass assigned role IDs to persistence when role IDs are provided")] + [Fact(DisplayName = "User handler should pass assigned role IDs to persistence when role ids are provided")] public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() { var cancellationToken = TestContext.Current.CancellationToken; @@ -104,11 +104,12 @@ public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() cancellationToken); result.IsSuccess.ShouldBeTrue(); - await repository.Received(1).UpdateAsync(Arg.Is(user => user.RoleIds.Single().Value == roleId), + await repository.Received(1).UpdateAsync( + Arg.Is(user => user.RoleIds.Single().Value == roleId), cancellationToken, replaceRoles: true); } - [Fact(DisplayName = "User handler should reject an empty role ID when role ID is invalid")] + [Fact(DisplayName = "User handler should reject an empty role ID when role id is invalid")] public async Task HandleAsync_Should_ReturnValidationFailure_When_RoleIdIsInvalid() { var cancellationToken = TestContext.Current.CancellationToken; diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs new file mode 100644 index 00000000..4e17b962 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs @@ -0,0 +1,44 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Users; + +namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Users; + +public sealed class UserRoleAssignmentsTests +{ + [Theory(DisplayName = "Role assignments should accept empty selections when roles are omitted")] + [InlineData(false)] + [InlineData(true)] + public void Create_Should_ReturnEmptySelection_When_RolesAreOmitted(bool nullValue) + { + IReadOnlyCollection? roles = nullValue ? null : []; + + var result = UserRoleAssignments.Create(roles); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } + + [Fact(DisplayName = "Role assignments should preserve identifiers when roles are valid")] + public void Create_Should_PreserveIdentifiers_When_RolesAreValid() + { + Guid[] roles = [Guid.CreateVersion7(), Guid.CreateVersion7()]; + + var result = UserRoleAssignments.Create(roles); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Select(role => role.Value).ShouldBe(roles); + } + + [Theory(DisplayName = "Role assignments should reject invalid selections when roles are invalid")] + [InlineData(false)] + [InlineData(true)] + public void Create_Should_ReturnValidationError_When_RolesAreInvalid(bool duplicate) + { + var id = Guid.CreateVersion7(); + Guid[] roles = duplicate ? [id, id] : [Guid.Empty]; + + var result = UserRoleAssignments.Create(roles); + + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldHaveSingleItem().Type.ShouldBe(ErrorType.Validation); + } +} From 5bf6f0d5464d2ac8c8e2fd084afd1c8c62a3fa07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Mon, 12 Oct 2026 22:08:47 +0400 Subject: [PATCH 03/34] style: format role assignment test arguments --- .../Application/Users/Create/CreateUserHandlerTests.cs | 3 ++- .../Application/Users/Update/UpdateUserHandlerTests.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs index 83f245de..c9a4d775 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs @@ -106,7 +106,8 @@ public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() result.IsSuccess.ShouldBeTrue(); await repository.Received(1).AddAsync( Arg.Is(user => user.RoleIds.Single().Value == roleId), - "StrongPassword1!", cancellationToken); + "StrongPassword1!", + cancellationToken); } [Fact(DisplayName = "User handler should reject an empty role ID when role id is invalid")] diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs index e629dec8..c08f258a 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs @@ -106,7 +106,8 @@ public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() result.IsSuccess.ShouldBeTrue(); await repository.Received(1).UpdateAsync( Arg.Is(user => user.RoleIds.Single().Value == roleId), - cancellationToken, replaceRoles: true); + cancellationToken, + replaceRoles: true); } [Fact(DisplayName = "User handler should reject an empty role ID when role id is invalid")] From 15d2529524902d7484929b6b37b725bb3a968b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Mon, 12 Oct 2026 22:16:59 +0400 Subject: [PATCH 04/34] refactor: simplify role membership synchronization --- .../Features/Users/Write/UsersRepository.cs | 14 ++++---------- .../Seeding/IdentityRoleSeederTests.cs | 3 ++- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs index e2f5ed96..41d5a93e 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs @@ -211,20 +211,14 @@ private bool SyncRoles(ApplicationUser applicationUser, User user) return false; } - foreach (var currentRole in applicationUser.Roles.ToArray()) + foreach (var currentRole in applicationUser.Roles.Where(role => !roleIds.Contains(role.RoleId)).ToArray()) { - if (!roleIds.Contains(currentRole.RoleId)) - { - dbContext.Set().Remove(currentRole); - } + dbContext.Set().Remove(currentRole); } - foreach (var roleId in roleIds) + foreach (var roleId in roleIds.Where(id => applicationUser.Roles.All(role => role.RoleId != id))) { - if (applicationUser.Roles.All(role => role.RoleId != roleId)) - { - applicationUser.Roles.Add(new ApplicationUserRole { UserId = applicationUser.Id, RoleId = roleId }); - } + applicationUser.Roles.Add(new ApplicationUserRole { UserId = applicationUser.Id, RoleId = roleId }); } return true; diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs index a5ae8d59..dbaa52d7 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs @@ -29,7 +29,8 @@ public async Task SeedAsync_Should_CreatePredefinedRoles_When_RolesAreMissing() .ToArrayAsync(cancellationToken); roles.Select(role => role.Name).ShouldBe(RoleNames.All, ignoreOrder: true); roles.ShouldAllBe(role => role.Id != Guid.Empty && role.CreatedAt != default && role.UpdatedAt != default); - roles.ShouldAllBe(role => role.NormalizedName == role.Name!.ToUpperInvariant()); + roles.ShouldAllBe(role => string.Equals( + role.NormalizedName, role.Name!.ToUpperInvariant(), StringComparison.Ordinal)); roles.ShouldAllBe(role => role.Claims.Count == 0); } From 3a90cf61383eb435f8d8332f6bb9a95291e41010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Mon, 12 Oct 2026 22:32:49 +0400 Subject: [PATCH 05/34] fix: restore container build dependencies --- .github/workflows/ci.yml | 35 +++++++++++++++++++ src/PANiXiDA.TacticalHeroes.Host/Dockerfile | 2 ++ .../PANiXiDA.TacticalHeroes.Host.csproj | 1 + .../Layers/LayerDependencyTests.cs | 5 ++- .../ProjectReferenceDependencyTests.cs | 7 ++-- .../README.md | 18 +++++----- .../Dockerfile | 1 + 7 files changed, 54 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed5ce255..12dff458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,41 @@ jobs: if: ${{ github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'development') }} uses: PANiXiDA-Infrastructure/ci-cd/.github/workflows/resolve-environment.yml@main + validate-containers: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + service: [ api, migrator ] + include: + - service: api + folder: src/PANiXiDA.TacticalHeroes.Host + - service: migrator + folder: tools/PANiXiDA.TacticalHeroes.Ef.Migrator + steps: + - uses: actions/checkout@v7 + + - name: Create nuget.config + if: ${{ vars.NUGET_CONFIG != '' }} + env: + NUGET_CONFIG: ${{ vars.NUGET_CONFIG }} + NUGET_PROVIDER: ${{ vars.NUGET_PROVIDER }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: printf '%s\n' "$NUGET_CONFIG" | envsubst > nuget.config + + - uses: docker/setup-buildx-action@v4 + + - name: Build container + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.folder }}/Dockerfile + push: false + cache-from: type=gha,scope=pr-${{ matrix.service }} + cache-to: type=gha,mode=max,scope=pr-${{ matrix.service }} + build-api: if: ${{ github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'development') }} needs: [ format, tests, sonar, helm, resolve-env ] diff --git a/src/PANiXiDA.TacticalHeroes.Host/Dockerfile b/src/PANiXiDA.TacticalHeroes.Host/Dockerfile index f6df973b..8538c776 100644 --- a/src/PANiXiDA.TacticalHeroes.Host/Dockerfile +++ b/src/PANiXiDA.TacticalHeroes.Host/Dockerfile @@ -16,6 +16,7 @@ COPY ["nuget.config", "."] COPY ["Directory.Packages.props", "."] COPY ["Directory.Build.props", "."] COPY ["Directory.Build.targets", "."] +COPY ["src/PANiXiDA.TacticalHeroes.Common/PANiXiDA.TacticalHeroes.Common.csproj", "src/PANiXiDA.TacticalHeroes.Common/"] COPY ["src/PANiXiDA.TacticalHeroes.Host/PANiXiDA.TacticalHeroes.Host.csproj", "src/PANiXiDA.TacticalHeroes.Host/"] COPY ["src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Application/PANiXiDA.TacticalHeroes.FileManager.Application.csproj", "src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Application/"] COPY ["src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Contracts/PANiXiDA.TacticalHeroes.FileManager.Contracts.csproj", "src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Contracts/"] @@ -23,6 +24,7 @@ COPY ["src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Domain/PANiXiDA.Tacti COPY ["src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Infrastructure/PANiXiDA.TacticalHeroes.FileManager.Infrastructure.csproj", "src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Infrastructure/"] COPY ["src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Presentation/PANiXiDA.TacticalHeroes.FileManager.Presentation.csproj", "src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Presentation/"] COPY ["src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/PANiXiDA.TacticalHeroes.Identity.Application.csproj", "src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/"] +COPY ["src/Identity/PANiXiDA.TacticalHeroes.Identity.Contracts/PANiXiDA.TacticalHeroes.Identity.Contracts.csproj", "src/Identity/PANiXiDA.TacticalHeroes.Identity.Contracts/"] COPY ["src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/PANiXiDA.TacticalHeroes.Identity.Domain.csproj", "src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/"] COPY ["src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/PANiXiDA.TacticalHeroes.Identity.Infrastructure.csproj", "src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/"] COPY ["src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/PANiXiDA.TacticalHeroes.Identity.Presentation.csproj", "src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/"] diff --git a/src/PANiXiDA.TacticalHeroes.Host/PANiXiDA.TacticalHeroes.Host.csproj b/src/PANiXiDA.TacticalHeroes.Host/PANiXiDA.TacticalHeroes.Host.csproj index 20c1db40..81797040 100644 --- a/src/PANiXiDA.TacticalHeroes.Host/PANiXiDA.TacticalHeroes.Host.csproj +++ b/src/PANiXiDA.TacticalHeroes.Host/PANiXiDA.TacticalHeroes.Host.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Layers/LayerDependencyTests.cs b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Layers/LayerDependencyTests.cs index 616ee273..402fb482 100644 --- a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Layers/LayerDependencyTests.cs +++ b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Layers/LayerDependencyTests.cs @@ -102,14 +102,13 @@ public void PresentationLayer_Should_NotDependOnDomainInfrastructureOrHost_When_ } } - [Fact(DisplayName = "Contracts layer should only be used by infrastructure and presentation when validated")] - public void ContractsLayer_Should_OnlyBeUsedByInfrastructureAndPresentation_When_Validated() + [Fact(DisplayName = "Contracts layer should only be used by infrastructure presentation and host when validated")] + public void ContractsLayer_Should_OnlyBeUsedByInfrastructurePresentationAndHost_When_Validated() { var forbiddenSources = new[] { ArchitectureDefinition.s_domainLayer, ArchitectureDefinition.s_applicationLayer, - ArchitectureDefinition.s_hostLayer, ArchitectureDefinition.TypesInAssembly(ArchitectureDefinition.CommonAssemblyName) }; diff --git a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs index b637c045..6da8c2d2 100644 --- a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs +++ b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs @@ -46,8 +46,8 @@ public void ContractsProjects_Should_NotReferenceOtherProjects_When_Validated() } } - [Fact(DisplayName = "Projects should use contracts only from infrastructure and presentation when validated")] - public void ProductionProjectReferences_Should_UseContractsOnlyFromInfrastructureAndPresentation_When_Validated() + [Fact(DisplayName = "Projects should use contracts only from infrastructure presentation and host when validated")] + public void ProductionProjectReferences_Should_UseContractsOnlyFromInfrastructurePresentationAndHost_When_Validated() { var projectPaths = GetProjectPaths(FindRepositoryRoot()); var contractsAssemblyNames = ArchitectureDefinition.s_modules @@ -56,7 +56,8 @@ public void ProductionProjectReferences_Should_UseContractsOnlyFromInfrastructur var violations = projectPaths .Where(project => !project.Key.EndsWith(".Infrastructure", StringComparison.Ordinal) - && !project.Key.EndsWith(".Presentation", StringComparison.Ordinal)) + && !project.Key.EndsWith(".Presentation", StringComparison.Ordinal) + && !project.Key.EndsWith(".Host", StringComparison.Ordinal)) .SelectMany(project => GetProjectReferences(project.Value) .Where(contractsAssemblyNames.Contains) .Select(target => $"{project.Key} must not reference {target}.")) diff --git a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md index 96999e15..56f2b5db 100644 --- a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md +++ b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md @@ -20,15 +20,15 @@ dotnet test --project tests/PANiXiDA.TacticalHeroes.ArchitectureTests/PANiXiDA.T проект `Contracts` не должен содержать `ProjectReference`, включая ссылки на `Common` и Contracts других модулей. -3. `ContractsLayer_Should_OnlyBeUsedByInfrastructureAndPresentation_When_Validated` — типы - `Contracts` могут использоваться в `Infrastructure`, `Presentation` и внутри - самих контрактов. Для `Domain`, `Application`, `Host` и `Common` зависимости - на типы контрактов запрещены. - -4. `ProductionProjectReferences_Should_UseContractsOnlyFromInfrastructureAndPresentation_When_Validated` - — среди production-проектов только `Infrastructure` и `Presentation` могут - содержать прямые ссылки на `Contracts` своего или другого модуля. Проверка - охватывает также Host и Common; ссылки тестовых проектов не ограничиваются. +3. `ContractsLayer_Should_OnlyBeUsedByInfrastructurePresentationAndHost_When_Validated` — типы + `Contracts` могут использоваться в `Infrastructure`, `Presentation`, `Host` и внутри + самих контрактов. Host использует контракты в сгенерированных обработчиках Wolverine. + Для `Domain`, `Application` и `Common` зависимости на типы контрактов запрещены. + +4. `ProductionProjectReferences_Should_UseContractsOnlyFromInfrastructurePresentationAndHost_When_Validated` + — среди production-проектов только `Infrastructure`, `Presentation` и `Host` могут + содержать прямые ссылки на `Contracts` своего или другого модуля. Ссылки тестовых + проектов не ограничиваются. 5. `CommonLayer_Should_NotDependOnModuleLayersOrHost_When_Validated` — типы `Common` не должны зависеть от слоёв модулей или `Host`. diff --git a/tools/PANiXiDA.TacticalHeroes.Ef.Migrator/Dockerfile b/tools/PANiXiDA.TacticalHeroes.Ef.Migrator/Dockerfile index 18144bb2..e7538f7c 100644 --- a/tools/PANiXiDA.TacticalHeroes.Ef.Migrator/Dockerfile +++ b/tools/PANiXiDA.TacticalHeroes.Ef.Migrator/Dockerfile @@ -14,6 +14,7 @@ COPY ["nuget.config", "."] COPY ["Directory.Packages.props", "."] COPY ["Directory.Build.props", "."] COPY ["Directory.Build.targets", "."] +COPY ["src/PANiXiDA.TacticalHeroes.Common/PANiXiDA.TacticalHeroes.Common.csproj", "src/PANiXiDA.TacticalHeroes.Common/"] COPY ["tools/PANiXiDA.TacticalHeroes.Ef.Migrator/PANiXiDA.TacticalHeroes.Ef.Migrator.csproj", "tools/PANiXiDA.TacticalHeroes.Ef.Migrator/"] COPY ["src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Infrastructure/PANiXiDA.TacticalHeroes.FileManager.Infrastructure.csproj", "src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Infrastructure/"] COPY ["src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Application/PANiXiDA.TacticalHeroes.FileManager.Application.csproj", "src/FileManager/PANiXiDA.TacticalHeroes.FileManager.Application/"] From 61762355e297163a99664899f40a49392591c996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Mon, 12 Oct 2026 22:35:03 +0400 Subject: [PATCH 06/34] test: shorten contract dependency test name --- .../Modules/ProjectReferenceDependencyTests.cs | 2 +- tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs index 6da8c2d2..a6553d5d 100644 --- a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs +++ b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/Modules/ProjectReferenceDependencyTests.cs @@ -47,7 +47,7 @@ public void ContractsProjects_Should_NotReferenceOtherProjects_When_Validated() } [Fact(DisplayName = "Projects should use contracts only from infrastructure presentation and host when validated")] - public void ProductionProjectReferences_Should_UseContractsOnlyFromInfrastructurePresentationAndHost_When_Validated() + public void ProjectReferences_Should_UseContractsOnlyFromInfrastructurePresentationAndHost_When_Validated() { var projectPaths = GetProjectPaths(FindRepositoryRoot()); var contractsAssemblyNames = ArchitectureDefinition.s_modules diff --git a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md index 56f2b5db..fc3878a7 100644 --- a/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md +++ b/tests/PANiXiDA.TacticalHeroes.ArchitectureTests/README.md @@ -25,7 +25,7 @@ dotnet test --project tests/PANiXiDA.TacticalHeroes.ArchitectureTests/PANiXiDA.T самих контрактов. Host использует контракты в сгенерированных обработчиках Wolverine. Для `Domain`, `Application` и `Common` зависимости на типы контрактов запрещены. -4. `ProductionProjectReferences_Should_UseContractsOnlyFromInfrastructurePresentationAndHost_When_Validated` +4. `ProjectReferences_Should_UseContractsOnlyFromInfrastructurePresentationAndHost_When_Validated` — среди production-проектов только `Infrastructure`, `Presentation` и `Host` могут содержать прямые ссылки на `Contracts` своего или другого модуля. Ссылки тестовых проектов не ограничиваются. From 3d92010d57abe2939bb53a23a14a3103f68f9dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Mon, 12 Oct 2026 22:45:17 +0400 Subject: [PATCH 07/34] ci: restore existing workflow --- .github/workflows/ci.yml | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12dff458..ed5ce255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,41 +45,6 @@ jobs: if: ${{ github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'development') }} uses: PANiXiDA-Infrastructure/ci-cd/.github/workflows/resolve-environment.yml@main - validate-containers: - if: ${{ github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - service: [ api, migrator ] - include: - - service: api - folder: src/PANiXiDA.TacticalHeroes.Host - - service: migrator - folder: tools/PANiXiDA.TacticalHeroes.Ef.Migrator - steps: - - uses: actions/checkout@v7 - - - name: Create nuget.config - if: ${{ vars.NUGET_CONFIG != '' }} - env: - NUGET_CONFIG: ${{ vars.NUGET_CONFIG }} - NUGET_PROVIDER: ${{ vars.NUGET_PROVIDER }} - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: printf '%s\n' "$NUGET_CONFIG" | envsubst > nuget.config - - - uses: docker/setup-buildx-action@v4 - - - name: Build container - uses: docker/build-push-action@v7 - with: - context: . - file: ${{ matrix.folder }}/Dockerfile - push: false - cache-from: type=gha,scope=pr-${{ matrix.service }} - cache-to: type=gha,mode=max,scope=pr-${{ matrix.service }} - build-api: if: ${{ github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'development') }} needs: [ format, tests, sonar, helm, resolve-env ] From b3f2d7628f6c538128d63fb4b1bb737cb1aadbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 17:43:01 +0400 Subject: [PATCH 08/34] refactor: require role assignments and enumerate default roles --- README.md | 6 +- .../Users/Create/CreateUserCommand.cs | 2 +- .../Users/GetDetails/UserDetailsReadModel.cs | 5 +- .../Users/Update/UpdateUserCommand.cs | 2 +- .../Users/Update/UpdateUserHandler.cs | 3 +- .../Roles/Enumerations/RoleNames.cs | 13 +++ .../Roles/RoleNames.cs | 12 --- .../Users/Abstractions/IUsersRepository.cs | 3 +- .../Users/UserRoleAssignments.cs | 10 ++- .../Seeding/IdentityRoleSeeder.cs | 5 +- .../Services/UserCredentialsService.cs | 3 +- .../GetDetails/UserDetailsReadModelMapper.cs | 6 +- .../Features/Users/Write/UsersRepository.cs | 14 ++-- .../Users/Create/CreateUserRequest.cs | 2 +- .../GetDetails/GetUserDetailsResponse.cs | 6 +- .../Users/Update/UpdateUserRequest.cs | 2 +- .../Claims/ClaimAssignmentEndpointTests.cs | 3 +- .../Users/Create/CreateUserEndpointTests.cs | 3 +- .../Users/Update/UpdateUserEndpointTests.cs | 3 +- .../Features/Users/UserApiTestClient.cs | 3 +- .../Users/UserRoleAssignmentEndpointTests.cs | 23 +++-- .../Seeding/IdentityRoleSeederTests.cs | 7 +- .../Services/UserCredentialsServiceTests.cs | 8 +- .../Users/Write/UserRoleAssignmentTests.cs | 16 ++-- .../Create/CreateUserCommandValidatorTests.cs | 12 ++- .../Users/Create/CreateUserHandlerTests.cs | 6 +- .../GetDetails/GetUserDetailsHandlerTests.cs | 3 +- .../Update/UpdateUserCommandValidatorTests.cs | 6 +- .../Users/Update/UpdateUserHandlerTests.cs | 17 ++-- .../Roles/Enumerations/RoleNamesTests.cs | 83 +++++++++++++++++++ .../Domain/Users/UserRoleAssignmentsTests.cs | 19 +++-- 31 files changed, 207 insertions(+), 99 deletions(-) create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs delete mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNamesTests.cs diff --git a/README.md b/README.md index 9a8d56c7..0da905d6 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,9 @@ lowercase role-name convention. Seeding preserves existing role IDs and claims; new roles have no permission/section claims. Public registration assigns Developer. Existing users are not reassigned automatically. -User create/update requests accept optional `roleIds` (GUID array); get-details -returns the selected IDs. On update, an omitted/null field preserves roles and `[]` -clears them. On administrative creation, omitted/null means no roles. Empty GUIDs, +User create/update requests require `roleIds` (GUID array), just like `claims`; +get-details returns the selected IDs. Updates replace the complete role selection; +`[]` means no roles. Omitted/null collections are rejected. Empty GUIDs, duplicates, and unknown role IDs are rejected before persistence. Use the existing roles list endpoint to populate selection options. Role changes rotate the security stamp and revoke the user's tokens. No database migration or new configuration is required. diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs index 450c68b8..990e83fe 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommand.cs @@ -9,4 +9,4 @@ public sealed record CreateUserCommand( bool IsConfirmed, IReadOnlyCollection Claims, string Status, - IReadOnlyCollection? RoleIds = null) : ICommand>; + IReadOnlyCollection RoleIds) : ICommand>; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs index d515db56..6a5df995 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/GetDetails/UserDetailsReadModel.cs @@ -11,10 +11,9 @@ public sealed record UserDetailsReadModel( bool IsConfirmed, string Status, string StatusDisplayName, - IReadOnlyCollection Claims) : IReadModel + IReadOnlyCollection Claims, + IReadOnlyCollection RoleIds) : IReadModel { - public IReadOnlyCollection RoleIds { get; init; } = []; - public bool IsBlocked => string.Equals( Status, UserStatus.Blocked.Name, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs index 790b1de7..2f2867b6 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommand.cs @@ -9,4 +9,4 @@ public sealed record UpdateUserCommand( bool IsConfirmed, IReadOnlyCollection Claims, string Status, - IReadOnlyCollection? RoleIds = null) : ICommand; + IReadOnlyCollection RoleIds) : ICommand; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs index 7c3208b5..fd2995e2 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs @@ -53,7 +53,6 @@ public Task HandleAsync( return usersRepository.UpdateAsync( user: user, - cancellationToken: cancellationToken, - replaceRoles: command.RoleIds is not null); + cancellationToken: cancellationToken); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs new file mode 100644 index 00000000..97465527 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs @@ -0,0 +1,13 @@ +namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; + +public sealed partial class RoleNames : Enumeration +{ + public static readonly RoleNames Developer = new(id: 1, name: "developer"); + public static readonly RoleNames Moderator = new(id: 2, name: "moderator"); + public static readonly RoleNames Player = new(id: 3, name: "player"); + + private RoleNames(int id, string name) + : base(id, name) + { + } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs deleted file mode 100644 index 2a05f784..00000000 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleNames.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Immutable; - -namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles; - -public static class RoleNames -{ - public const string Developer = "developer"; - public const string Moderator = "moderator"; - public const string Player = "player"; - - public static ImmutableArray All { get; } = [Developer, Moderator, Player]; -} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs index fd47a065..a1b374d9 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/Abstractions/IUsersRepository.cs @@ -9,8 +9,7 @@ Task> AddAsync( Task UpdateAsync( User user, - CancellationToken cancellationToken, - bool replaceRoles = true); + CancellationToken cancellationToken); Task DeleteAsync( Guid id, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs index e74d4c0a..99df8883 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs @@ -4,11 +4,17 @@ namespace PANiXiDA.TacticalHeroes.Identity.Domain.Users; public static class UserRoleAssignments { - public static Result> Create(IReadOnlyCollection? values) + public static Result> Create(IReadOnlyCollection values) { + if (values is null) + { + return Result.Failure>( + error: Error.Validation(message: "Role IDs are required.").WithField(nameof(User.RoleIds))); + } + var roleIds = new List(); var seen = new HashSet(); - foreach (var value in values ?? []) + foreach (var value in values) { if (!seen.Add(value)) { diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs index b78e4f35..8ce2b327 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Identity; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; @@ -9,8 +9,9 @@ internal sealed class IdentityRoleSeeder(RoleManager roleManage { public async Task SeedAsync(CancellationToken cancellationToken) { - foreach (var name in RoleNames.All) + foreach (var role in RoleNames.GetAll()) { + var name = role.Name; cancellationToken.ThrowIfCancellationRequested(); if (await roleManager.FindByNameAsync(name) is not null) { diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs index 1d372657..cbb49569 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs @@ -5,6 +5,7 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Login; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; @@ -42,7 +43,7 @@ public async Task> RegisterAsync( return Result.Failure(errors: validationResult.Errors); } - var developerRole = await roleManager.FindByNameAsync(RoleNames.Developer); + var developerRole = await roleManager.FindByNameAsync(RoleNames.Developer.Name); if (developerRole is null) { return Result.Failure(error: Error.NotFound(message: "Developer role was not found.")); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs index 3df3317a..09c36d1f 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs @@ -23,9 +23,7 @@ public static IQueryable ProjectTo( .Select(claim => new Claim( type: claim.ClaimType!, value: claim.ClaimValue!)) - .ToArray()) - { - RoleIds = user.Roles.Select(role => role.RoleId).ToArray() - }); + .ToArray(), + RoleIds: user.Roles.Select(role => role.RoleId).ToArray())); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs index 41d5a93e..0f00a251 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs @@ -52,8 +52,7 @@ public async Task> AddAsync( public async Task UpdateAsync( User user, - CancellationToken cancellationToken, - bool replaceRoles = true) + CancellationToken cancellationToken) { var applicationUser = await userManager.Users .WithAuthorizationGraph() @@ -64,16 +63,13 @@ public async Task UpdateAsync( return UserNotFound(); } - if (replaceRoles) + var rolesResult = await ValidateRolesAsync(user, cancellationToken); + if (rolesResult.IsFailure) { - var rolesResult = await ValidateRolesAsync(user, cancellationToken); - if (rolesResult.IsFailure) - { - return rolesResult; - } + return rolesResult; } - var rolesChanged = replaceRoles && SyncRoles(applicationUser, user); + var rolesChanged = SyncRoles(applicationUser, user); ApplicationUserMapper.MapToDbModel( user: user, dbModel: applicationUser, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs index d1cde26b..feadac50 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Create/CreateUserRequest.cs @@ -9,4 +9,4 @@ public sealed record CreateUserRequest( bool IsConfirmed, IReadOnlyCollection Claims, string Status, - IReadOnlyCollection? RoleIds = null); + IReadOnlyCollection RoleIds); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs index 3f02daf7..1fcae8df 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/GetDetails/GetUserDetailsResponse.cs @@ -9,7 +9,5 @@ public sealed record GetUserDetailsResponse( bool IsConfirmed, string Status, string StatusDisplayName, - IReadOnlyCollection Claims) -{ - public IReadOnlyCollection RoleIds { get; init; } = []; -} + IReadOnlyCollection Claims, + IReadOnlyCollection RoleIds); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs index 2c5def62..dc11288b 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Users/Update/UpdateUserRequest.cs @@ -8,4 +8,4 @@ public sealed record UpdateUserRequest( bool IsConfirmed, IReadOnlyCollection Claims, string Status, - IReadOnlyCollection? RoleIds = null); + IReadOnlyCollection RoleIds); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs index 406c9a9b..df2e44be 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs @@ -106,7 +106,8 @@ await client.UpdateAsync( UserName: request.UserName, IsConfirmed: request.IsConfirmed, Claims: claims, - Status: request.Status), + Status: request.Status, + RoleIds: []), cancellationToken); } var details = await client.GetDetailsAsync(user.Id, cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs index 766bdbb6..efa5b4af 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs @@ -17,7 +17,8 @@ public async Task PostUsers_Should_PersistUser_When_RequestIsValid() "StrongPassword1!", true, [new Claim("permission", "compendium.heroes.read")], - "Active"); + "Active", + RoleIds: []); var response = await client.CreateAsync(cancellationToken, request); var user = await UserDatabaseTestHelper.FindAsync( diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs index 48826677..69a17688 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs @@ -20,7 +20,8 @@ await client.UpdateAsync( "updated-hero", false, [new Claim("permission", "compendium.heroes.update")], - "Blocked"), + "Blocked", + RoleIds: []), cancellationToken); var user = await UserDatabaseTestHelper.FindAsync( Fixture, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserApiTestClient.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserApiTestClient.cs index e55b3b7a..0b44e1a1 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserApiTestClient.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserApiTestClient.cs @@ -22,7 +22,8 @@ internal static CreateUserRequest CreateDefaultRequest( "StrongPassword1!", isConfirmed, [], - "Active"); + "Active", + RoleIds: []); } internal async Task CreateAsync( diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs index 6705fb48..1f1581b9 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs @@ -30,10 +30,9 @@ public async Task PostUsers_Should_AssignMultipleRoles_When_RolesAreSelected() user.Roles.Select(role => role.RoleId).ShouldBe(selected, ignoreOrder: true); } - [Theory(DisplayName = "User edits should replace clear or preserve roles when role selections vary")] + [Theory(DisplayName = "User edits should replace or clear roles when role selections vary")] [InlineData("replace")] [InlineData("clear")] - [InlineData("omit")] public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(string mode) { var cancellationToken = TestContext.Current.CancellationToken; @@ -43,7 +42,7 @@ public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(stri var client = new UserApiTestClient(Fixture); var created = await client.CreateAsync( cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); - IReadOnlyCollection? selected = mode == "omit" ? null : mode == "clear" ? [] : [moderatorId]; + IReadOnlyCollection selected = mode == "clear" ? [] : [moderatorId]; var payload = JsonSerializer.SerializeToNode( new UpdateUserRequest( Email: "edited@example.com", @@ -54,10 +53,6 @@ public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(stri RoleIds: selected), JsonOptions)! .AsObject(); - if (mode == "omit") - { - payload.Remove("roleIds"); - } using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{created.Id}") { Content = JsonContent.Create(payload, options: JsonOptions) @@ -68,10 +63,14 @@ public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(stri response.StatusCode.ShouldBe(HttpStatusCode.NoContent); details.Email.ShouldBe("edited@example.com"); - details.RoleIds.ShouldBe(mode == "omit" ? [developerId] : selected!, ignoreOrder: true); + details.RoleIds.ShouldBe(selected, ignoreOrder: true); } [Theory(DisplayName = "User requests should reject invalid role selections when role selection is invalid")] + [InlineData(false, "omitted")] + [InlineData(true, "omitted")] + [InlineData(false, "null")] + [InlineData(true, "null")] [InlineData(false, "missing")] [InlineData(true, "missing")] [InlineData(false, "empty")] @@ -92,6 +91,14 @@ public async Task SaveUser_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(b var payload = JsonSerializer.SerializeToNode( UserApiTestClient.CreateDefaultRequest() with { RoleIds = roleIds }, JsonOptions)! .AsObject(); + if (invalid == "omitted") + { + payload.Remove("roleIds"); + } + else if (invalid == "null") + { + payload["roleIds"] = null; + } var path = "/api/v1/users"; Guid? existingId = null; if (update) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs index dbaa52d7..a273db5b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; @@ -27,7 +28,7 @@ public async Task SeedAsync_Should_CreatePredefinedRoles_When_RolesAreMissing() .Include(role => role.Claims) .AsNoTracking() .ToArrayAsync(cancellationToken); - roles.Select(role => role.Name).ShouldBe(RoleNames.All, ignoreOrder: true); + roles.Select(role => role.Name).ShouldBe(RoleNames.GetAll().Select(role => role.Name), ignoreOrder: true); roles.ShouldAllBe(role => role.Id != Guid.Empty && role.CreatedAt != default && role.UpdatedAt != default); roles.ShouldAllBe(role => string.Equals( role.NormalizedName, role.Name!.ToUpperInvariant(), StringComparison.Ordinal)); @@ -42,14 +43,14 @@ public async Task SeedAsync_Should_PreserveIdsAndClaims_When_RolesAlreadyExist() var seeder = scope.ServiceProvider.GetRequiredService(); var manager = scope.ServiceProvider.GetRequiredService>(); await seeder.SeedAsync(cancellationToken); - var role = await manager.FindByNameAsync(RoleNames.Developer); + var role = await manager.FindByNameAsync(RoleNames.Developer.Name); role.ShouldNotBeNull(); var id = role.Id; (await manager.AddClaimAsync(role, new Claim("permission", "identity.users.read"))).Succeeded.ShouldBeTrue(); await seeder.SeedAsync(cancellationToken); - var existing = await manager.FindByNameAsync(RoleNames.Developer); + var existing = await manager.FindByNameAsync(RoleNames.Developer.Name); existing.ShouldNotBeNull(); existing.Id.ShouldBe(id); (await manager.GetClaimsAsync(existing)).ShouldHaveSingleItem().Value.ShouldBe("identity.users.read"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index 565422c7..be729a84 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs @@ -8,7 +8,7 @@ using OpenIddict.EntityFrameworkCore.Models; using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Abstractions; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Events; @@ -48,7 +48,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr user.Email.ShouldBe("register@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); - (await userManager.GetRolesAsync(user)).ShouldBe([RoleNames.Developer]); + (await userManager.GetRolesAsync(user)).ShouldBe([RoleNames.Developer.Name]); var aggregateTracker = scope.ServiceProvider.GetRequiredService(); var trackedUser = aggregateTracker.GetAll() @@ -102,8 +102,8 @@ public async Task RegisterAsync_Should_RejectInvalidDefaultRole_When_DefaultRole dbContext.Set().Add(new ApplicationRole { Id = Guid.Empty, - Name = RoleNames.Developer, - NormalizedName = RoleNames.Developer.ToUpperInvariant() + Name = RoleNames.Developer.Name, + NormalizedName = RoleNames.Developer.Name.ToUpperInvariant() }); await dbContext.SaveChangesAsync(cancellationToken); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs index 7350e570..58d0d152 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -5,6 +5,7 @@ using OpenIddict.EntityFrameworkCore.Models; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; @@ -19,7 +20,6 @@ namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persi public sealed class UserRoleAssignmentTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) { [Theory(DisplayName = "User updates should synchronize roles and sessions when role assignments vary")] - [InlineData("omit")] [InlineData("same")] [InlineData("replace")] [InlineData("add")] @@ -35,15 +35,15 @@ public async Task UpdateAsync_Should_SynchronizeRolesAndSessions_When_RoleAssign role => role.Name!, role => role.Id, cancellationToken); var userId = Guid.CreateVersion7(); var created = await repository.AddAsync( - CreateUser(userId, [roles[RoleNames.Developer]]), "StrongPassword1!", cancellationToken); + CreateUser(userId, [roles[RoleNames.Developer.Name]]), "StrongPassword1!", cancellationToken); created.IsSuccess.ShouldBeTrue(); var originalStamp = (await dbContext.Set() .SingleAsync(user => user.Id == userId, cancellationToken)).SecurityStamp; var targetRoles = mode switch { - "same" => new[] { roles[RoleNames.Developer] }, - "replace" => [roles[RoleNames.Moderator]], - "add" => [roles[RoleNames.Developer], roles[RoleNames.Player]], + "same" => new[] { roles[RoleNames.Developer.Name] }, + "replace" => [roles[RoleNames.Moderator.Name]], + "add" => [roles[RoleNames.Developer.Name], roles[RoleNames.Player.Name]], _ => [] }; var tokenManager = scope.ServiceProvider.GetRequiredService(); @@ -62,16 +62,14 @@ await tokenManager.CreateAsync( var result = await repository.UpdateAsync( user: CreateUser(userId, targetRoles), - cancellationToken: cancellationToken, - replaceRoles: mode != "omit"); + cancellationToken: cancellationToken); result.IsSuccess.ShouldBeTrue(); var user = await dbContext.Set() .Include(item => item.Roles) .AsNoTracking() .SingleAsync(item => item.Id == userId, cancellationToken); - var expectedRoles = mode == "omit" ? [roles[RoleNames.Developer]] : targetRoles; - user.Roles.Select(role => role.RoleId).ShouldBe(expectedRoles, ignoreOrder: true); + user.Roles.Select(role => role.RoleId).ShouldBe(targetRoles, ignoreOrder: true); var changed = mode is "replace" or "add" or "clear"; (user.SecurityStamp != originalStamp).ShouldBe(changed); var tokens = await dbContext.Set>() diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs index 7c9e41b6..1a0e5592 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs @@ -18,7 +18,8 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() "StrongPassword1!", true, [new Claim("permission", "compendium.heroes.read")], - "Active")); + "Active", + RoleIds: [])); result.IsValid.ShouldBeTrue(); } @@ -29,7 +30,14 @@ public void Validate_Should_ReturnErrors_When_CommandIsInvalid() var validator = new CreateUserCommandValidator(); var result = validator.Validate( - new CreateUserCommand("invalid", "", "", false, [new Claim("", "")], "Deleted")); + new CreateUserCommand( + "invalid", + "", + "", + false, + [new Claim("", "")], + "Deleted", + [])); result.Errors.ShouldContain(error => error.PropertyName == nameof(CreateUserCommand.Email)); result.Errors.ShouldContain(error => error.PropertyName == nameof(CreateUserCommand.UserName)); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs index c9a4d775..ed3e5d4b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserHandlerTests.cs @@ -35,7 +35,8 @@ public async Task HandleAsync_Should_ReturnUserId_When_RepositorySucceeds() "StrongPassword1!", true, claims, - "Active"), + "Active", + RoleIds: []), cancellationToken); result.IsSuccess.ShouldBeTrue(); @@ -74,7 +75,8 @@ public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_W "StrongPassword1!", false, [new Claim(claimType, claimValue)], - status), + status, + RoleIds: []), cancellationToken); result.IsFailure.ShouldBeTrue(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/GetDetails/GetUserDetailsHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/GetDetails/GetUserDetailsHandlerTests.cs index c86cf72e..76051103 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/GetDetails/GetUserDetailsHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/GetDetails/GetUserDetailsHandlerTests.cs @@ -16,7 +16,8 @@ public async Task HandleAsync_Should_ReturnUser_When_UserExists() true, "Active", "Active", - []); + [], + RoleIds: []); var usersReadRepository = Substitute.For(); usersReadRepository.GetDetailsByIdAsync(userId, Arg.Any()) .Returns(readModel); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs index aedf5159..fab6ee3a 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserCommandValidatorTests.cs @@ -18,7 +18,8 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() "hero", true, [new Claim("permission", "compendium.heroes.update")], - "Blocked")); + "Blocked", + RoleIds: [])); result.IsValid.ShouldBeTrue(); } @@ -35,7 +36,8 @@ public void Validate_Should_ReturnErrors_When_CommandIsInvalid() "", false, [new Claim("", "")], - "Deleted")); + "Deleted", + RoleIds: [])); result.Errors.ShouldContain(error => error.PropertyName == nameof(UpdateUserCommand.Id)); result.Errors.ShouldContain(error => error.PropertyName == nameof(UpdateUserCommand.Email)); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs index c08f258a..3319675b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Update/UpdateUserHandlerTests.cs @@ -22,8 +22,7 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() && user.Status == UserStatus.Blocked && user.ConfirmationStatus.IsConfirmed && user.Claims.Any(claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), - Arg.Any(), - replaceRoles: false) + Arg.Any()) .Returns(Result.Success()); var handler = new UpdateUserHandler(repository); var cancellationToken = TestContext.Current.CancellationToken; @@ -35,7 +34,8 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() "hero", true, claims, - "Blocked"), + "Blocked", + RoleIds: []), cancellationToken); result.IsSuccess.ShouldBeTrue(); @@ -46,8 +46,7 @@ await repository.Received(1).UpdateAsync( && user.Status == UserStatus.Blocked && user.ConfirmationStatus.IsConfirmed && user.Claims.Any(claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), - cancellationToken, - replaceRoles: false); + cancellationToken); } [Theory( @@ -74,7 +73,8 @@ public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_W userName, false, [new Claim(claimType, claimValue)], - status), + status, + RoleIds: []), cancellationToken); result.IsFailure.ShouldBeTrue(); @@ -88,7 +88,7 @@ public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() var cancellationToken = TestContext.Current.CancellationToken; var roleId = Guid.CreateVersion7(); var repository = Substitute.For(); - repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + repository.UpdateAsync(Arg.Any(), Arg.Any()) .Returns(Result.Success()); var handler = new UpdateUserHandler(repository); @@ -106,8 +106,7 @@ public async Task HandleAsync_Should_PassRoleIds_When_RoleIdsAreProvided() result.IsSuccess.ShouldBeTrue(); await repository.Received(1).UpdateAsync( Arg.Is(user => user.RoleIds.Single().Value == roleId), - cancellationToken, - replaceRoles: true); + cancellationToken); } [Fact(DisplayName = "User handler should reject an empty role ID when role id is invalid")] diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNamesTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNamesTests.cs new file mode 100644 index 00000000..ba2fa16d --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNamesTests.cs @@ -0,0 +1,83 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; + +namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.Enumerations; + +public sealed class RoleNamesTests +{ + [Fact(DisplayName = "Default roles should retain their identifier order when called")] + public void GetAll_Should_ReturnRolesInIdOrder_When_Called() + { + var roles = RoleNames.GetAll(); + + roles.ShouldBe([RoleNames.Developer, RoleNames.Moderator, RoleNames.Player]); + } + + [Theory(DisplayName = "Default role should resolve its name when id is known")] + [InlineData(1, "developer")] + [InlineData(2, "moderator")] + [InlineData(3, "player")] + public void FromId_Should_ReturnRole_When_IdIsKnown(int id, string name) + { + var role = RoleNames.FromId(id); + + role.Name.ShouldBe(name); + } + + [Fact(DisplayName = "Default role should reject an unknown identifier when id is unknown")] + public void FromId_Should_Throw_When_IdIsUnknown() + { + Action action = () => RoleNames.FromId(0); + + action.ShouldThrow(); + } + + [Theory(DisplayName = "Default role should resolve a trimmed name when name is known")] + [InlineData("developer", 1)] + [InlineData(" moderator ", 2)] + [InlineData("player", 3)] + public void FromName_Should_ReturnRole_When_NameIsKnown(string name, int id) + { + var role = RoleNames.FromName(name); + + role.Id.ShouldBe(id); + } + + [Theory(DisplayName = "Default role should reject an invalid name when name is invalid")] + [InlineData("admin")] + [InlineData("Developer")] + [InlineData(" ")] + public void FromName_Should_Throw_When_NameIsInvalid(string name) + { + Action action = () => RoleNames.FromName(name); + + action.ShouldThrow(); + } + + [Theory(DisplayName = "Default role lookup should report a match when id is provided")] + [InlineData(1, "developer")] + [InlineData(2, "moderator")] + [InlineData(3, "player")] + [InlineData(0, null)] + public void TryFromId_Should_ReportMatch_When_IdIsProvided(int id, string? expectedName) + { + var found = RoleNames.TryFromId(id, out var role); + + found.ShouldBe(expectedName is not null); + (role?.Name).ShouldBe(expectedName); + } + + [Theory(DisplayName = "Default role lookup should report a match when name is provided")] + [InlineData("developer", 1)] + [InlineData(" moderator ", 2)] + [InlineData("player", 3)] + [InlineData("admin", null)] + [InlineData("Developer", null)] + [InlineData(" ", null)] + public void TryFromName_Should_ReportMatch_When_NameIsProvided(string name, int? expectedId) + { + var found = RoleNames.TryFromName(name, out var role); + + found.ShouldBe(expectedId.HasValue); + (role?.Id).ShouldBe(expectedId); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs index 4e17b962..8b0424ac 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs @@ -4,19 +4,24 @@ namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Users; public sealed class UserRoleAssignmentsTests { - [Theory(DisplayName = "Role assignments should accept empty selections when roles are omitted")] - [InlineData(false)] - [InlineData(true)] - public void Create_Should_ReturnEmptySelection_When_RolesAreOmitted(bool nullValue) + [Fact(DisplayName = "Role assignments should accept empty selections when roles are empty")] + public void Create_Should_ReturnEmptySelection_When_RolesAreEmpty() { - IReadOnlyCollection? roles = nullValue ? null : []; - - var result = UserRoleAssignments.Create(roles); + var result = UserRoleAssignments.Create([]); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBeEmpty(); } + [Fact(DisplayName = "Role assignments should reject a missing selection when roles are null")] + public void Create_Should_ReturnValidationError_When_RolesAreNull() + { + var result = UserRoleAssignments.Create(null!); + + result.ShouldHaveSingleError(ErrorType.Validation, "Role IDs are required.") + .ShouldHaveField(nameof(User.RoleIds)); + } + [Fact(DisplayName = "Role assignments should preserve identifiers when roles are valid")] public void Create_Should_PreserveIdentifiers_When_RolesAreValid() { From d926c215bd03e699efff9ce3740c5dafa0fa3efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 17:45:36 +0400 Subject: [PATCH 09/34] fix: pin patched cryptography dependencies --- Directory.Packages.props | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index a2ba79e9..9b67fcc5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,6 +39,8 @@ + + From d631bb0a1ff1a38eb38d7044c2363da405d0b852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 17:47:27 +0400 Subject: [PATCH 10/34] style: remove unused role imports --- .../IdentityProvider/Seeding/IdentityRoleSeederTests.cs | 1 - .../Persistence/Features/Users/Write/UserRoleAssignmentTests.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs index a273db5b..804d4ae5 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs index 58d0d152..16ca34b2 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -4,7 +4,6 @@ using OpenIddict.Abstractions; using OpenIddict.EntityFrameworkCore.Models; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; From 290f0bffdd31c3feb1a887bed71ace4d726588e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 17:49:27 +0400 Subject: [PATCH 11/34] test: require role ids in user OpenAPI schemas --- .../Presentation/Features/Users/UserRequestValidationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs index 43266312..5b182a12 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs @@ -88,8 +88,8 @@ public async Task GetOpenApiDocument_Should_PreserveUserJsonContract_When_Reques .Select(property => property.GetString()!) .ToArray(); string[] expectedProperties = includePassword - ? ["email", "userName", "password", "isConfirmed", "claims", "status"] - : ["email", "userName", "isConfirmed", "claims", "status"]; + ? ["email", "userName", "password", "isConfirmed", "claims", "status", "roleIds"] + : ["email", "userName", "isConfirmed", "claims", "status", "roleIds"]; requiredProperties.ShouldBe(expectedProperties, ignoreOrder: true); var status = schema.GetProperty("properties").GetProperty("status"); status.GetProperty("type").GetString().ShouldBe("string"); From 78d6cb231ff3a94b24277bb7d7ffc4f5d82e8476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 17:55:31 +0400 Subject: [PATCH 12/34] refactor: select role names before seeding --- .../IdentityProvider/Seeding/IdentityRoleSeeder.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs index 8ce2b327..b4a01a5c 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs @@ -9,9 +9,8 @@ internal sealed class IdentityRoleSeeder(RoleManager roleManage { public async Task SeedAsync(CancellationToken cancellationToken) { - foreach (var role in RoleNames.GetAll()) + foreach (var name in RoleNames.GetAll().Select(role => role.Name)) { - var name = role.Name; cancellationToken.ThrowIfCancellationRequested(); if (await roleManager.FindByNameAsync(name) is not null) { From e7a82ca6973a290f11aa9f6924c75b397a507141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 18:34:03 +0400 Subject: [PATCH 13/34] refactor: align role enumeration naming and display names --- .../Roles/Enumerations/RoleName.cs | 16 ++++++ .../Roles/Enumerations/RoleNames.cs | 13 ----- .../Seeding/IdentityRoleSeeder.cs | 5 +- .../Services/UserCredentialsService.cs | 2 +- .../Seeding/IdentityRoleSeederTests.cs | 6 +-- .../Services/UserCredentialsServiceTests.cs | 6 +-- .../Users/Write/UserRoleAssignmentTests.cs | 10 ++-- .../{RoleNamesTests.cs => RoleNameTests.cs} | 49 ++++++++++--------- 8 files changed, 57 insertions(+), 50 deletions(-) create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs delete mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs rename tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/{RoleNamesTests.cs => RoleNameTests.cs} (64%) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs new file mode 100644 index 00000000..1566f7bb --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs @@ -0,0 +1,16 @@ +namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; + +public sealed partial class RoleName : Enumeration +{ + public static readonly RoleName Developer = new(id: 1, name: nameof(Developer), displayName: "Разработчик"); + public static readonly RoleName Moderator = new(id: 2, name: nameof(Moderator), displayName: "Модератор"); + public static readonly RoleName Player = new(id: 3, name: nameof(Player), displayName: "Игрок"); + + private RoleName(int id, string name, string displayName) + : base(id, name) + { + DisplayName = displayName; + } + + public string DisplayName { get; } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs deleted file mode 100644 index 97465527..00000000 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleNames.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; - -public sealed partial class RoleNames : Enumeration -{ - public static readonly RoleNames Developer = new(id: 1, name: "developer"); - public static readonly RoleNames Moderator = new(id: 2, name: "moderator"); - public static readonly RoleNames Player = new(id: 3, name: "player"); - - private RoleNames(int id, string name) - : base(id, name) - { - } -} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs index b4a01a5c..e74769d8 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs @@ -1,15 +1,18 @@ using Microsoft.AspNetCore.Identity; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; + using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; +using RoleNameValue = PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects.RoleName; + namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; internal sealed class IdentityRoleSeeder(RoleManager roleManager, TimeProvider timeProvider) { public async Task SeedAsync(CancellationToken cancellationToken) { - foreach (var name in RoleNames.GetAll().Select(role => role.Name)) + foreach (var name in RoleName.GetAll().Select(role => RoleNameValue.Create(role.Name).Value.Value)) { cancellationToken.ThrowIfCancellationRequested(); if (await roleManager.FindByNameAsync(name) is not null) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs index cbb49569..87c43328 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs @@ -43,7 +43,7 @@ public async Task> RegisterAsync( return Result.Failure(errors: validationResult.Errors); } - var developerRole = await roleManager.FindByNameAsync(RoleNames.Developer.Name); + var developerRole = await roleManager.FindByNameAsync(RoleName.Developer.Name); if (developerRole is null) { return Result.Failure(error: Error.NotFound(message: "Developer role was not found.")); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs index 804d4ae5..635293ff 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs @@ -27,7 +27,7 @@ public async Task SeedAsync_Should_CreatePredefinedRoles_When_RolesAreMissing() .Include(role => role.Claims) .AsNoTracking() .ToArrayAsync(cancellationToken); - roles.Select(role => role.Name).ShouldBe(RoleNames.GetAll().Select(role => role.Name), ignoreOrder: true); + roles.Select(role => role.Name).ShouldBe(["developer", "moderator", "player"], ignoreOrder: true); roles.ShouldAllBe(role => role.Id != Guid.Empty && role.CreatedAt != default && role.UpdatedAt != default); roles.ShouldAllBe(role => string.Equals( role.NormalizedName, role.Name!.ToUpperInvariant(), StringComparison.Ordinal)); @@ -42,14 +42,14 @@ public async Task SeedAsync_Should_PreserveIdsAndClaims_When_RolesAlreadyExist() var seeder = scope.ServiceProvider.GetRequiredService(); var manager = scope.ServiceProvider.GetRequiredService>(); await seeder.SeedAsync(cancellationToken); - var role = await manager.FindByNameAsync(RoleNames.Developer.Name); + var role = await manager.FindByNameAsync(RoleName.Developer.Name); role.ShouldNotBeNull(); var id = role.Id; (await manager.AddClaimAsync(role, new Claim("permission", "identity.users.read"))).Succeeded.ShouldBeTrue(); await seeder.SeedAsync(cancellationToken); - var existing = await manager.FindByNameAsync(RoleNames.Developer.Name); + var existing = await manager.FindByNameAsync(RoleName.Developer.Name); existing.ShouldNotBeNull(); existing.Id.ShouldBe(id); (await manager.GetClaimsAsync(existing)).ShouldHaveSingleItem().Value.ShouldBe("identity.users.read"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index be729a84..eff02986 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs @@ -48,7 +48,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr user.Email.ShouldBe("register@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); - (await userManager.GetRolesAsync(user)).ShouldBe([RoleNames.Developer.Name]); + (await userManager.GetRolesAsync(user)).ShouldBe([RoleName.Developer.Name.ToLowerInvariant()]); var aggregateTracker = scope.ServiceProvider.GetRequiredService(); var trackedUser = aggregateTracker.GetAll() @@ -102,8 +102,8 @@ public async Task RegisterAsync_Should_RejectInvalidDefaultRole_When_DefaultRole dbContext.Set().Add(new ApplicationRole { Id = Guid.Empty, - Name = RoleNames.Developer.Name, - NormalizedName = RoleNames.Developer.Name.ToUpperInvariant() + Name = RoleName.Developer.Name, + NormalizedName = RoleName.Developer.Name.ToUpperInvariant() }); await dbContext.SaveChangesAsync(cancellationToken); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs index 16ca34b2..0f767123 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -31,18 +31,18 @@ public async Task UpdateAsync_Should_SynchronizeRolesAndSessions_When_RoleAssign var repository = scope.ServiceProvider.GetRequiredService(); await scope.ServiceProvider.GetRequiredService().SeedAsync(cancellationToken); var roles = await dbContext.Set().ToDictionaryAsync( - role => role.Name!, role => role.Id, cancellationToken); + role => role.Name!, role => role.Id, StringComparer.OrdinalIgnoreCase, cancellationToken); var userId = Guid.CreateVersion7(); var created = await repository.AddAsync( - CreateUser(userId, [roles[RoleNames.Developer.Name]]), "StrongPassword1!", cancellationToken); + CreateUser(userId, [roles[RoleName.Developer.Name]]), "StrongPassword1!", cancellationToken); created.IsSuccess.ShouldBeTrue(); var originalStamp = (await dbContext.Set() .SingleAsync(user => user.Id == userId, cancellationToken)).SecurityStamp; var targetRoles = mode switch { - "same" => new[] { roles[RoleNames.Developer.Name] }, - "replace" => [roles[RoleNames.Moderator.Name]], - "add" => [roles[RoleNames.Developer.Name], roles[RoleNames.Player.Name]], + "same" => new[] { roles[RoleName.Developer.Name] }, + "replace" => [roles[RoleName.Moderator.Name]], + "add" => [roles[RoleName.Developer.Name], roles[RoleName.Player.Name]], _ => [] }; var tokenManager = scope.ServiceProvider.GetRequiredService(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNamesTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs similarity index 64% rename from tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNamesTests.cs rename to tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs index ba2fa16d..b02f005d 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNamesTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs @@ -2,80 +2,81 @@ namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.Enumerations; -public sealed class RoleNamesTests +public sealed class RoleNameTests { [Fact(DisplayName = "Default roles should retain their identifier order when called")] public void GetAll_Should_ReturnRolesInIdOrder_When_Called() { - var roles = RoleNames.GetAll(); + var roles = RoleName.GetAll(); - roles.ShouldBe([RoleNames.Developer, RoleNames.Moderator, RoleNames.Player]); + roles.ShouldBe([RoleName.Developer, RoleName.Moderator, RoleName.Player]); } [Theory(DisplayName = "Default role should resolve its name when id is known")] - [InlineData(1, "developer")] - [InlineData(2, "moderator")] - [InlineData(3, "player")] - public void FromId_Should_ReturnRole_When_IdIsKnown(int id, string name) + [InlineData(1, "Developer", "Разработчик")] + [InlineData(2, "Moderator", "Модератор")] + [InlineData(3, "Player", "Игрок")] + public void FromId_Should_ReturnRole_When_IdIsKnown(int id, string name, string displayName) { - var role = RoleNames.FromId(id); + var role = RoleName.FromId(id); role.Name.ShouldBe(name); + role.DisplayName.ShouldBe(displayName); } [Fact(DisplayName = "Default role should reject an unknown identifier when id is unknown")] public void FromId_Should_Throw_When_IdIsUnknown() { - Action action = () => RoleNames.FromId(0); + Action action = () => RoleName.FromId(0); action.ShouldThrow(); } [Theory(DisplayName = "Default role should resolve a trimmed name when name is known")] - [InlineData("developer", 1)] - [InlineData(" moderator ", 2)] - [InlineData("player", 3)] + [InlineData("Developer", 1)] + [InlineData(" Moderator ", 2)] + [InlineData("Player", 3)] public void FromName_Should_ReturnRole_When_NameIsKnown(string name, int id) { - var role = RoleNames.FromName(name); + var role = RoleName.FromName(name); role.Id.ShouldBe(id); } [Theory(DisplayName = "Default role should reject an invalid name when name is invalid")] [InlineData("admin")] - [InlineData("Developer")] + [InlineData("developer")] [InlineData(" ")] public void FromName_Should_Throw_When_NameIsInvalid(string name) { - Action action = () => RoleNames.FromName(name); + Action action = () => RoleName.FromName(name); action.ShouldThrow(); } [Theory(DisplayName = "Default role lookup should report a match when id is provided")] - [InlineData(1, "developer")] - [InlineData(2, "moderator")] - [InlineData(3, "player")] + [InlineData(1, "Developer")] + [InlineData(2, "Moderator")] + [InlineData(3, "Player")] [InlineData(0, null)] public void TryFromId_Should_ReportMatch_When_IdIsProvided(int id, string? expectedName) { - var found = RoleNames.TryFromId(id, out var role); + var found = RoleName.TryFromId(id, out var role); found.ShouldBe(expectedName is not null); (role?.Name).ShouldBe(expectedName); } [Theory(DisplayName = "Default role lookup should report a match when name is provided")] - [InlineData("developer", 1)] - [InlineData(" moderator ", 2)] - [InlineData("player", 3)] + [InlineData("Developer", 1)] + [InlineData(" Moderator ", 2)] + [InlineData("Player", 3)] [InlineData("admin", null)] - [InlineData("Developer", null)] + [InlineData("developer", null)] [InlineData(" ", null)] public void TryFromName_Should_ReportMatch_When_NameIsProvided(string name, int? expectedId) { - var found = RoleNames.TryFromName(name, out var role); + var found = RoleName.TryFromName(name, out var role); found.ShouldBe(expectedId.HasValue); (role?.Id).ShouldBe(expectedId); From b7a7ab219bbecf6657211eec5d68a36c69021ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 18:34:58 +0400 Subject: [PATCH 14/34] style: group role seeder imports --- .../IdentityProvider/Seeding/IdentityRoleSeeder.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs index e74769d8..62050a7c 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Identity; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; - using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using RoleNameValue = PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects.RoleName; From 5f5c22aa7c71678b3ec8213b087fbaaa1135e2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 19:12:34 +0400 Subject: [PATCH 15/34] refactor: move role collection factory into RoleId --- .../Create/CreateUserCommandValidator.cs | 4 +- .../Users/Create/CreateUserHandler.cs | 3 +- .../Update/UpdateUserCommandValidator.cs | 4 +- .../Users/Update/UpdateUserHandler.cs | 3 +- .../Roles/RoleId.cs | 30 ++++++++++++ .../Users/UserRoleAssignments.cs | 36 -------------- .../Domain/Roles/RoleIdTests.cs | 42 ++++++++++++++++ .../Domain/Users/UserRoleAssignmentsTests.cs | 49 ------------------- 8 files changed, 80 insertions(+), 91 deletions(-) delete mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs index d6aaf41b..98978769 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserCommandValidator.cs @@ -1,5 +1,5 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; -using PANiXiDA.TacticalHeroes.Identity.Domain.Users; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; @@ -34,7 +34,7 @@ public CreateUserCommandValidator() .SetValidator(new ClaimCatalogValidator()); RuleFor(command => command.RoleIds) - .MustBeValidDomainValue(UserRoleAssignments.Create); + .MustBeValidDomainValue(RoleId.Create); RuleFor(command => command.Status) .MustBeValidDomainValue(UserStatus.Create); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs index c40dc3f3..08446023 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Create/CreateUserHandler.cs @@ -1,3 +1,4 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims; @@ -17,7 +18,7 @@ public Task> HandleAsync( var emailResult = Email.Create(value: command.Email); var userNameResult = UserName.Create(value: command.UserName); var statusResult = UserStatus.Create(value: command.Status); - var roleIdsResult = UserRoleAssignments.Create(command.RoleIds); + var roleIdsResult = RoleId.Create(command.RoleIds); var validationResult = Result.Combine(emailResult, userNameResult, statusResult, roleIdsResult); if (validationResult.IsFailure) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs index 8ea03ffd..8485650b 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs @@ -1,5 +1,5 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; -using PANiXiDA.TacticalHeroes.Identity.Domain.Users; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; @@ -34,7 +34,7 @@ public UpdateUserCommandValidator() .SetValidator(new ClaimCatalogValidator()); RuleFor(command => command.RoleIds) - .MustBeValidDomainValue(UserRoleAssignments.Create); + .MustBeValidDomainValue(RoleId.Create); RuleFor(command => command.Status) .MustBeValidDomainValue(UserStatus.Create); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs index fd2995e2..20306d71 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserHandler.cs @@ -1,3 +1,4 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims; @@ -18,7 +19,7 @@ public Task HandleAsync( var emailResult = Email.Create(value: command.Email); var userNameResult = UserName.Create(value: command.UserName); var statusResult = UserStatus.Create(value: command.Status); - var roleIdsResult = UserRoleAssignments.Create(command.RoleIds); + var roleIdsResult = RoleId.Create(command.RoleIds); var validationResult = Result.Combine(idResult, emailResult, userNameResult, statusResult, roleIdsResult); if (validationResult.IsFailure) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleId.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleId.cs index 423fe042..67374ebc 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleId.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/RoleId.cs @@ -24,4 +24,34 @@ public static Result Create(Guid value) return Result.Success(value: new RoleId(value: value)); } + + public static Result> Create(IReadOnlyCollection values) + { + if (values is null) + { + return Result.Failure>( + error: Error.Validation(message: "Role IDs are required.")); + } + + var roleIds = new List(); + var seen = new HashSet(); + foreach (var value in values) + { + if (!seen.Add(value)) + { + return Result.Failure>( + error: Error.Validation(message: "Role IDs must be unique.")); + } + + var result = Create(value); + if (result.IsFailure) + { + return Result.Failure>(errors: result.Errors); + } + + roleIds.Add(result.Value); + } + + return Result.Success>(value: roleIds); + } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs deleted file mode 100644 index 99df8883..00000000 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/UserRoleAssignments.cs +++ /dev/null @@ -1,36 +0,0 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; - -namespace PANiXiDA.TacticalHeroes.Identity.Domain.Users; - -public static class UserRoleAssignments -{ - public static Result> Create(IReadOnlyCollection values) - { - if (values is null) - { - return Result.Failure>( - error: Error.Validation(message: "Role IDs are required.").WithField(nameof(User.RoleIds))); - } - - var roleIds = new List(); - var seen = new HashSet(); - foreach (var value in values) - { - if (!seen.Add(value)) - { - return Result.Failure>( - error: Error.Validation(message: "Role IDs must be unique.").WithField(nameof(User.RoleIds))); - } - - var result = RoleId.Create(value); - if (result.IsFailure) - { - return Result.Failure>(errors: result.Errors); - } - - roleIds.Add(result.Value); - } - - return Result.Success>(value: roleIds); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleIdTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleIdTests.cs index 60304060..5d71f97f 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleIdTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleIdTests.cs @@ -42,4 +42,46 @@ public void ToString_Should_ReturnValue_When_ConvertedToString() result.ShouldBe(value.ToString()); } + + [Fact(DisplayName = "Role assignments should accept empty selections when roles are empty")] + public void Create_Should_ReturnEmptyCollection_When_RolesAreEmpty() + { + var result = RoleId.Create([]); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } + + [Fact(DisplayName = "Role assignments should reject a missing selection when values are null")] + public void Create_Should_ReturnValidationError_When_ValuesAreNull() + { + var result = RoleId.Create(null!); + + result.ShouldHaveSingleError(ErrorType.Validation, "Role IDs are required."); + } + + [Fact(DisplayName = "Role assignments should preserve identifiers when roles are valid")] + public void Create_Should_ReturnIds_When_RolesAreValid() + { + Guid[] roles = [Guid.CreateVersion7(), Guid.CreateVersion7()]; + + var result = RoleId.Create(roles); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Select(role => role.Value).ShouldBe(roles); + } + + [Theory(DisplayName = "Role assignments should reject invalid selections when roles are invalid")] + [InlineData(false)] + [InlineData(true)] + public void Create_Should_ReturnValidationError_When_RolesAreInvalid(bool duplicate) + { + var id = Guid.CreateVersion7(); + Guid[] roles = duplicate ? [id, id] : [Guid.Empty]; + + var result = RoleId.Create(roles); + + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldHaveSingleItem().Type.ShouldBe(ErrorType.Validation); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs deleted file mode 100644 index 8b0424ac..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserRoleAssignmentsTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Users; - -namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Users; - -public sealed class UserRoleAssignmentsTests -{ - [Fact(DisplayName = "Role assignments should accept empty selections when roles are empty")] - public void Create_Should_ReturnEmptySelection_When_RolesAreEmpty() - { - var result = UserRoleAssignments.Create([]); - - result.IsSuccess.ShouldBeTrue(); - result.Value.ShouldBeEmpty(); - } - - [Fact(DisplayName = "Role assignments should reject a missing selection when roles are null")] - public void Create_Should_ReturnValidationError_When_RolesAreNull() - { - var result = UserRoleAssignments.Create(null!); - - result.ShouldHaveSingleError(ErrorType.Validation, "Role IDs are required.") - .ShouldHaveField(nameof(User.RoleIds)); - } - - [Fact(DisplayName = "Role assignments should preserve identifiers when roles are valid")] - public void Create_Should_PreserveIdentifiers_When_RolesAreValid() - { - Guid[] roles = [Guid.CreateVersion7(), Guid.CreateVersion7()]; - - var result = UserRoleAssignments.Create(roles); - - result.IsSuccess.ShouldBeTrue(); - result.Value.Select(role => role.Value).ShouldBe(roles); - } - - [Theory(DisplayName = "Role assignments should reject invalid selections when roles are invalid")] - [InlineData(false)] - [InlineData(true)] - public void Create_Should_ReturnValidationError_When_RolesAreInvalid(bool duplicate) - { - var id = Guid.CreateVersion7(); - Guid[] roles = duplicate ? [id, id] : [Guid.Empty]; - - var result = UserRoleAssignments.Create(roles); - - result.IsFailure.ShouldBeTrue(); - result.Errors.ShouldHaveSingleItem().Type.ShouldBe(ErrorType.Validation); - } -} From 63802d422419260d5ad93945bc17ab1329057c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 19:14:36 +0400 Subject: [PATCH 16/34] fix: retain user id validation import --- .../Users/Update/UpdateUserCommandValidator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs index 8485650b..a0a01c59 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Users/Update/UpdateUserCommandValidator.cs @@ -1,5 +1,6 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; +using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; From 192664b5017c9b2d6b84082997696ee3194f919e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 19:30:58 +0400 Subject: [PATCH 17/34] refactor: replace role name value object with enumeration --- README.md | 3 ++ .../Create/CreateRoleCommandValidator.cs | 2 +- .../Roles/Create/CreateRoleHandler.cs | 2 +- .../Update/UpdateRoleCommandValidator.cs | 2 +- .../Roles/Update/UpdateRoleHandler.cs | 2 +- .../Roles/Enumerations/RoleName.cs | 22 +++++++++ .../Roles/Role.cs | 2 +- .../Roles/ValueObjects/RoleName.cs | 34 -------------- .../Seeding/IdentityRoleSeeder.cs | 4 +- .../Core/IdentityWriteDbContext.cs | 6 +-- .../Write/Mappers/ApplicationRoleMapper.cs | 4 +- .../Claims/ClaimAssignmentEndpointTests.cs | 10 ++-- .../Roles/Create/CreateRoleEndpointTests.cs | 6 +-- .../Roles/Delete/DeleteRoleEndpointTests.cs | 2 +- .../GetDetails/GetRoleDetailsEndpointTests.cs | 4 +- .../Roles/GetList/GetRolesEndpointTests.cs | 8 +--- .../Features/Roles/RoleApiTestClient.cs | 18 ++++++- .../Features/Roles/RoleNameEndpointTests.cs | 30 ++++++++++++ .../Roles/Update/UpdateRoleEndpointTests.cs | 8 ++-- .../Roles/Read/RolesReadRepositoryTests.cs | 14 +++--- .../Mappers/ApplicationRoleMapperTests.cs | 6 +-- .../Roles/Write/RolesRepositoryTests.cs | 16 +++---- .../Roles/IntegrationTestData.cs | 2 +- .../Create/CreateRoleCommandValidatorTests.cs | 2 +- .../Roles/Create/CreateRoleHandlerTests.cs | 8 ++-- .../GetDetails/GetRoleDetailsHandlerTests.cs | 2 +- .../Roles/GetList/GetRolesHandlerTests.cs | 2 +- .../Update/UpdateRoleCommandValidatorTests.cs | 2 +- .../Roles/Update/UpdateRoleHandlerTests.cs | 8 ++-- .../Roles/Enumerations/RoleNameTests.cs | 34 ++++++++++++++ .../Domain/Roles/RoleTests.cs | 10 ++-- .../Roles/ValueObjects/RoleNameTests.cs | 47 ------------------- 32 files changed, 172 insertions(+), 150 deletions(-) delete mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs diff --git a/README.md b/README.md index 7f3aaed8..272f2769 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ lowercase role-name convention. Seeding preserves existing role IDs and claims; new roles have no permission/section claims. Public registration assigns Developer. Existing users are not reassigned automatically. +Role names are restricted to `Developer`, `Moderator`, and `Player`. Input is +trimmed and matched without case sensitivity; persisted names remain lowercase. + User create/update requests require `roleIds` (GUID array), just like `claims`; get-details returns the selected IDs. Updates replace the complete role selection; `[]` means no roles. Omitted/null collections are rejected. Empty GUIDs, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs index 6383d385..e7e20528 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs @@ -1,6 +1,6 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs index e7a0fc73..0ebe38f4 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs @@ -2,7 +2,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs index 7d3b234c..9b357b6f 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs @@ -1,7 +1,7 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs index 93ccffe1..0882e7ae 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs @@ -2,7 +2,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs index 1566f7bb..401ca060 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs @@ -2,6 +2,8 @@ namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; public sealed partial class RoleName : Enumeration { + public const int MaxNameLength = 128; + public static readonly RoleName Developer = new(id: 1, name: nameof(Developer), displayName: "Разработчик"); public static readonly RoleName Moderator = new(id: 2, name: nameof(Moderator), displayName: "Модератор"); public static readonly RoleName Player = new(id: 3, name: nameof(Player), displayName: "Игрок"); @@ -13,4 +15,24 @@ private RoleName(int id, string name, string displayName) } public string DisplayName { get; } + + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Result.Failure( + error: Error.Validation(message: "Role name is required.") + .WithField(nameof(RoleName))); + } + + var normalizedValue = value.Trim(); + var roleName = GetAll().FirstOrDefault(role => string.Equals( + role.Name, normalizedValue, StringComparison.OrdinalIgnoreCase)); + + return roleName is not null + ? Result.Success(value: roleName) + : Result.Failure( + error: Error.Validation(message: $"Role name '{normalizedValue}' is invalid.") + .WithField(nameof(RoleName))); + } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs index 9a163c02..c6c43330 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs @@ -1,6 +1,6 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs deleted file mode 100644 index 08d74cce..00000000 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; - -public sealed partial class RoleName : ValueObject -{ - public const int MaxLength = 128; - - private RoleName(string value) - { - Value = value; - } - - public string Value { get; } - - public static Result Create(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return Result.Failure( - error: Error.Validation(message: "Role name cannot be empty.") - .WithField(nameof(RoleName))); - } - - var normalizedValue = value.Trim().ToLowerInvariant(); - - if (normalizedValue.Length > MaxLength) - { - return Result.Failure( - error: Error.Validation(message: $"Role name cannot be longer than {MaxLength} characters.") - .WithField(nameof(RoleName))); - } - - return Result.Success(value: new RoleName(value: normalizedValue)); - } -} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs index 62050a7c..163b0baf 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs @@ -3,15 +3,13 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; -using RoleNameValue = PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects.RoleName; - namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; internal sealed class IdentityRoleSeeder(RoleManager roleManager, TimeProvider timeProvider) { public async Task SeedAsync(CancellationToken cancellationToken) { - foreach (var name in RoleName.GetAll().Select(role => RoleNameValue.Create(role.Name).Value.Value)) + foreach (var name in RoleName.GetAll().Select(role => role.Name.ToLowerInvariant())) { cancellationToken.ThrowIfCancellationRequested(); if (await roleManager.FindByNameAsync(name) is not null) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs index 01eddcee..c66a166a 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs @@ -6,7 +6,7 @@ using OpenIddict.EntityFrameworkCore.Models; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; @@ -96,8 +96,8 @@ private static void ConfigureAspNetIdentity(ModelBuilder modelBuilder) builder.ToTable("asp_net_roles", Schema); builder.Property(role => role.Id).ValueGeneratedNever(); - builder.Property(role => role.Name).HasMaxLength(RoleName.MaxLength).IsRequired(); - builder.Property(role => role.NormalizedName).HasMaxLength(RoleName.MaxLength).IsRequired(); + builder.Property(role => role.Name).HasMaxLength(RoleName.MaxNameLength).IsRequired(); + builder.Property(role => role.NormalizedName).HasMaxLength(RoleName.MaxNameLength).IsRequired(); builder.HasMany(role => role.Users) .WithOne(user => user.Role) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs index 14a33255..619312bc 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs @@ -1,7 +1,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.Mappers; @@ -33,7 +33,7 @@ public static void MapToDbModel( ApplicationRole dbModel, DateTime updatedAt) { - dbModel.Name = role.Name.Value; + dbModel.Name = role.Name.Name.ToLowerInvariant(); dbModel.UpdatedAt = updatedAt; } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs index df2e44be..a595c6f8 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs @@ -35,7 +35,7 @@ public async Task AssignClaims_Should_ReturnBadRequest_When_ManagedClaimIsUnknow if (update) { var id = role - ? (await new RoleApiTestClient(Fixture).CreateAsync(cancellationToken)).Id + ? (await new RoleApiTestClient(Fixture).RecreateAsync(cancellationToken)).Id : (await new UserApiTestClient(Fixture).CreateAsync(cancellationToken)).Id; path = $"{path}/{id}"; payload.Remove("password"); @@ -75,11 +75,13 @@ public async Task AssignRoleClaims_Should_PersistSectionOnly_When_SectionIsKnown var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); Claim[] claims = [new(ApplicationClaimTypes.Section, "identity")]; - var role = await client.CreateAsync(cancellationToken, new CreateRoleRequest("admin", update ? [] : claims)); + var role = await client.RecreateAsync( + cancellationToken, + new CreateRoleRequest("moderator", update ? [] : claims)); if (update) { - await client.UpdateAsync(role.Id, new UpdateRoleRequest("admin", claims), cancellationToken); + await client.UpdateAsync(role.Id, new UpdateRoleRequest("moderator", claims), cancellationToken); } var details = await client.GetDetailsAsync(role.Id, cancellationToken); @@ -118,7 +120,7 @@ await client.UpdateAsync( private static JsonObject CreatePayload(bool role, Claim claim) { return role - ? JsonSerializer.SerializeToNode(new CreateRoleRequest("admin", [claim]), JsonOptions)!.AsObject() + ? JsonSerializer.SerializeToNode(new CreateRoleRequest("moderator", [claim]), JsonOptions)!.AsObject() : JsonSerializer.SerializeToNode( UserApiTestClient.CreateDefaultRequest() with { Claims = [claim] }, JsonOptions)! .AsObject(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs index bdcb0404..c12a37e9 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs @@ -12,10 +12,10 @@ public async Task PostRoles_Should_PersistRole_When_RequestIsValid() var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); - var response = await client.CreateAsync( + var response = await client.RecreateAsync( cancellationToken, new CreateRoleRequest( - " ADMIN ", + " MODERATOR ", [new Claim("permission", "compendium.heroes.update")])); var role = await RoleDatabaseTestHelper.FindAsync( Fixture, @@ -23,7 +23,7 @@ public async Task PostRoles_Should_PersistRole_When_RequestIsValid() cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("admin"); + role.Name.ShouldBe("moderator"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs index 198b806f..ef7b3609 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs @@ -8,7 +8,7 @@ public async Task DeleteRole_Should_RemoveRole_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); - var createdRole = await client.CreateAsync(cancellationToken); + var createdRole = await client.RecreateAsync(cancellationToken); await client.DeleteAsync(createdRole.Id, cancellationToken); var role = await RoleDatabaseTestHelper.FindAsync( diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs index 809847d7..4787b3d0 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs @@ -8,11 +8,11 @@ public async Task GetRoleDetails_Should_ReturnRole_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); - var createdRole = await client.CreateAsync(cancellationToken); + var createdRole = await client.RecreateAsync(cancellationToken); var response = await client.GetDetailsAsync(createdRole.Id, cancellationToken); response.Id.ShouldBe(createdRole.Id); - response.Name.ShouldBe("admin"); + response.Name.ShouldBe("moderator"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs index f600a596..3cd9958b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs @@ -1,5 +1,3 @@ -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; - namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.GetList; public sealed class GetRolesEndpointTests(FunctionalTestFixture fixture) @@ -10,12 +8,10 @@ public async Task GetRoles_Should_ReturnSortedPage_When_RolesExist() { var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); - await client.CreateAsync(cancellationToken, new CreateRoleRequest("viewer", [])); - await client.CreateAsync(cancellationToken, new CreateRoleRequest("admin", [])); var response = await client.GetListAsync(cancellationToken); - response.TotalCount.ShouldBe(5); - response.Items.Select(role => role.Name).ShouldBe(["admin", "developer", "moderator", "player", "viewer"]); + response.TotalCount.ShouldBe(3); + response.Items.Select(role => role.Name).ShouldBe(["developer", "moderator", "player"]); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs index 61f30e65..f98e15c9 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs @@ -10,11 +10,27 @@ namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features internal sealed class RoleApiTestClient(FunctionalTestFixture fixture) { + internal async Task RecreateAsync( + CancellationToken cancellationToken, + CreateRoleRequest? request = null) + { + request ??= new CreateRoleRequest("moderator", []); + var roles = await GetListAsync(cancellationToken); + var existing = roles.Items.SingleOrDefault(role => string.Equals( + role.Name, request.Name.Trim(), StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + await DeleteAsync(existing.Id, cancellationToken); + } + + return await CreateAsync(cancellationToken, request); + } + internal async Task CreateAsync( CancellationToken cancellationToken, CreateRoleRequest? request = null) { - request ??= new CreateRoleRequest("admin", []); + request ??= new CreateRoleRequest("moderator", []); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, RolesPath) { Content = JsonContent.Create(request, options: TestJsonSerializerOptions.Web) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs new file mode 100644 index 00000000..d07a3c42 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs @@ -0,0 +1,30 @@ +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; + +namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; + +public sealed class RoleNameEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) +{ + [Theory(DisplayName = "Role requests should reject arbitrary names when name is unknown")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveRole_Should_ReturnBadRequest_When_NameIsUnknown(bool update) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var roleId = roles.Items.Single(role => role.Name == "player").Id; + var path = update ? $"/api/v1/roles/{roleId}" : "/api/v1/roles"; + using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) + { + Content = JsonContent.Create(new CreateRoleRequest("admin", []), options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var details = await client.GetDetailsAsync(roleId, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + details.Name.ShouldBe("player"); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index 35273e22..d67563c9 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -11,12 +11,14 @@ public async Task PutRole_Should_UpdatePostgreSql_When_RequestIsValid() { var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); - var createdRole = await client.CreateAsync(cancellationToken); + var createdRole = await client.RecreateAsync(cancellationToken); + var roles = await client.GetListAsync(cancellationToken); + await client.DeleteAsync(roles.Items.Single(role => role.Name == "player").Id, cancellationToken); await client.UpdateAsync( createdRole.Id, new UpdateRoleRequest( - "manager", + "player", [new Claim("permission", "compendium.heroes.update")]), cancellationToken); var role = await RoleDatabaseTestHelper.FindAsync( @@ -25,7 +27,7 @@ [new Claim("permission", "compendium.heroes.update")]), cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("manager"); + role.Name.ShouldBe("player"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs index bda25970..aab697ca 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs @@ -13,22 +13,22 @@ public sealed class RolesReadRepositoryTests(IntegrationTestFixture fixture) public async Task GetDetailsByIdAsync_Should_ReturnDetails_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; - var roleId = await AddRoleAsync("admin", cancellationToken); + var roleId = await AddRoleAsync("developer", cancellationToken); await using var scope = Fixture.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var details = await repository.GetDetailsByIdAsync(roleId, cancellationToken); details.ShouldNotBeNull(); - details.Name.ShouldBe("admin"); + details.Name.ShouldBe("developer"); } [Fact(DisplayName = "GetPageAsync should return roles sorted by name when roles exist")] public async Task GetPageAsync_Should_ReturnSortedPage_When_RolesExist() { var cancellationToken = TestContext.Current.CancellationToken; - await AddRoleAsync("viewer", cancellationToken); - await AddRoleAsync("admin", cancellationToken); + await AddRoleAsync("player", cancellationToken); + await AddRoleAsync("developer", cancellationToken); await using var scope = Fixture.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -38,14 +38,14 @@ public async Task GetPageAsync_Should_ReturnSortedPage_When_RolesExist() cancellationToken); page.TotalCount.ShouldBe(2); - page.Items.Select(item => item.Name).ShouldBe(["admin", "viewer"]); + page.Items.Select(item => item.Name).ShouldBe(["developer", "player"]); } [Fact(DisplayName = "ExistsByIdAsync should return true for an existing role when role exists")] public async Task ExistsByIdAsync_Should_ReturnTrue_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; - var roleId = await AddRoleAsync("exists-role", cancellationToken); + var roleId = await AddRoleAsync("moderator", cancellationToken); await using var scope = Fixture.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -65,7 +65,7 @@ public async Task AnyAsync_Should_ReflectWhetherRolesExist_When_Called() (await emptyRepository.AnyAsync(cancellationToken)).ShouldBeFalse(); } - await AddRoleAsync("any-role", cancellationToken); + await AddRoleAsync("moderator", cancellationToken); await using var populatedScope = Fixture.CreateScope(); var populatedRepository = diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs index 872261ee..8378983f 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs @@ -17,7 +17,7 @@ public void ToDomain_Should_ReturnValidationFailures_When_IdentityIsInvalid() [Fact(DisplayName = "Role mapper should reject invalid claims when claim is invalid")] public void ToDomain_Should_ReturnValidationFailures_When_ClaimIsInvalid() { - var result = ApplicationRoleMapper.ToDomain(CreateDbModel(Guid.CreateVersion7(), "admin", [("", "")])); + var result = ApplicationRoleMapper.ToDomain(CreateDbModel(Guid.CreateVersion7(), "developer", [("", "")])); result.IsFailure.ShouldBeTrue(); result.Errors.Count.ShouldBe(2); @@ -28,11 +28,11 @@ public void ToDomain_Should_NormalizeAndDeduplicateClaims_When_ValuesAreValid() { var result = ApplicationRoleMapper.ToDomain(CreateDbModel( Guid.CreateVersion7(), - " ADMIN ", + " DEVELOPER ", [(" permission ", " heroes.manage "), ("permission", "heroes.manage")])); result.IsSuccess.ShouldBeTrue(); - result.Value.Name.Value.ShouldBe("admin"); + result.Value.Name.Name.ShouldBe("Developer"); var claim = result.Value.Claims.ShouldHaveSingleItem(); claim.Type.Value.ShouldBe("permission"); claim.Value.Value.ShouldBe("heroes.manage"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index 477cbffd..404c80da 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -18,7 +18,7 @@ public async Task AddAsync_Should_PersistRole_When_CommandIsValid() { var cancellationToken = TestContext.Current.CancellationToken; var roleId = await AddRoleAsync( - " ADMIN ", + " DEVELOPER ", [new Claim("permission", "heroes.read")], cancellationToken); @@ -29,7 +29,7 @@ [new Claim("permission", "heroes.read")], .AsNoTracking() .SingleAsync(item => item.Id == roleId, cancellationToken); - role.Name.ShouldBe("admin"); + role.Name.ShouldBe("developer"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("heroes.read"); } @@ -38,7 +38,7 @@ public async Task UpdateAsync_Should_PersistChanges_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; var roleId = await AddRoleAsync( - "admin", + "developer", [new Claim("permission", "heroes.read")], cancellationToken); @@ -48,7 +48,7 @@ [new Claim("permission", "heroes.read")], (await repository.UpdateAsync( IntegrationTestData.CreateRole( id: roleId, - name: "manager", + name: "moderator", claims: ((Claim[])[new Claim("permission", "heroes.manage")]).Select( claim => (claim.Type, claim.Value))), cancellationToken)) @@ -64,7 +64,7 @@ [new Claim("permission", "heroes.read")], .AsNoTracking() .SingleAsync(item => item.Id == roleId, cancellationToken); - role.Name.ShouldBe("manager"); + role.Name.ShouldBe("moderator"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("heroes.manage"); } @@ -72,7 +72,7 @@ [new Claim("permission", "heroes.read")], public async Task DeleteAsync_Should_RemoveRole_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; - var roleId = await AddRoleAsync("delete-role", [], cancellationToken); + var roleId = await AddRoleAsync("player", [], cancellationToken); await using (var scope = Fixture.CreateScope()) { @@ -98,7 +98,7 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() (await repository.AddAsync( IntegrationTestData.CreateRole( id: Guid.CreateVersion7(), - name: "admin", + name: "developer", claims: []), cancellationToken)) .IsSuccess @@ -106,7 +106,7 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() var result = await repository.AddAsync( IntegrationTestData.CreateRole( id: Guid.CreateVersion7(), - name: "ADMIN", + name: "DEVELOPER", claims: []), cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs index 8756d967..16c2c781 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs @@ -1,7 +1,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Roles; diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs index 1f9ef402..87c57112 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs @@ -12,7 +12,7 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() var validator = new CreateRoleCommandValidator(); var result = validator.Validate( - new CreateRoleCommand("admin", [new Claim("permission", "compendium.heroes.update")])); + new CreateRoleCommand("developer", [new Claim("permission", "compendium.heroes.update")])); result.IsValid.ShouldBeTrue(); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs index 2d78d0cd..065355fb 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs @@ -16,7 +16,7 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds() IReadOnlyCollection claims = [new Claim("permission", "heroes.manage")]; var repository = Substitute.For(); repository.AddAsync( - Arg.Is(role => role.Name.Value == "admin" + Arg.Is(role => role.Name.Name == "Developer" && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), Arg @@ -26,13 +26,13 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds() var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new CreateRoleCommand("admin", claims), + new CreateRoleCommand("developer", claims), cancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(roleId); await repository.Received(1).AddAsync( - Arg.Is(role => role.Name.Value == "admin" + Arg.Is(role => role.Name.Name == "Developer" && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), cancellationToken); @@ -41,7 +41,7 @@ await repository.Received(1).AddAsync( [Theory( DisplayName = "Create role handler should reject invalid values without persistence when command is invalid")] [InlineData("", "permission", "heroes.manage")] - [InlineData("admin", "", "")] + [InlineData("developer", "", "")] public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_When_CommandIsInvalid( string name, string claimType, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs index d513f233..da8bd15f 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs @@ -9,7 +9,7 @@ public sealed class GetRoleDetailsHandlerTests public async Task HandleAsync_Should_ReturnRole_When_RoleExists() { var roleId = Guid.CreateVersion7(); - var readModel = new RoleDetailsReadModel(roleId, "admin", []); + var readModel = new RoleDetailsReadModel(roleId, "developer", []); var rolesReadRepository = Substitute.For(); rolesReadRepository.GetDetailsByIdAsync(roleId, Arg.Any()) .Returns(readModel); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs index 942d1576..60fa52b3 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs @@ -11,7 +11,7 @@ public async Task HandleAsync_Should_ReturnPage_When_RepositorySucceeds() var pagination = new PaginationParameters(1, 20); var sorting = SortingParameters.None; var page = PaginationResult.Create( - [new RoleListItemReadModel(Guid.CreateVersion7(), "admin")], + [new RoleListItemReadModel(Guid.CreateVersion7(), "developer")], 1, 20, 1); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs index 472047be..12ce13bf 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs @@ -14,7 +14,7 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() var result = validator.Validate( new UpdateRoleCommand( Guid.CreateVersion7(), - "admin", + "developer", [new Claim("permission", "compendium.heroes.update")])); result.IsValid.ShouldBeTrue(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs index db95e5bd..33ea93ee 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs @@ -17,7 +17,7 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() repository.UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Value == "admin" + && role.Name.Name == "Developer" && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), Arg.Any()) @@ -26,14 +26,14 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new UpdateRoleCommand(roleId, "admin", claims), + new UpdateRoleCommand(roleId, "developer", claims), cancellationToken); result.IsSuccess.ShouldBeTrue(); await repository.Received(1).UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Value == "admin" + && role.Name.Name == "Developer" && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), cancellationToken); @@ -42,7 +42,7 @@ await repository.Received(1).UpdateAsync( [Theory( DisplayName = "Update role handler should reject invalid values without persistence when command is invalid")] [InlineData("", "permission", "heroes.manage")] - [InlineData("admin", "", "")] + [InlineData("developer", "", "")] public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_When_CommandIsInvalid( string name, string claimType, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs index b02f005d..31b5bb2d 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs @@ -81,4 +81,38 @@ public void TryFromName_Should_ReportMatch_When_NameIsProvided(string name, int? found.ShouldBe(expectedId.HasValue); (role?.Id).ShouldBe(expectedId); } + + [Theory(DisplayName = "Role name factory should normalize known names when name is known")] + [InlineData("Developer", 1)] + [InlineData(" developer ", 1)] + [InlineData("MODERATOR", 2)] + [InlineData("player", 3)] + public void Create_Should_ReturnRole_When_NameIsKnown(string name, int id) + { + var result = RoleName.Create(name); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBe(RoleName.FromId(id)); + } + + [Theory(DisplayName = "Role name factory should reject missing names when value is empty")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_Should_ReturnValidationFailure_When_ValueIsEmpty(string? value) + { + var result = RoleName.Create(value!); + + result.ShouldHaveSingleError(ErrorType.Validation, "Role name is required.") + .ShouldHaveField(nameof(RoleName)); + } + + [Fact(DisplayName = "Role name factory should reject unknown names when value is unknown")] + public void Create_Should_ReturnValidationFailure_When_ValueIsUnknown() + { + var result = RoleName.Create("admin"); + + result.ShouldHaveSingleError(ErrorType.Validation, "Role name 'admin' is invalid.") + .ShouldHaveField(nameof(RoleName)); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs index e90464c6..2e2262a8 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs @@ -1,7 +1,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles; @@ -14,11 +14,11 @@ public void Create_Should_RestoreState_When_PersistedValuesAreValid() var result = Role.Create( RoleId.Create(id).Value, - RoleName.Create(" ADMIN ").Value, + RoleName.Create(" DEVELOPER ").Value, [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)]); result.Id.Value.ShouldBe(id); - result.Name.Value.ShouldBe("admin"); + result.Name.Name.ShouldBe("Developer"); var claim = result.Claims.ShouldHaveSingleItem(); claim.Type.Value.ShouldBe("permission"); claim.Value.Value.ShouldBe("heroes.manage"); @@ -29,7 +29,7 @@ public void Create_Should_IsolateCollectionStorage_When_InputCollectionsChange() { var claim = RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value); List claims = [claim]; - var role = Role.Create(RoleId.New(), RoleName.Create("admin").Value, claims); + var role = Role.Create(RoleId.New(), RoleName.Create("developer").Value, claims); claims.Clear(); @@ -89,6 +89,6 @@ public void Claims_Should_RejectExternalMutations_When_AClaimIsGranted() private static Role CreateRole() { - return Role.Create(RoleId.New(), RoleName.Create("admin").Value, []); + return Role.Create(RoleId.New(), RoleName.Create("developer").Value, []); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs deleted file mode 100644 index 3eb4144a..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; - -namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.ValueObjects; - -public sealed class RoleNameTests -{ - [Fact(DisplayName = "Role name should normalize a valid value when role name is valid")] - public void Create_Should_NormalizeValue_When_RoleNameIsValid() - { - var result = RoleName.Create(" ADMIN "); - - result.IsSuccess.ShouldBeTrue(); - result.Value.Value.ShouldBe("admin"); - } - - [Theory(DisplayName = "Role name should reject an empty value when role name is empty")] - [InlineData("")] - [InlineData(" ")] - public void Create_Should_ReturnValidationFailure_When_RoleNameIsEmpty(string value) - { - var result = RoleName.Create(value); - - result.ShouldHaveSingleError(ErrorType.Validation, "Role name cannot be empty.") - .ShouldHaveField(nameof(RoleName)); - } - - [Fact(DisplayName = "Role name should reject a value over the maximum length when role name is too long")] - public void Create_Should_ReturnValidationFailure_When_RoleNameIsTooLong() - { - var result = RoleName.Create(new string('a', RoleName.MaxLength + 1)); - - result.ShouldHaveSingleError( - ErrorType.Validation, - $"Role name cannot be longer than {RoleName.MaxLength} characters.") - .ShouldHaveField(nameof(RoleName)); - } - - [Fact(DisplayName = "Role name should format its value when converted to string")] - public void ToString_Should_FormatValue_When_ConvertedToString() - { - var roleName = RoleName.Create("admin").Value; - - var result = roleName.ToString(); - - result.ShouldBe("RoleName { Value = admin }"); - } -} From 9596782012ab6cf9f8cf70fa4e0b46fc4bb36694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 19:44:17 +0400 Subject: [PATCH 18/34] refactor: persist role enumeration names unchanged --- README.md | 6 +++--- .../IdentityProvider/Seeding/IdentityRoleSeeder.cs | 2 +- .../Roles/Write/Mappers/ApplicationRoleMapper.cs | 2 +- .../Auth/Register/RegisterUserEndpointTests.cs | 2 +- .../Features/Roles/Create/CreateRoleEndpointTests.cs | 2 +- .../Roles/GetDetails/GetRoleDetailsEndpointTests.cs | 2 +- .../Features/Roles/GetList/GetRolesEndpointTests.cs | 2 +- .../Features/Roles/RoleNameEndpointTests.cs | 4 ++-- .../Features/Roles/Update/UpdateRoleEndpointTests.cs | 4 ++-- .../Features/Users/UserRoleAssignmentEndpointTests.cs | 10 +++++----- .../Seeding/IdentityRoleSeederTests.cs | 2 +- .../Services/UserCredentialsServiceTests.cs | 2 +- .../Features/Roles/Read/RolesReadRepositoryTests.cs | 4 ++-- .../Features/Roles/Write/RolesRepositoryTests.cs | 4 ++-- 14 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 272f2769..03b07f4e 100644 --- a/README.md +++ b/README.md @@ -81,13 +81,13 @@ Frontend adaptation is separate; no database migration is required. ## User Roles -Identity seeds `developer`, `moderator`, and `player` at startup using the existing -lowercase role-name convention. Seeding preserves existing role IDs and claims; +Identity seeds `Developer`, `Moderator`, and `Player` at startup using the +enumeration names. Seeding preserves existing role IDs and claims; new roles have no permission/section claims. Public registration assigns Developer. Existing users are not reassigned automatically. Role names are restricted to `Developer`, `Moderator`, and `Player`. Input is -trimmed and matched without case sensitivity; persisted names remain lowercase. +trimmed and matched without case sensitivity; names are persisted as `RoleName.Name`. User create/update requests require `roleIds` (GUID array), just like `claims`; get-details returns the selected IDs. Updates replace the complete role selection; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs index 163b0baf..b235fe89 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs @@ -9,7 +9,7 @@ internal sealed class IdentityRoleSeeder(RoleManager roleManage { public async Task SeedAsync(CancellationToken cancellationToken) { - foreach (var name in RoleName.GetAll().Select(role => role.Name.ToLowerInvariant())) + foreach (var name in RoleName.GetAll().Select(role => role.Name)) { cancellationToken.ThrowIfCancellationRequested(); if (await roleManager.FindByNameAsync(name) is not null) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs index 619312bc..af9acb49 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs @@ -33,7 +33,7 @@ public static void MapToDbModel( ApplicationRole dbModel, DateTime updatedAt) { - dbModel.Name = role.Name.Name.ToLowerInvariant(); + dbModel.Name = role.Name.Name; dbModel.UpdatedAt = updatedAt; } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs index 61b348bb..0d376842 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs @@ -34,6 +34,6 @@ public async Task PostRegister_Should_PersistUnconfirmedUser_When_RequestIsValid user.Email.ShouldBe("registered@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); - user.Roles.ShouldHaveSingleItem().Role!.Name.ShouldBe("developer"); + user.Roles.ShouldHaveSingleItem().Role!.Name.ShouldBe("Developer"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs index c12a37e9..3cc6517a 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs @@ -23,7 +23,7 @@ public async Task PostRoles_Should_PersistRole_When_RequestIsValid() cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("moderator"); + role.Name.ShouldBe("Moderator"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs index 4787b3d0..5806e00b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs @@ -13,6 +13,6 @@ public async Task GetRoleDetails_Should_ReturnRole_When_RoleExists() var response = await client.GetDetailsAsync(createdRole.Id, cancellationToken); response.Id.ShouldBe(createdRole.Id); - response.Name.ShouldBe("moderator"); + response.Name.ShouldBe("Moderator"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs index 3cd9958b..5fefdeeb 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetList/GetRolesEndpointTests.cs @@ -12,6 +12,6 @@ public async Task GetRoles_Should_ReturnSortedPage_When_RolesExist() var response = await client.GetListAsync(cancellationToken); response.TotalCount.ShouldBe(3); - response.Items.Select(role => role.Name).ShouldBe(["developer", "moderator", "player"]); + response.Items.Select(role => role.Name).ShouldBe(["Developer", "Moderator", "Player"]); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs index d07a3c42..93f70412 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs @@ -13,7 +13,7 @@ public async Task SaveRole_Should_ReturnBadRequest_When_NameIsUnknown(bool updat var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); var roles = await client.GetListAsync(cancellationToken); - var roleId = roles.Items.Single(role => role.Name == "player").Id; + var roleId = roles.Items.Single(role => role.Name == "Player").Id; var path = update ? $"/api/v1/roles/{roleId}" : "/api/v1/roles"; using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) { @@ -25,6 +25,6 @@ public async Task SaveRole_Should_ReturnBadRequest_When_NameIsUnknown(bool updat var details = await client.GetDetailsAsync(roleId, cancellationToken); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); - details.Name.ShouldBe("player"); + details.Name.ShouldBe("Player"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index d67563c9..b7dfe30a 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -13,7 +13,7 @@ public async Task PutRole_Should_UpdatePostgreSql_When_RequestIsValid() var client = new RoleApiTestClient(Fixture); var createdRole = await client.RecreateAsync(cancellationToken); var roles = await client.GetListAsync(cancellationToken); - await client.DeleteAsync(roles.Items.Single(role => role.Name == "player").Id, cancellationToken); + await client.DeleteAsync(roles.Items.Single(role => role.Name == "Player").Id, cancellationToken); await client.UpdateAsync( createdRole.Id, @@ -27,7 +27,7 @@ [new Claim("permission", "compendium.heroes.update")]), cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("player"); + role.Name.ShouldBe("Player"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs index 1f1581b9..2493b5f2 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs @@ -15,8 +15,8 @@ public async Task PostUsers_Should_AssignMultipleRoles_When_RolesAreSelected() var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; Guid[] selected = [ - roles.Single(role => role.Name == "developer").Id, - roles.Single(role => role.Name == "moderator").Id + roles.Single(role => role.Name == "Developer").Id, + roles.Single(role => role.Name == "Moderator").Id ]; var client = new UserApiTestClient(Fixture); @@ -37,8 +37,8 @@ public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(stri { var cancellationToken = TestContext.Current.CancellationToken; var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; - var developerId = roles.Single(role => role.Name == "developer").Id; - var moderatorId = roles.Single(role => role.Name == "moderator").Id; + var developerId = roles.Single(role => role.Name == "Developer").Id; + var moderatorId = roles.Single(role => role.Name == "Moderator").Id; var client = new UserApiTestClient(Fixture); var created = await client.CreateAsync( cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); @@ -81,7 +81,7 @@ public async Task SaveUser_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(b { var cancellationToken = TestContext.Current.CancellationToken; var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; - var developerId = roles.Single(role => role.Name == "developer").Id; + var developerId = roles.Single(role => role.Name == "Developer").Id; Guid[] roleIds = invalid switch { "empty" => [Guid.Empty], diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs index 635293ff..80844cd6 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs @@ -27,7 +27,7 @@ public async Task SeedAsync_Should_CreatePredefinedRoles_When_RolesAreMissing() .Include(role => role.Claims) .AsNoTracking() .ToArrayAsync(cancellationToken); - roles.Select(role => role.Name).ShouldBe(["developer", "moderator", "player"], ignoreOrder: true); + roles.Select(role => role.Name).ShouldBe(["Developer", "Moderator", "Player"], ignoreOrder: true); roles.ShouldAllBe(role => role.Id != Guid.Empty && role.CreatedAt != default && role.UpdatedAt != default); roles.ShouldAllBe(role => string.Equals( role.NormalizedName, role.Name!.ToUpperInvariant(), StringComparison.Ordinal)); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index eff02986..ad23d9ee 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs @@ -48,7 +48,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr user.Email.ShouldBe("register@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); - (await userManager.GetRolesAsync(user)).ShouldBe([RoleName.Developer.Name.ToLowerInvariant()]); + (await userManager.GetRolesAsync(user)).ShouldBe([RoleName.Developer.Name]); var aggregateTracker = scope.ServiceProvider.GetRequiredService(); var trackedUser = aggregateTracker.GetAll() diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs index aab697ca..ae4ec66e 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs @@ -20,7 +20,7 @@ public async Task GetDetailsByIdAsync_Should_ReturnDetails_When_RoleExists() var details = await repository.GetDetailsByIdAsync(roleId, cancellationToken); details.ShouldNotBeNull(); - details.Name.ShouldBe("developer"); + details.Name.ShouldBe("Developer"); } [Fact(DisplayName = "GetPageAsync should return roles sorted by name when roles exist")] @@ -38,7 +38,7 @@ public async Task GetPageAsync_Should_ReturnSortedPage_When_RolesExist() cancellationToken); page.TotalCount.ShouldBe(2); - page.Items.Select(item => item.Name).ShouldBe(["developer", "player"]); + page.Items.Select(item => item.Name).ShouldBe(["Developer", "Player"]); } [Fact(DisplayName = "ExistsByIdAsync should return true for an existing role when role exists")] diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index 404c80da..1996f246 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -29,7 +29,7 @@ [new Claim("permission", "heroes.read")], .AsNoTracking() .SingleAsync(item => item.Id == roleId, cancellationToken); - role.Name.ShouldBe("developer"); + role.Name.ShouldBe("Developer"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("heroes.read"); } @@ -64,7 +64,7 @@ [new Claim("permission", "heroes.read")], .AsNoTracking() .SingleAsync(item => item.Id == roleId, cancellationToken); - role.Name.ShouldBe("moderator"); + role.Name.ShouldBe("Moderator"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("heroes.manage"); } From e64288e4e6ac02e3076b505dd01bd90dd3fe55f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 19:52:54 +0400 Subject: [PATCH 19/34] refactor: use role name lookup and default player role --- README.md | 4 +-- .../Roles/Enumerations/RoleName.cs | 9 ++++--- .../Services/UserCredentialsService.cs | 10 ++++--- .../Register/RegisterUserEndpointTests.cs | 2 +- .../Claims/ClaimAssignmentEndpointTests.cs | 6 ++--- .../Roles/Create/CreateRoleEndpointTests.cs | 2 +- .../Features/Roles/RoleApiTestClient.cs | 4 +-- .../Roles/Update/UpdateRoleEndpointTests.cs | 2 +- .../Services/UserCredentialsServiceTests.cs | 6 ++--- .../Roles/Read/RolesReadRepositoryTests.cs | 10 +++---- .../Mappers/ApplicationRoleMapperTests.cs | 4 +-- .../Roles/Write/RolesRepositoryTests.cs | 12 ++++----- .../Create/CreateRoleCommandValidatorTests.cs | 2 +- .../Roles/Create/CreateRoleHandlerTests.cs | 4 +-- .../GetDetails/GetRoleDetailsHandlerTests.cs | 2 +- .../Roles/GetList/GetRolesHandlerTests.cs | 2 +- .../Update/UpdateRoleCommandValidatorTests.cs | 2 +- .../Roles/Update/UpdateRoleHandlerTests.cs | 4 +-- .../Roles/Enumerations/RoleNameTests.cs | 27 +++++++++++++------ .../Domain/Roles/RoleTests.cs | 6 ++--- 20 files changed, 68 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 03b07f4e..2ce3b422 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,11 @@ Frontend adaptation is separate; no database migration is required. Identity seeds `Developer`, `Moderator`, and `Player` at startup using the enumeration names. Seeding preserves existing role IDs and claims; -new roles have no permission/section claims. Public registration assigns Developer. +new roles have no permission/section claims. Public registration assigns Player via `RoleName.GetDefault()`. Existing users are not reassigned automatically. Role names are restricted to `Developer`, `Moderator`, and `Player`. Input is -trimmed and matched without case sensitivity; names are persisted as `RoleName.Name`. +trimmed and matched case-sensitively; names are persisted as `RoleName.Name`. User create/update requests require `roleIds` (GUID array), just like `claims`; get-details returns the selected IDs. Updates replace the complete role selection; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs index 401ca060..75e26f00 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs @@ -16,6 +16,11 @@ private RoleName(int id, string name, string displayName) public string DisplayName { get; } + public static RoleName GetDefault() + { + return Player; + } + public static Result Create(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -26,10 +31,8 @@ public static Result Create(string value) } var normalizedValue = value.Trim(); - var roleName = GetAll().FirstOrDefault(role => string.Equals( - role.Name, normalizedValue, StringComparison.OrdinalIgnoreCase)); - return roleName is not null + return TryFromName(normalizedValue, out var roleName) && roleName is not null ? Result.Success(value: roleName) : Result.Failure( error: Error.Validation(message: $"Role name '{normalizedValue}' is invalid.") diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs index 87c43328..f4bd6052 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs @@ -43,13 +43,15 @@ public async Task> RegisterAsync( return Result.Failure(errors: validationResult.Errors); } - var developerRole = await roleManager.FindByNameAsync(RoleName.Developer.Name); - if (developerRole is null) + var defaultRoleName = RoleName.GetDefault(); + var defaultRole = await roleManager.FindByNameAsync(defaultRoleName.Name); + if (defaultRole is null) { - return Result.Failure(error: Error.NotFound(message: "Developer role was not found.")); + return Result.Failure( + error: Error.NotFound(message: $"Default role '{defaultRoleName.Name}' was not found.")); } - var roleIdResult = RoleId.Create(value: developerRole.Id); + var roleIdResult = RoleId.Create(value: defaultRole.Id); if (roleIdResult.IsFailure) { return Result.Failure(errors: roleIdResult.Errors); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs index 0d376842..2f794825 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs @@ -34,6 +34,6 @@ public async Task PostRegister_Should_PersistUnconfirmedUser_When_RequestIsValid user.Email.ShouldBe("registered@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); - user.Roles.ShouldHaveSingleItem().Role!.Name.ShouldBe("Developer"); + user.Roles.ShouldHaveSingleItem().Role!.Name.ShouldBe("Player"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs index a595c6f8..c9242317 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs @@ -77,11 +77,11 @@ public async Task AssignRoleClaims_Should_PersistSectionOnly_When_SectionIsKnown Claim[] claims = [new(ApplicationClaimTypes.Section, "identity")]; var role = await client.RecreateAsync( cancellationToken, - new CreateRoleRequest("moderator", update ? [] : claims)); + new CreateRoleRequest("Moderator", update ? [] : claims)); if (update) { - await client.UpdateAsync(role.Id, new UpdateRoleRequest("moderator", claims), cancellationToken); + await client.UpdateAsync(role.Id, new UpdateRoleRequest("Moderator", claims), cancellationToken); } var details = await client.GetDetailsAsync(role.Id, cancellationToken); @@ -120,7 +120,7 @@ await client.UpdateAsync( private static JsonObject CreatePayload(bool role, Claim claim) { return role - ? JsonSerializer.SerializeToNode(new CreateRoleRequest("moderator", [claim]), JsonOptions)!.AsObject() + ? JsonSerializer.SerializeToNode(new CreateRoleRequest("Moderator", [claim]), JsonOptions)!.AsObject() : JsonSerializer.SerializeToNode( UserApiTestClient.CreateDefaultRequest() with { Claims = [claim] }, JsonOptions)! .AsObject(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs index 3cc6517a..7432adf2 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs @@ -15,7 +15,7 @@ public async Task PostRoles_Should_PersistRole_When_RequestIsValid() var response = await client.RecreateAsync( cancellationToken, new CreateRoleRequest( - " MODERATOR ", + " Moderator ", [new Claim("permission", "compendium.heroes.update")])); var role = await RoleDatabaseTestHelper.FindAsync( Fixture, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs index f98e15c9..6f2518bb 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs @@ -14,7 +14,7 @@ internal async Task RecreateAsync( CancellationToken cancellationToken, CreateRoleRequest? request = null) { - request ??= new CreateRoleRequest("moderator", []); + request ??= new CreateRoleRequest("Moderator", []); var roles = await GetListAsync(cancellationToken); var existing = roles.Items.SingleOrDefault(role => string.Equals( role.Name, request.Name.Trim(), StringComparison.OrdinalIgnoreCase)); @@ -30,7 +30,7 @@ internal async Task CreateAsync( CancellationToken cancellationToken, CreateRoleRequest? request = null) { - request ??= new CreateRoleRequest("moderator", []); + request ??= new CreateRoleRequest("Moderator", []); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, RolesPath) { Content = JsonContent.Create(request, options: TestJsonSerializerOptions.Web) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index b7dfe30a..d8cba08b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -18,7 +18,7 @@ public async Task PutRole_Should_UpdatePostgreSql_When_RequestIsValid() await client.UpdateAsync( createdRole.Id, new UpdateRoleRequest( - "player", + "Player", [new Claim("permission", "compendium.heroes.update")]), cancellationToken); var role = await RoleDatabaseTestHelper.FindAsync( diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index ad23d9ee..c64eb0b2 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs @@ -48,7 +48,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr user.Email.ShouldBe("register@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); - (await userManager.GetRolesAsync(user)).ShouldBe([RoleName.Developer.Name]); + (await userManager.GetRolesAsync(user)).ShouldBe([RoleName.Player.Name]); var aggregateTracker = scope.ServiceProvider.GetRequiredService(); var trackedUser = aggregateTracker.GetAll() @@ -102,8 +102,8 @@ public async Task RegisterAsync_Should_RejectInvalidDefaultRole_When_DefaultRole dbContext.Set().Add(new ApplicationRole { Id = Guid.Empty, - Name = RoleName.Developer.Name, - NormalizedName = RoleName.Developer.Name.ToUpperInvariant() + Name = RoleName.Player.Name, + NormalizedName = RoleName.Player.Name.ToUpperInvariant() }); await dbContext.SaveChangesAsync(cancellationToken); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs index ae4ec66e..a803a452 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs @@ -13,7 +13,7 @@ public sealed class RolesReadRepositoryTests(IntegrationTestFixture fixture) public async Task GetDetailsByIdAsync_Should_ReturnDetails_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; - var roleId = await AddRoleAsync("developer", cancellationToken); + var roleId = await AddRoleAsync("Developer", cancellationToken); await using var scope = Fixture.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -27,8 +27,8 @@ public async Task GetDetailsByIdAsync_Should_ReturnDetails_When_RoleExists() public async Task GetPageAsync_Should_ReturnSortedPage_When_RolesExist() { var cancellationToken = TestContext.Current.CancellationToken; - await AddRoleAsync("player", cancellationToken); - await AddRoleAsync("developer", cancellationToken); + await AddRoleAsync("Player", cancellationToken); + await AddRoleAsync("Developer", cancellationToken); await using var scope = Fixture.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -45,7 +45,7 @@ public async Task GetPageAsync_Should_ReturnSortedPage_When_RolesExist() public async Task ExistsByIdAsync_Should_ReturnTrue_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; - var roleId = await AddRoleAsync("moderator", cancellationToken); + var roleId = await AddRoleAsync("Moderator", cancellationToken); await using var scope = Fixture.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -65,7 +65,7 @@ public async Task AnyAsync_Should_ReflectWhetherRolesExist_When_Called() (await emptyRepository.AnyAsync(cancellationToken)).ShouldBeFalse(); } - await AddRoleAsync("moderator", cancellationToken); + await AddRoleAsync("Moderator", cancellationToken); await using var populatedScope = Fixture.CreateScope(); var populatedRepository = diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs index 8378983f..28455702 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs @@ -17,7 +17,7 @@ public void ToDomain_Should_ReturnValidationFailures_When_IdentityIsInvalid() [Fact(DisplayName = "Role mapper should reject invalid claims when claim is invalid")] public void ToDomain_Should_ReturnValidationFailures_When_ClaimIsInvalid() { - var result = ApplicationRoleMapper.ToDomain(CreateDbModel(Guid.CreateVersion7(), "developer", [("", "")])); + var result = ApplicationRoleMapper.ToDomain(CreateDbModel(Guid.CreateVersion7(), "Developer", [("", "")])); result.IsFailure.ShouldBeTrue(); result.Errors.Count.ShouldBe(2); @@ -28,7 +28,7 @@ public void ToDomain_Should_NormalizeAndDeduplicateClaims_When_ValuesAreValid() { var result = ApplicationRoleMapper.ToDomain(CreateDbModel( Guid.CreateVersion7(), - " DEVELOPER ", + " Developer ", [(" permission ", " heroes.manage "), ("permission", "heroes.manage")])); result.IsSuccess.ShouldBeTrue(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index 1996f246..d0b9582d 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -18,7 +18,7 @@ public async Task AddAsync_Should_PersistRole_When_CommandIsValid() { var cancellationToken = TestContext.Current.CancellationToken; var roleId = await AddRoleAsync( - " DEVELOPER ", + " Developer ", [new Claim("permission", "heroes.read")], cancellationToken); @@ -38,7 +38,7 @@ public async Task UpdateAsync_Should_PersistChanges_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; var roleId = await AddRoleAsync( - "developer", + "Developer", [new Claim("permission", "heroes.read")], cancellationToken); @@ -48,7 +48,7 @@ [new Claim("permission", "heroes.read")], (await repository.UpdateAsync( IntegrationTestData.CreateRole( id: roleId, - name: "moderator", + name: "Moderator", claims: ((Claim[])[new Claim("permission", "heroes.manage")]).Select( claim => (claim.Type, claim.Value))), cancellationToken)) @@ -72,7 +72,7 @@ [new Claim("permission", "heroes.read")], public async Task DeleteAsync_Should_RemoveRole_When_RoleExists() { var cancellationToken = TestContext.Current.CancellationToken; - var roleId = await AddRoleAsync("player", [], cancellationToken); + var roleId = await AddRoleAsync("Player", [], cancellationToken); await using (var scope = Fixture.CreateScope()) { @@ -98,7 +98,7 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() (await repository.AddAsync( IntegrationTestData.CreateRole( id: Guid.CreateVersion7(), - name: "developer", + name: "Developer", claims: []), cancellationToken)) .IsSuccess @@ -106,7 +106,7 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() var result = await repository.AddAsync( IntegrationTestData.CreateRole( id: Guid.CreateVersion7(), - name: "DEVELOPER", + name: "Developer", claims: []), cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs index 87c57112..e65ab29b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs @@ -12,7 +12,7 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() var validator = new CreateRoleCommandValidator(); var result = validator.Validate( - new CreateRoleCommand("developer", [new Claim("permission", "compendium.heroes.update")])); + new CreateRoleCommand("Developer", [new Claim("permission", "compendium.heroes.update")])); result.IsValid.ShouldBeTrue(); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs index 065355fb..b68144e6 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs @@ -26,7 +26,7 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds() var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new CreateRoleCommand("developer", claims), + new CreateRoleCommand("Developer", claims), cancellationToken); result.IsSuccess.ShouldBeTrue(); @@ -41,7 +41,7 @@ await repository.Received(1).AddAsync( [Theory( DisplayName = "Create role handler should reject invalid values without persistence when command is invalid")] [InlineData("", "permission", "heroes.manage")] - [InlineData("developer", "", "")] + [InlineData("Developer", "", "")] public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_When_CommandIsInvalid( string name, string claimType, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs index da8bd15f..04c923f5 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs @@ -9,7 +9,7 @@ public sealed class GetRoleDetailsHandlerTests public async Task HandleAsync_Should_ReturnRole_When_RoleExists() { var roleId = Guid.CreateVersion7(); - var readModel = new RoleDetailsReadModel(roleId, "developer", []); + var readModel = new RoleDetailsReadModel(roleId, "Developer", []); var rolesReadRepository = Substitute.For(); rolesReadRepository.GetDetailsByIdAsync(roleId, Arg.Any()) .Returns(readModel); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs index 60fa52b3..fddd2539 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs @@ -11,7 +11,7 @@ public async Task HandleAsync_Should_ReturnPage_When_RepositorySucceeds() var pagination = new PaginationParameters(1, 20); var sorting = SortingParameters.None; var page = PaginationResult.Create( - [new RoleListItemReadModel(Guid.CreateVersion7(), "developer")], + [new RoleListItemReadModel(Guid.CreateVersion7(), "Developer")], 1, 20, 1); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs index 12ce13bf..bd3fb7c1 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs @@ -14,7 +14,7 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() var result = validator.Validate( new UpdateRoleCommand( Guid.CreateVersion7(), - "developer", + "Developer", [new Claim("permission", "compendium.heroes.update")])); result.IsValid.ShouldBeTrue(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs index 33ea93ee..df346894 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs @@ -26,7 +26,7 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new UpdateRoleCommand(roleId, "developer", claims), + new UpdateRoleCommand(roleId, "Developer", claims), cancellationToken); result.IsSuccess.ShouldBeTrue(); @@ -42,7 +42,7 @@ await repository.Received(1).UpdateAsync( [Theory( DisplayName = "Update role handler should reject invalid values without persistence when command is invalid")] [InlineData("", "permission", "heroes.manage")] - [InlineData("developer", "", "")] + [InlineData("Developer", "", "")] public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_When_CommandIsInvalid( string name, string claimType, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs index 31b5bb2d..a67ecac0 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs @@ -82,11 +82,19 @@ public void TryFromName_Should_ReportMatch_When_NameIsProvided(string name, int? (role?.Id).ShouldBe(expectedId); } - [Theory(DisplayName = "Role name factory should normalize known names when name is known")] + [Fact(DisplayName = "Default role should be player when called")] + public void GetDefault_Should_ReturnPlayer_When_Called() + { + var role = RoleName.GetDefault(); + + role.ShouldBe(RoleName.Player); + } + + [Theory(DisplayName = "Role name factory should trim known names when name is known")] [InlineData("Developer", 1)] - [InlineData(" developer ", 1)] - [InlineData("MODERATOR", 2)] - [InlineData("player", 3)] + [InlineData(" Developer ", 1)] + [InlineData("Moderator", 2)] + [InlineData("Player", 3)] public void Create_Should_ReturnRole_When_NameIsKnown(string name, int id) { var result = RoleName.Create(name); @@ -107,12 +115,15 @@ public void Create_Should_ReturnValidationFailure_When_ValueIsEmpty(string? valu .ShouldHaveField(nameof(RoleName)); } - [Fact(DisplayName = "Role name factory should reject unknown names when value is unknown")] - public void Create_Should_ReturnValidationFailure_When_ValueIsUnknown() + [Theory(DisplayName = "Role name factory should reject unknown names when value is unknown")] + [InlineData("admin")] + [InlineData("developer")] + [InlineData("MODERATOR")] + public void Create_Should_ReturnValidationFailure_When_ValueIsUnknown(string value) { - var result = RoleName.Create("admin"); + var result = RoleName.Create(value); - result.ShouldHaveSingleError(ErrorType.Validation, "Role name 'admin' is invalid.") + result.ShouldHaveSingleError(ErrorType.Validation, $"Role name '{value}' is invalid.") .ShouldHaveField(nameof(RoleName)); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs index 2e2262a8..85f34463 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs @@ -14,7 +14,7 @@ public void Create_Should_RestoreState_When_PersistedValuesAreValid() var result = Role.Create( RoleId.Create(id).Value, - RoleName.Create(" DEVELOPER ").Value, + RoleName.Create(" Developer ").Value, [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)]); result.Id.Value.ShouldBe(id); @@ -29,7 +29,7 @@ public void Create_Should_IsolateCollectionStorage_When_InputCollectionsChange() { var claim = RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value); List claims = [claim]; - var role = Role.Create(RoleId.New(), RoleName.Create("developer").Value, claims); + var role = Role.Create(RoleId.New(), RoleName.Create("Developer").Value, claims); claims.Clear(); @@ -89,6 +89,6 @@ public void Claims_Should_RejectExternalMutations_When_AClaimIsGranted() private static Role CreateRole() { - return Role.Create(RoleId.New(), RoleName.Create("developer").Value, []); + return Role.Create(RoleId.New(), RoleName.Create("Developer").Value, []); } } From 0b961897c8bd626421189c4ad2d2604e4a893265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 20:15:47 +0400 Subject: [PATCH 20/34] feat: configure default roles and seed role claims --- README.md | 17 ++- .../Roles/Create/CreateRoleCommand.cs | 3 +- .../Create/CreateRoleCommandValidator.cs | 2 +- .../Roles/Create/CreateRoleHandler.cs | 5 +- .../Roles/GetDetails/RoleDetailsReadModel.cs | 3 +- .../Roles/GetList/RoleListItemReadModel.cs | 3 +- .../Roles/Update/UpdateRoleCommand.cs | 3 +- .../Update/UpdateRoleCommandValidator.cs | 2 +- .../Roles/Update/UpdateRoleHandler.cs | 5 +- .../Roles/Enumerations/RoleName.cs | 41 ------ .../Roles/Role.cs | 13 +- .../Roles/ValueObjects/RoleName.cs | 31 +++++ ...tityProviderServiceCollectionExtensions.cs | 1 - ...yProviderApplicationSeederHostedService.cs | 2 - .../Seeding/IdentityRoleSeeder.cs | 35 ----- .../Services/UserCredentialsService.cs | 7 +- .../Core/IdentityWriteDbContext.cs | 11 +- .../20261018100000_AddDefaultRole.cs | 43 ++++++ .../20261018100100_SeedRolesAndClaims.cs | 79 +++++++++++ .../IdentityWriteDbContextModelSnapshot.cs | 11 ++ .../Roles/Read/DbModels/RoleReadDbModel.cs | 1 + .../GetDetails/RoleDetailsReadModelMapper.cs | 3 +- .../Roles/Write/DbModels/ApplicationRole.cs | 1 + .../Write/Mappers/ApplicationRoleMapper.cs | 8 +- .../Features/Roles/Write/RolesRepository.cs | 80 +++++++++++ .../Roles/Create/CreateRoleRequest.cs | 3 +- .../Roles/Delete/DeleteRoleEndpoint.cs | 3 +- .../GetDetails/GetRoleDetailsResponse.cs | 3 +- .../Roles/GetList/RoleListItemResponse.cs | 3 +- .../Roles/Update/UpdateRoleRequest.cs | 3 +- .../Roles/DefaultRoleEndpointTests.cs | 75 ++++++++++ .../Features/Roles/RoleNameEndpointTests.cs | 6 +- .../Roles/Update/UpdateRoleEndpointTests.cs | 6 +- .../Presentation/FunctionalTestFixture.cs | 10 +- .../Seeding/IdentityRoleSeederTests.cs | 92 ------------- .../Services/UserCredentialsServiceTests.cs | 32 ++++- .../Migrations/SeedRolesAndClaimsTests.cs | 84 ++++++++++++ .../Mappers/ApplicationRoleMapperTests.cs | 18 ++- .../Roles/Write/RolesRepositoryTests.cs | 83 +++++++++++ .../Users/Write/UserRoleAssignmentTests.cs | 12 +- .../IntegrationTestFixture.cs | 13 ++ .../Roles/IntegrationTestData.cs | 8 +- .../Roles/Create/CreateRoleHandlerTests.cs | 14 +- .../Roles/Update/UpdateRoleHandlerTests.cs | 15 +- .../Roles/Enumerations/RoleNameTests.cs | 129 ------------------ .../Domain/Roles/RoleTests.cs | 14 +- .../Roles/ValueObjects/RoleNameTests.cs | 48 +++++++ 47 files changed, 702 insertions(+), 382 deletions(-) delete mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs delete mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100000_AddDefaultRole.cs create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100100_SeedRolesAndClaims.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs diff --git a/README.md b/README.md index 2ce3b422..bc273d22 100644 --- a/README.md +++ b/README.md @@ -77,24 +77,27 @@ independently of API permissions; enabling permission checks on existing operati is separate work. Existing assignments need explicit section grants for UI visibility. The old `/api/v1/permissions` route is replaced by `/api/v1/claims/permissions`. -Frontend adaptation is separate; no database migration is required. +Frontend adaptation is separate. ## User Roles -Identity seeds `Developer`, `Moderator`, and `Player` at startup using the -enumeration names. Seeding preserves existing role IDs and claims; -new roles have no permission/section claims. Public registration assigns Player via `RoleName.GetDefault()`. +Identity migrations seed `Developer`, `Moderator`, and `Player`. Developer receives all current +permissions and both sections; Moderator receives Compendium permissions and its section; +Player has no administrative claims and is the initial default role. Startup does not reseed roles. Existing users are not reassigned automatically. -Role names are restricted to `Developer`, `Moderator`, and `Player`. Input is -trimmed and matched case-sensitively; names are persisted as `RoleName.Name`. +Role names are arbitrary nonempty values up to 128 characters, trimmed without changing case. +Role create/update requests and list/details responses include `isDefault`. Selecting a new default +switches it atomically; the current default cannot be deleted or unset directly (HTTP 409). +Registration selects the role marked `isDefault`, independently of its name. User create/update requests require `roleIds` (GUID array), just like `claims`; get-details returns the selected IDs. Updates replace the complete role selection; `[]` means no roles. Omitted/null collections are rejected. Empty GUIDs, duplicates, and unknown role IDs are rejected before persistence. Use the existing roles list endpoint to populate selection options. Role changes rotate the security -stamp and revoke the user's tokens. No database migration or new configuration is required. +stamp and revoke the user's tokens. Apply the Identity default-role and seed-data migrations; +no configuration changes are required. Rolling back seed data preserves roles and assignments. ## Request Validation diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs index 874b1b1c..9e94da4c 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs @@ -4,4 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; public sealed record CreateRoleCommand( string Name, - IReadOnlyCollection Claims) : ICommand>; + IReadOnlyCollection Claims, + bool IsDefault = false) : ICommand>; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs index e7e20528..6383d385 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs @@ -1,6 +1,6 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs index 0ebe38f4..f2c2901c 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs @@ -2,7 +2,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; @@ -40,7 +40,8 @@ public Task> HandleAsync( var role = Role.Create( id: RoleId.New(), name: nameResult.Value, - claims: claims); + claims: claims, + isDefault: command.IsDefault); return rolesRepository.AddAsync( role: role, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs index fcb60293..6862e526 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs @@ -5,4 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.GetDetails; public sealed record RoleDetailsReadModel( Guid Id, string Name, - IReadOnlyCollection Claims) : IReadModel; + IReadOnlyCollection Claims, + bool IsDefault = false) : IReadModel; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs index ab086649..d1e790d9 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs @@ -2,4 +2,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.GetList; public sealed record RoleListItemReadModel( Guid Id, - string Name) : IReadModel; + string Name, + bool IsDefault = false) : IReadModel; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs index 8915b455..d67592da 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs @@ -5,4 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; public sealed record UpdateRoleCommand( Guid Id, string Name, - IReadOnlyCollection Claims) : ICommand; + IReadOnlyCollection Claims, + bool IsDefault = false) : ICommand; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs index 9b357b6f..7d3b234c 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs @@ -1,7 +1,7 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs index 0882e7ae..d509a48a 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs @@ -2,7 +2,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; @@ -41,7 +41,8 @@ public Task HandleAsync( var role = Role.Create( id: idResult.Value, name: nameResult.Value, - claims: claims); + claims: claims, + isDefault: command.IsDefault); return rolesRepository.UpdateAsync( role: role, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs deleted file mode 100644 index 75e26f00..00000000 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Enumerations/RoleName.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; - -public sealed partial class RoleName : Enumeration -{ - public const int MaxNameLength = 128; - - public static readonly RoleName Developer = new(id: 1, name: nameof(Developer), displayName: "Разработчик"); - public static readonly RoleName Moderator = new(id: 2, name: nameof(Moderator), displayName: "Модератор"); - public static readonly RoleName Player = new(id: 3, name: nameof(Player), displayName: "Игрок"); - - private RoleName(int id, string name, string displayName) - : base(id, name) - { - DisplayName = displayName; - } - - public string DisplayName { get; } - - public static RoleName GetDefault() - { - return Player; - } - - public static Result Create(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return Result.Failure( - error: Error.Validation(message: "Role name is required.") - .WithField(nameof(RoleName))); - } - - var normalizedValue = value.Trim(); - - return TryFromName(normalizedValue, out var roleName) && roleName is not null - ? Result.Success(value: roleName) - : Result.Failure( - error: Error.Validation(message: $"Role name '{normalizedValue}' is invalid.") - .WithField(nameof(RoleName))); - } -} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs index c6c43330..6bbcc98f 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs @@ -1,6 +1,6 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles; @@ -10,22 +10,27 @@ public sealed class Role : AggregateRoot private Role( RoleId id, - RoleName name) + RoleName name, + bool isDefault) : base(id) { Name = name; + IsDefault = isDefault; } public RoleName Name { get; private set; } + public bool IsDefault { get; private set; } + public IReadOnlyCollection Claims => _claims.AsReadOnly(); public static Role Create( RoleId id, RoleName name, - IEnumerable claims) + IEnumerable claims, + bool isDefault = false) { - var role = new Role(id: id, name: name); + var role = new Role(id: id, name: name, isDefault: isDefault); foreach (var claim in claims) { diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs new file mode 100644 index 00000000..f18f0803 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs @@ -0,0 +1,31 @@ +namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; + +public sealed partial class RoleName : ValueObject +{ + public const int MaxLength = 128; + + private RoleName(string value) + { + Value = value; + } + + public string Value { get; } + + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Result.Failure( + error: Error.Validation(message: "Role name is required.") + .WithField(nameof(RoleName))); + } + + var normalizedValue = value.Trim(); + + return normalizedValue.Length <= MaxLength + ? Result.Success(value: new RoleName(value: normalizedValue)) + : Result.Failure( + error: Error.Validation(message: $"Role name cannot be longer than {MaxLength} characters.") + .WithField(nameof(RoleName))); + } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs index 750dd36e..31fe7c3e 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/DependencyInjection/IdentityProviderServiceCollectionExtensions.cs @@ -37,7 +37,6 @@ public static IServiceCollection AddIdentityProvider( .ValidateOnStart(); serviceCollection.AddScoped(); - serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddHostedService(); serviceCollection.AddDataProtection() diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs index 02833f74..6ae043da 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityProviderApplicationSeederHostedService.cs @@ -10,8 +10,6 @@ internal sealed class IdentityProviderApplicationSeederHostedService( public async Task StartAsync(CancellationToken cancellationToken) { await using var scope = serviceScopeFactory.CreateAsyncScope(); - var roleSeeder = scope.ServiceProvider.GetRequiredService(); - await roleSeeder.SeedAsync(cancellationToken); var seeder = scope.ServiceProvider.GetRequiredService(); await seeder.SeedAsync(cancellationToken); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs deleted file mode 100644 index b235fe89..00000000 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Seeding/IdentityRoleSeeder.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Identity; - -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; - -namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; - -internal sealed class IdentityRoleSeeder(RoleManager roleManager, TimeProvider timeProvider) -{ - public async Task SeedAsync(CancellationToken cancellationToken) - { - foreach (var name in RoleName.GetAll().Select(role => role.Name)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (await roleManager.FindByNameAsync(name) is not null) - { - continue; - } - - var nowUtc = timeProvider.GetUtcNow().UtcDateTime; - var result = await roleManager.CreateAsync(new ApplicationRole - { - Id = Guid.CreateVersion7(), - Name = name, - CreatedAt = nowUtc, - UpdatedAt = nowUtc - }); - if (!result.Succeeded) - { - var errors = string.Join(", ", result.Errors.Select(error => error.Description)); - throw new InvalidOperationException($"Could not seed role '{name}': {errors}"); - } - } - } -} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs index f4bd6052..56611f68 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs @@ -5,7 +5,6 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Login; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; @@ -43,12 +42,12 @@ public async Task> RegisterAsync( return Result.Failure(errors: validationResult.Errors); } - var defaultRoleName = RoleName.GetDefault(); - var defaultRole = await roleManager.FindByNameAsync(defaultRoleName.Name); + var defaultRole = await roleManager.Roles + .SingleOrDefaultAsync(role => role.IsDefault, cancellationToken); if (defaultRole is null) { return Result.Failure( - error: Error.NotFound(message: $"Default role '{defaultRoleName.Name}' was not found.")); + error: Error.NotFound(message: "Default role was not found.")); } var roleIdResult = RoleId.Create(value: defaultRole.Id); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs index c66a166a..f4a8fac9 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs @@ -6,7 +6,7 @@ using OpenIddict.EntityFrameworkCore.Models; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects; @@ -96,8 +96,13 @@ private static void ConfigureAspNetIdentity(ModelBuilder modelBuilder) builder.ToTable("asp_net_roles", Schema); builder.Property(role => role.Id).ValueGeneratedNever(); - builder.Property(role => role.Name).HasMaxLength(RoleName.MaxNameLength).IsRequired(); - builder.Property(role => role.NormalizedName).HasMaxLength(RoleName.MaxNameLength).IsRequired(); + builder.Property(role => role.Name).HasMaxLength(RoleName.MaxLength).IsRequired(); + builder.Property(role => role.NormalizedName).HasMaxLength(RoleName.MaxLength).IsRequired(); + + builder.Property(role => role.IsDefault).HasDefaultValue(false); + builder.HasIndex(role => role.IsDefault) + .IsUnique() + .HasFilter("is_default"); builder.HasMany(role => role.Users) .WithOne(user => user.Role) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100000_AddDefaultRole.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100000_AddDefaultRole.cs new file mode 100644 index 00000000..c82dc210 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100000_AddDefaultRole.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core.Migrations; + +[DbContext(typeof(IdentityWriteDbContext))] +[Migration("20261018100000_AddDefaultRole")] +public partial class AddDefaultRole : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_default", + schema: "identity", + table: "asp_net_roles", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_roles_is_default", + schema: "identity", + table: "asp_net_roles", + column: "is_default", + unique: true, + filter: "is_default"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_asp_net_roles_is_default", + schema: "identity", + table: "asp_net_roles"); + + migrationBuilder.DropColumn( + name: "is_default", + schema: "identity", + table: "asp_net_roles"); + } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100100_SeedRolesAndClaims.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100100_SeedRolesAndClaims.cs new file mode 100644 index 00000000..4c152820 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/20261018100100_SeedRolesAndClaims.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core.Migrations; + +[DbContext(typeof(IdentityWriteDbContext))] +[Migration("20261018100100_SeedRolesAndClaims")] +public partial class SeedRolesAndClaims : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + INSERT INTO identity.asp_net_roles + (id, name, normalized_name, concurrency_stamp, created_at, updated_at, is_default) + SELECT id, name, normalized_name, id::text, NOW(), NOW(), false + FROM (VALUES + ('0199f2a0-0000-7000-8000-000000000001'::uuid, 'Developer', 'DEVELOPER'), + ('0199f2a0-0000-7000-8000-000000000002'::uuid, 'Moderator', 'MODERATOR'), + ('0199f2a0-0000-7000-8000-000000000003'::uuid, 'Player', 'PLAYER') + ) AS roles(id, name, normalized_name) + ON CONFLICT (normalized_name) DO NOTHING; + + UPDATE identity.asp_net_roles + SET is_default = true + WHERE normalized_name = 'PLAYER' + AND NOT EXISTS (SELECT 1 FROM identity.asp_net_roles WHERE is_default); + + INSERT INTO identity.asp_net_role_claims (role_id, claim_type, claim_value) + SELECT role.id, seed.claim_type, seed.claim_value + FROM (VALUES + ('DEVELOPER', 'permission', 'identity.users.create'), + ('DEVELOPER', 'permission', 'identity.users.read'), + ('DEVELOPER', 'permission', 'identity.users.update'), + ('DEVELOPER', 'permission', 'identity.users.delete'), + ('DEVELOPER', 'permission', 'identity.roles.create'), + ('DEVELOPER', 'permission', 'identity.roles.read'), + ('DEVELOPER', 'permission', 'identity.roles.update'), + ('DEVELOPER', 'permission', 'identity.roles.delete'), + ('DEVELOPER', 'permission', 'compendium.factions.create'), + ('DEVELOPER', 'permission', 'compendium.factions.read'), + ('DEVELOPER', 'permission', 'compendium.factions.update'), + ('DEVELOPER', 'permission', 'compendium.factions.delete'), + ('DEVELOPER', 'permission', 'compendium.heroes.create'), + ('DEVELOPER', 'permission', 'compendium.heroes.read'), + ('DEVELOPER', 'permission', 'compendium.heroes.update'), + ('DEVELOPER', 'permission', 'compendium.heroes.delete'), + ('DEVELOPER', 'permission', 'compendium.units.create'), + ('DEVELOPER', 'permission', 'compendium.units.read'), + ('DEVELOPER', 'permission', 'compendium.units.update'), + ('DEVELOPER', 'permission', 'compendium.units.delete'), + ('DEVELOPER', 'section', 'identity'), + ('DEVELOPER', 'section', 'compendium'), + ('MODERATOR', 'permission', 'compendium.factions.create'), + ('MODERATOR', 'permission', 'compendium.factions.read'), + ('MODERATOR', 'permission', 'compendium.factions.update'), + ('MODERATOR', 'permission', 'compendium.factions.delete'), + ('MODERATOR', 'permission', 'compendium.heroes.create'), + ('MODERATOR', 'permission', 'compendium.heroes.read'), + ('MODERATOR', 'permission', 'compendium.heroes.update'), + ('MODERATOR', 'permission', 'compendium.heroes.delete'), + ('MODERATOR', 'permission', 'compendium.units.create'), + ('MODERATOR', 'permission', 'compendium.units.read'), + ('MODERATOR', 'permission', 'compendium.units.update'), + ('MODERATOR', 'permission', 'compendium.units.delete'), + ('MODERATOR', 'section', 'compendium') + ) AS seed(normalized_name, claim_type, claim_value) + JOIN identity.asp_net_roles AS role ON role.normalized_name = seed.normalized_name + ON CONFLICT (role_id, claim_type, claim_value) DO NOTHING; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // Roles may have been edited or assigned to users, so rollback preserves the seeded data. + } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/IdentityWriteDbContextModelSnapshot.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/IdentityWriteDbContextModelSnapshot.cs index 784b1819..d872fda6 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/IdentityWriteDbContextModelSnapshot.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/Migrations/IdentityWriteDbContextModelSnapshot.cs @@ -326,6 +326,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_default"); + b.Property("Name") .IsRequired() .HasMaxLength(128) @@ -345,6 +351,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_asp_net_roles"); + b.HasIndex("IsDefault") + .IsUnique() + .HasDatabaseName("ix_asp_net_roles_is_default") + .HasFilter("is_default"); + b.HasIndex("NormalizedName") .IsUnique() .HasDatabaseName("RoleNameIndex"); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/DbModels/RoleReadDbModel.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/DbModels/RoleReadDbModel.cs index b365c62e..44a14017 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/DbModels/RoleReadDbModel.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/DbModels/RoleReadDbModel.cs @@ -6,6 +6,7 @@ public sealed class RoleReadDbModel : ReadDbModel { public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + public bool IsDefault { get; set; } public string Name { get; set; } = string.Empty; public string NormalizedName { get; set; } = string.Empty; public string? ConcurrencyStamp { get; set; } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs index 74d7b828..19776599 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs @@ -18,6 +18,7 @@ public static IQueryable ProjectTo( .Select(claim => new Claim( type: claim.ClaimType!, value: claim.ClaimValue!)) - .ToArray())); + .ToArray(), + IsDefault: role.IsDefault)); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/DbModels/ApplicationRole.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/DbModels/ApplicationRole.cs index c6696da3..fbce9771 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/DbModels/ApplicationRole.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/DbModels/ApplicationRole.cs @@ -8,6 +8,7 @@ public sealed class ApplicationRole : IdentityRole { public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + public bool IsDefault { get; set; } public ICollection Users { get; set; } = []; public ICollection Claims { get; set; } = []; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs index af9acb49..10d89009 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs @@ -1,7 +1,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.Mappers; @@ -33,7 +33,8 @@ public static void MapToDbModel( ApplicationRole dbModel, DateTime updatedAt) { - dbModel.Name = role.Name.Name; + dbModel.Name = role.Name.Value; + dbModel.IsDefault = role.IsDefault; dbModel.UpdatedAt = updatedAt; } @@ -80,6 +81,7 @@ public static Result ToDomain(ApplicationRole role) return Result.Success(value: Role.Create( id: idResult.Value, name: nameResult.Value, - claims: domainClaims)); + claims: domainClaims, + isDefault: role.IsDefault)); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs index d601f101..8e7e2922 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs @@ -21,12 +21,18 @@ public async Task> AddAsync( Role role, CancellationToken cancellationToken) { + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + await LockRolesAsync(cancellationToken); + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; var applicationRole = ApplicationRoleMapper.ToDbModel( role: role, createdAt: nowUtc, updatedAt: nowUtc); + applicationRole.IsDefault = false; var identityResult = await roleManager.CreateAsync(role: applicationRole); if (!identityResult.Succeeded) @@ -34,6 +40,12 @@ public async Task> AddAsync( return IdentityResultMapper.ToResult(result: identityResult); } + await SetDefaultAsync(applicationRole, role.IsDefault, cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + aggregateTracker.Track(role); return Result.Success(value: applicationRole.Id); @@ -43,6 +55,11 @@ public async Task UpdateAsync( Role role, CancellationToken cancellationToken) { + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + await LockRolesAsync(cancellationToken); + var applicationRole = await roleManager.Roles .Include(item => item.Claims) .SingleOrDefaultAsync(item => item.Id == role.Id.Value, cancellationToken); @@ -52,10 +69,17 @@ public async Task UpdateAsync( return RoleNotFound(); } + if (applicationRole.IsDefault && !role.IsDefault) + { + return DefaultRoleConflict(); + } + + var wasDefault = applicationRole.IsDefault; ApplicationRoleMapper.MapToDbModel( role: role, dbModel: applicationRole, updatedAt: timeProvider.GetUtcNow().UtcDateTime); + applicationRole.IsDefault = wasDefault; SyncClaims( applicationRole: applicationRole, role: role); @@ -67,6 +91,12 @@ public async Task UpdateAsync( return IdentityResultMapper.ToResult(result: identityResult); } + await SetDefaultAsync(applicationRole, role.IsDefault, cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + aggregateTracker.Track(role); return Result.Success(); @@ -76,6 +106,11 @@ public async Task DeleteAsync( Guid id, CancellationToken cancellationToken) { + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + await LockRolesAsync(cancellationToken); + var applicationRole = await roleManager.Roles .Include(role => role.Claims) .SingleOrDefaultAsync(role => role.Id == id, cancellationToken); @@ -85,6 +120,11 @@ public async Task DeleteAsync( return RoleNotFound(); } + if (applicationRole.IsDefault) + { + return DefaultRoleConflict(); + } + var roleResult = ApplicationRoleMapper.ToDomain(role: applicationRole); if (roleResult.IsFailure) @@ -99,11 +139,51 @@ public async Task DeleteAsync( return IdentityResultMapper.ToResult(result: identityResult); } + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + aggregateTracker.Track(roleResult.Value); return Result.Success(); } + private Task LockRolesAsync(CancellationToken cancellationToken) + { + return dbContext.Database.ExecuteSqlRawAsync( + "LOCK TABLE identity.asp_net_roles IN SHARE ROW EXCLUSIVE MODE", cancellationToken); + } + + private async Task SetDefaultAsync( + ApplicationRole role, + bool isDefault, + CancellationToken cancellationToken) + { + if (!isDefault || role.IsDefault) + { + return; + } + + var previousDefaults = await dbContext.Set() + .Where(item => item.IsDefault && item.Id != role.Id) + .ToArrayAsync(cancellationToken); + foreach (var previous in previousDefaults) + { + previous.IsDefault = false; + previous.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + } + await dbContext.SaveChangesAsync(cancellationToken); + + role.IsDefault = true; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static Result DefaultRoleConflict() + { + return Result.Failure(error: Error.Conflict(message: "Select another default role first.")); + } + private static Result RoleNotFound() { return Result.Failure(error: Error.NotFound(message: "Role was not found.")); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs index 49c62310..8161c9d0 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs @@ -4,4 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; public sealed record CreateRoleRequest( string Name, - IReadOnlyCollection Claims); + IReadOnlyCollection Claims, + bool IsDefault = false); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Delete/DeleteRoleEndpoint.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Delete/DeleteRoleEndpoint.cs index 32efbb03..f6161a0f 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Delete/DeleteRoleEndpoint.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Delete/DeleteRoleEndpoint.cs @@ -14,7 +14,8 @@ public void Map(EndpointMapBuilder builder) .Produces(StatusCodes.Status204NoContent) .ProducesValidationProblem(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) - .ProducesProblem(StatusCodes.Status404NotFound); + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); } private static async Task HandleAsync( diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs index a67885da..0d127289 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs @@ -5,4 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.GetDetail public sealed record GetRoleDetailsResponse( Guid Id, string Name, - IReadOnlyCollection Claims); + IReadOnlyCollection Claims, + bool IsDefault = false); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs index be03c434..17cb891c 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs @@ -2,4 +2,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.GetList; public sealed record RoleListItemResponse( Guid Id, - string Name); + string Name, + bool IsDefault = false); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs index a0cf099a..51b466ea 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs @@ -4,4 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; public sealed record UpdateRoleRequest( string Name, - IReadOnlyCollection Claims); + IReadOnlyCollection Claims, + bool IsDefault = false); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs new file mode 100644 index 00000000..c1081187 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs @@ -0,0 +1,75 @@ +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Auth.Register; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; + +namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; + +public sealed class DefaultRoleEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) +{ + [Theory(DisplayName = "Role requests should switch the default when a custom role is selected")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveRole_Should_SwitchDefault_When_CustomRoleIsSelected(bool create) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var initialRoles = await client.GetListAsync(cancellationToken); + var previous = initialRoles.Items.Single(role => role.IsDefault); + previous.Name.ShouldBe("Player"); + Guid id; + if (create) + { + id = (await client.CreateAsync(cancellationToken, + new CreateRoleRequest("CustomDefault", [], IsDefault: true))).Id; + } + else + { + id = initialRoles.Items.Single(role => role.Name == "Moderator").Id; + await client.UpdateAsync( + id, new UpdateRoleRequest("CustomDefault", [], IsDefault: true), cancellationToken); + } + + await client.UpdateAsync(id, new UpdateRoleRequest("RenamedDefault", [], IsDefault: true), cancellationToken); + + var roles = await client.GetListAsync(cancellationToken); + roles.Items.Single(role => role.IsDefault).Id.ShouldBe(id); + var details = await client.GetDetailsAsync(id, cancellationToken); + details.Name.ShouldBe("RenamedDefault"); + details.IsDefault.ShouldBeTrue(); + (await client.GetDetailsAsync(previous.Id, cancellationToken)).IsDefault.ShouldBeFalse(); + using var response = await Client.PostAsJsonAsync("/api/v1/auth/register", + new RegisterUserRequest("default@example.com", "new-user", "StrongPassword1!"), + JsonOptions, cancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + var registered = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + registered.ShouldNotBeNull(); + var user = await UserDatabaseTestHelper.FindAsync(Fixture, registered.Id, cancellationToken); + user.ShouldNotBeNull(); + user.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(id); + } + + [Theory(DisplayName = "Role requests should return conflict when the current default would be removed")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveRole_Should_ReturnConflict_When_CurrentDefaultWouldBeRemoved(bool delete) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var current = roles.Items.Single(role => role.IsDefault); + using var request = new HttpRequestMessage(delete ? HttpMethod.Delete : HttpMethod.Put, + $"/api/v1/roles/{current.Id}"); + if (!delete) + { + request.Content = JsonContent.Create(new UpdateRoleRequest("Player", [], IsDefault: false), + options: JsonOptions); + } + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + (await client.GetDetailsAsync(current.Id, cancellationToken)).IsDefault.ShouldBeTrue(); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs index 93f70412..ef433199 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs @@ -5,10 +5,10 @@ namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features public sealed class RoleNameEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) { - [Theory(DisplayName = "Role requests should reject arbitrary names when name is unknown")] + [Theory(DisplayName = "Role requests should reject empty names when name is empty")] [InlineData(false)] [InlineData(true)] - public async Task SaveRole_Should_ReturnBadRequest_When_NameIsUnknown(bool update) + public async Task SaveRole_Should_ReturnBadRequest_When_NameIsEmpty(bool update) { var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); @@ -17,7 +17,7 @@ public async Task SaveRole_Should_ReturnBadRequest_When_NameIsUnknown(bool updat var path = update ? $"/api/v1/roles/{roleId}" : "/api/v1/roles"; using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) { - Content = JsonContent.Create(new CreateRoleRequest("admin", []), options: JsonOptions) + Content = JsonContent.Create(new CreateRoleRequest(" ", []), options: JsonOptions) }; using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index d8cba08b..5f748112 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -12,13 +12,11 @@ public async Task PutRole_Should_UpdatePostgreSql_When_RequestIsValid() var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); var createdRole = await client.RecreateAsync(cancellationToken); - var roles = await client.GetListAsync(cancellationToken); - await client.DeleteAsync(roles.Items.Single(role => role.Name == "Player").Id, cancellationToken); await client.UpdateAsync( createdRole.Id, new UpdateRoleRequest( - "Player", + "CustomManager", [new Claim("permission", "compendium.heroes.update")]), cancellationToken); var role = await RoleDatabaseTestHelper.FindAsync( @@ -27,7 +25,7 @@ [new Claim("permission", "compendium.heroes.update")]), cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("Player"); + role.Name.ShouldBe("CustomManager"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs index 2efced91..8d7967fe 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations.Operations; using Microsoft.Extensions.DependencyInjection; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core.Migrations; using PANiXiDA.TacticalHeroes.Testing.Databases; namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation; @@ -98,8 +100,12 @@ private void CreateCurrentClient() private async Task SeedIdentityProviderAsync(CancellationToken cancellationToken) { await using var scope = _factory.Services.CreateAsyncScope(); - var roleSeeder = scope.ServiceProvider.GetRequiredService(); - await roleSeeder.SeedAsync(cancellationToken); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var migration = new SeedRolesAndClaims(); + foreach (var operation in migration.UpOperations.Cast()) + { + await dbContext.Database.ExecuteSqlRawAsync(operation.Sql, cancellationToken); + } var seeder = scope.ServiceProvider.GetRequiredService(); await seeder.SeedAsync(cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs deleted file mode 100644 index 80844cd6..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Seeding/IdentityRoleSeederTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Security.Claims; - -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; - -namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.IdentityProvider.Seeding; - -public sealed class IdentityRoleSeederTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) -{ - [Fact(DisplayName = "Role seeding should create predefined roles when roles are missing")] - public async Task SeedAsync_Should_CreatePredefinedRoles_When_RolesAreMissing() - { - var cancellationToken = TestContext.Current.CancellationToken; - await using var scope = Fixture.CreateScope(); - var seeder = scope.ServiceProvider.GetRequiredService(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - await seeder.SeedAsync(cancellationToken); - - var roles = await dbContext.Set() - .Include(role => role.Claims) - .AsNoTracking() - .ToArrayAsync(cancellationToken); - roles.Select(role => role.Name).ShouldBe(["Developer", "Moderator", "Player"], ignoreOrder: true); - roles.ShouldAllBe(role => role.Id != Guid.Empty && role.CreatedAt != default && role.UpdatedAt != default); - roles.ShouldAllBe(role => string.Equals( - role.NormalizedName, role.Name!.ToUpperInvariant(), StringComparison.Ordinal)); - roles.ShouldAllBe(role => role.Claims.Count == 0); - } - - [Fact(DisplayName = "Role seeding should preserve IDs and claims when roles already exist")] - public async Task SeedAsync_Should_PreserveIdsAndClaims_When_RolesAlreadyExist() - { - var cancellationToken = TestContext.Current.CancellationToken; - await using var scope = Fixture.CreateScope(); - var seeder = scope.ServiceProvider.GetRequiredService(); - var manager = scope.ServiceProvider.GetRequiredService>(); - await seeder.SeedAsync(cancellationToken); - var role = await manager.FindByNameAsync(RoleName.Developer.Name); - role.ShouldNotBeNull(); - var id = role.Id; - (await manager.AddClaimAsync(role, new Claim("permission", "identity.users.read"))).Succeeded.ShouldBeTrue(); - - await seeder.SeedAsync(cancellationToken); - - var existing = await manager.FindByNameAsync(RoleName.Developer.Name); - existing.ShouldNotBeNull(); - existing.Id.ShouldBe(id); - (await manager.GetClaimsAsync(existing)).ShouldHaveSingleItem().Value.ShouldBe("identity.users.read"); - (await manager.Roles.CountAsync(cancellationToken)).ShouldBe(3); - } - - [Fact(DisplayName = "Role seeding should stop when cancellation is requested")] - public async Task SeedAsync_Should_Throw_When_CancellationIsRequested() - { - await using var scope = Fixture.CreateScope(); - var seeder = scope.ServiceProvider.GetRequiredService(); - using var cancellation = new CancellationTokenSource(); - await cancellation.CancelAsync(); - - var exception = await Should.ThrowAsync(() => seeder.SeedAsync(cancellation.Token)); - - exception.CancellationToken.ShouldBe(cancellation.Token); - } - - [Fact(DisplayName = "Role seeding should fail startup when role creation fails")] - public async Task SeedAsync_Should_Throw_When_RoleCreationFails() - { - await using var scope = Fixture.CreateScope(); - var seeder = scope.ServiceProvider.GetRequiredService(); - var manager = scope.ServiceProvider.GetRequiredService>(); - manager.RoleValidators.Add(new RejectingRoleValidator()); - - var exception = await Should.ThrowAsync( - () => seeder.SeedAsync(TestContext.Current.CancellationToken)); - - exception.Message.ShouldContain("Rejected role"); - } - - private sealed class RejectingRoleValidator : IRoleValidator - { - public Task ValidateAsync(RoleManager manager, ApplicationRole role) => - Task.FromResult(IdentityResult.Failed( - new IdentityError { Code = "Rejected", Description = "Rejected role" })); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index c64eb0b2..c89df5c4 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs @@ -8,12 +8,10 @@ using OpenIddict.EntityFrameworkCore.Models; using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Abstractions; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Events; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Claims; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; @@ -34,7 +32,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr var service = scope.ServiceProvider.GetRequiredService(); var userManager = scope.ServiceProvider.GetRequiredService>(); - await scope.ServiceProvider.GetRequiredService().SeedAsync(cancellationToken); + await Fixture.SeedRolesAsync(cancellationToken); var result = await service.RegisterAsync( " REGISTER@Example.COM ", @@ -48,7 +46,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr user.Email.ShouldBe("register@example.com"); user.UserName.ShouldBe("registered-hero"); user.EmailConfirmed.ShouldBeFalse(); - (await userManager.GetRolesAsync(user)).ShouldBe([RoleName.Player.Name]); + (await userManager.GetRolesAsync(user)).ShouldBe(["Player"]); var aggregateTracker = scope.ServiceProvider.GetRequiredService(); var trackedUser = aggregateTracker.GetAll() @@ -64,6 +62,27 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr domainEvent.ConfirmationToken.ShouldNotBeNullOrWhiteSpace(); } + [Fact(DisplayName = "Registration should assign the configured role when the default has a custom name")] + public async Task RegisterAsync_Should_AssignConfiguredRole_When_DefaultHasCustomName() + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var role = await dbContext.Set().SingleAsync(item => item.IsDefault, cancellationToken); + role.Name = "Newcomer"; + role.NormalizedName = "NEWCOMER"; + await dbContext.SaveChangesAsync(cancellationToken); + var service = scope.ServiceProvider.GetRequiredService(); + + var result = await service.RegisterAsync("newcomer@example.com", "newcomer", Password, cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var user = await dbContext.Set().Include(item => item.Roles) + .SingleAsync(item => item.Id == result.Value, cancellationToken); + user.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(role.Id); + } + [Theory( DisplayName = "RegisterAsync should reject invalid credentials without persistence when credentials are invalid")] [InlineData("invalid-email", "hero", 1)] @@ -102,8 +121,9 @@ public async Task RegisterAsync_Should_RejectInvalidDefaultRole_When_DefaultRole dbContext.Set().Add(new ApplicationRole { Id = Guid.Empty, - Name = RoleName.Player.Name, - NormalizedName = RoleName.Player.Name.ToUpperInvariant() + Name = "Player", + NormalizedName = "PLAYER", + IsDefault = true }); await dbContext.SaveChangesAsync(cancellationToken); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs new file mode 100644 index 00000000..e91da116 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; + +namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persistence.Core.Migrations; + +public sealed class SeedRolesAndClaimsTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact(DisplayName = "Seed migration should initialize roles and claims when roles are missing")] + public async Task Up_Should_CreateRolesAndClaims_When_RolesAreMissing() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await Fixture.SeedRolesAsync(cancellationToken); + + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var roles = await dbContext.Set() + .Include(role => role.Claims) + .AsNoTracking() + .ToArrayAsync(cancellationToken); + roles.Select(role => role.Name).ShouldBe(["Developer", "Moderator", "Player"], ignoreOrder: true); + roles.ShouldAllBe(role => role.Id != Guid.Empty && role.CreatedAt != default && role.UpdatedAt != default); + roles.ShouldAllBe(role => string.Equals( + role.NormalizedName, role.Name!.ToUpperInvariant(), StringComparison.Ordinal)); + roles.Single(role => role.IsDefault).Name.ShouldBe("Player"); + var developer = roles.Single(role => role.Name == "Developer"); + developer.Claims.Count(claim => claim.ClaimType == "permission").ShouldBe(20); + developer.Claims.Where(claim => claim.ClaimType == "section").Select(claim => claim.ClaimValue) + .ShouldBe(["identity", "compendium"], ignoreOrder: true); + var moderator = roles.Single(role => role.Name == "Moderator"); + moderator.Claims.Count.ShouldBe(13); + moderator.Claims.ShouldAllBe(claim => claim.ClaimValue!.StartsWith("compendium", StringComparison.Ordinal)); + roles.Single(role => role.Name == "Player").Claims.ShouldBeEmpty(); + dbContext.Database.HasPendingModelChanges().ShouldBeFalse(); + } + + [Fact(DisplayName = "Seed migration should preserve existing IDs and custom claims when roles already exist")] + public async Task Up_Should_PreserveIdsAndCustomClaims_When_RolesAlreadyExist() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var id = Guid.CreateVersion7(); + dbContext.Set().Add(new ApplicationRole + { + Id = id, + Name = "developer", + NormalizedName = "DEVELOPER", + IsDefault = true, + Claims = [new ApplicationRoleClaim { ClaimType = "custom", ClaimValue = "kept" }] + }); + await dbContext.SaveChangesAsync(cancellationToken); + + await Fixture.SeedRolesAsync(cancellationToken); + await Fixture.SeedRolesAsync(cancellationToken); + + dbContext.ChangeTracker.Clear(); + var roles = await dbContext.Set().Include(role => role.Claims).ToArrayAsync(cancellationToken); + roles.Length.ShouldBe(3); + var developer = roles.Single(role => role.Id == id); + developer.Name.ShouldBe("developer"); + developer.IsDefault.ShouldBeTrue(); + developer.Claims.Count.ShouldBe(23); + developer.Claims.ShouldContain(claim => claim.ClaimType == "custom" && claim.ClaimValue == "kept"); + roles.Single(role => role.Name == "Player").IsDefault.ShouldBeFalse(); + } + + [Fact(DisplayName = "Default role index should reject a second default when one already exists")] + public async Task SaveChangesAsync_Should_RejectSecondDefault_When_DefaultExists() + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var moderator = await dbContext.Set() + .SingleAsync(role => role.Name == "Moderator", cancellationToken); + moderator.IsDefault = true; + + await Should.ThrowAsync(() => dbContext.SaveChangesAsync(cancellationToken)); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs index 28455702..d069d820 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs @@ -23,16 +23,24 @@ public void ToDomain_Should_ReturnValidationFailures_When_ClaimIsInvalid() result.Errors.Count.ShouldBe(2); } - [Fact(DisplayName = "Role mapper should normalize and deduplicate claims when values are valid")] - public void ToDomain_Should_NormalizeAndDeduplicateClaims_When_ValuesAreValid() + [Theory(DisplayName = "Role mapper should normalize and deduplicate claims when values are valid")] + [InlineData(false)] + [InlineData(true)] + public void ToDomain_Should_NormalizeAndDeduplicateClaims_When_ValuesAreValid(bool isDefault) { - var result = ApplicationRoleMapper.ToDomain(CreateDbModel( + var dbModel = CreateDbModel( Guid.CreateVersion7(), " Developer ", - [(" permission ", " heroes.manage "), ("permission", "heroes.manage")])); + [(" permission ", " heroes.manage "), ("permission", "heroes.manage")]); + dbModel.IsDefault = isDefault; + + var result = ApplicationRoleMapper.ToDomain(dbModel); result.IsSuccess.ShouldBeTrue(); - result.Value.Name.Name.ShouldBe("Developer"); + result.Value.Name.Value.ShouldBe("Developer"); + result.Value.IsDefault.ShouldBe(isDefault); + ApplicationRoleMapper.ToDbModel(result.Value, DateTime.UtcNow, DateTime.UtcNow) + .IsDefault.ShouldBe(isDefault); var claim = result.Value.Claims.ShouldHaveSingleItem(); claim.Type.Value.ShouldBe("permission"); claim.Value.Value.ShouldBe("heroes.manage"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index d0b9582d..dc752970 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -113,6 +113,89 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); } + [Theory(DisplayName = "Role persistence should switch the default when a different role is selected")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveAsync_Should_SwitchDefault_When_DifferentRoleIsSelected(bool create) + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var previous = await dbContext.Set().SingleAsync(role => role.IsDefault, cancellationToken); + var targetId = create + ? Guid.CreateVersion7() + : await dbContext.Set().Where(role => role.Name == "Moderator") + .Select(role => role.Id).SingleAsync(cancellationToken); + var role = IntegrationTestData.CreateRole(targetId, "CustomDefault", [], isDefault: true); + var repository = scope.ServiceProvider.GetRequiredService(); + + if (create) + { + (await repository.AddAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); + } + else + { + (await repository.UpdateAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); + } + + var defaults = await dbContext.Set().AsNoTracking() + .Where(item => item.IsDefault).ToArrayAsync(cancellationToken); + defaults.ShouldHaveSingleItem().Id.ShouldBe(targetId); + previous.IsDefault.ShouldBeFalse(); + (await repository.DeleteAsync(previous.Id, cancellationToken)).IsSuccess.ShouldBeTrue(); + } + + [Theory(DisplayName = "Role persistence should retain the default when a duplicate name rejects the change")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveAsync_Should_RetainDefault_When_DuplicateNameRejectsChange(bool create) + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var targetId = create + ? Guid.CreateVersion7() + : await dbContext.Set().Where(role => role.Name == "Moderator") + .Select(role => role.Id).SingleAsync(cancellationToken); + var role = IntegrationTestData.CreateRole(targetId, "Developer", [], isDefault: true); + var repository = scope.ServiceProvider.GetRequiredService(); + + var result = create + ? (await repository.AddAsync(role, cancellationToken)).IsFailure + : (await repository.UpdateAsync(role, cancellationToken)).IsFailure; + + result.ShouldBeTrue(); + var defaultName = await dbContext.Set().AsNoTracking().Where(item => item.IsDefault) + .Select(item => item.Name).SingleAsync(cancellationToken); + defaultName.ShouldBe("Player"); + } + + [Theory(DisplayName = "Role persistence should reject removal when the role is the current default")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveAsync_Should_ReturnConflict_When_CurrentDefaultWouldBeRemoved(bool delete) + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var role = await dbContext.Set().SingleAsync(item => item.IsDefault, cancellationToken); + var repository = scope.ServiceProvider.GetRequiredService(); + + var result = delete + ? await repository.DeleteAsync(role.Id, cancellationToken) + : await repository.UpdateAsync( + IntegrationTestData.CreateRole(role.Id, "Renamed", [], isDefault: false), cancellationToken); + + result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); + var existing = await dbContext.Set().AsNoTracking() + .SingleAsync(item => item.Id == role.Id, cancellationToken); + existing.IsDefault.ShouldBeTrue(); + existing.Name.ShouldBe("Player"); + } + private async Task AddRoleAsync( string name, IReadOnlyCollection claims, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs index 0f767123..cd74c7a4 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -4,11 +4,9 @@ using OpenIddict.Abstractions; using OpenIddict.EntityFrameworkCore.Models; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; @@ -29,20 +27,20 @@ public async Task UpdateAsync_Should_SynchronizeRolesAndSessions_When_RoleAssign await using var scope = Fixture.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var repository = scope.ServiceProvider.GetRequiredService(); - await scope.ServiceProvider.GetRequiredService().SeedAsync(cancellationToken); + await Fixture.SeedRolesAsync(cancellationToken); var roles = await dbContext.Set().ToDictionaryAsync( role => role.Name!, role => role.Id, StringComparer.OrdinalIgnoreCase, cancellationToken); var userId = Guid.CreateVersion7(); var created = await repository.AddAsync( - CreateUser(userId, [roles[RoleName.Developer.Name]]), "StrongPassword1!", cancellationToken); + CreateUser(userId, [roles["Developer"]]), "StrongPassword1!", cancellationToken); created.IsSuccess.ShouldBeTrue(); var originalStamp = (await dbContext.Set() .SingleAsync(user => user.Id == userId, cancellationToken)).SecurityStamp; var targetRoles = mode switch { - "same" => new[] { roles[RoleName.Developer.Name] }, - "replace" => [roles[RoleName.Moderator.Name]], - "add" => [roles[RoleName.Developer.Name], roles[RoleName.Player.Name]], + "same" => new[] { roles["Developer"] }, + "replace" => [roles["Moderator"]], + "add" => [roles["Developer"], roles["Player"]], _ => [] }; var tokenManager = scope.ServiceProvider.GetRequiredService(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/IntegrationTestFixture.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/IntegrationTestFixture.cs index a6e7fa7f..c9d6cac3 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/IntegrationTestFixture.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/IntegrationTestFixture.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Migrations.Operations; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +9,7 @@ using PANiXiDA.TacticalHeroes.Identity.Infrastructure.DependencyInjection; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Options.IdentityProvider; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core.Migrations; using PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persistence; using PANiXiDA.TacticalHeroes.Testing.Databases; @@ -75,6 +77,17 @@ public AsyncServiceScope CreateScope() return _serviceProvider.CreateAsyncScope(); } + public async Task SeedRolesAsync(CancellationToken cancellationToken) + { + await using var scope = CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var migration = new SeedRolesAndClaims(); + foreach (var operation in migration.UpOperations.Cast()) + { + await dbContext.Database.ExecuteSqlRawAsync(operation.Sql, cancellationToken); + } + } + public Task ResetDatabaseAsync(CancellationToken cancellationToken) { return _database.ResetPostgreSqlDatabaseAsync(cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs index 16c2c781..e4221fac 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs @@ -1,7 +1,7 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Roles; @@ -10,13 +10,15 @@ internal static class IntegrationTestData internal static Role CreateRole( Guid id, string name, - IEnumerable<(string Type, string Value)> claims) + IEnumerable<(string Type, string Value)> claims, + bool isDefault = false) { return Role.Create( id: RoleId.Create(value: id).Value, name: RoleName.Create(value: name).Value, claims: claims.Select(claim => RoleClaim.Create( type: ClaimType.Create(value: claim.Type).Value, - value: ClaimValue.Create(value: claim.Value).Value))); + value: ClaimValue.Create(value: claim.Value).Value)), + isDefault: isDefault); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs index b68144e6..6ccc6850 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs @@ -8,15 +8,18 @@ namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Application.Roles.Create; public sealed class CreateRoleHandlerTests { - [Fact( + [Theory( DisplayName = "Create role handler should delegate creation to the write repository when repository succeeds")] - public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds() + [InlineData(false)] + [InlineData(true)] + public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds(bool isDefault) { var roleId = Guid.CreateVersion7(); IReadOnlyCollection claims = [new Claim("permission", "heroes.manage")]; var repository = Substitute.For(); repository.AddAsync( - Arg.Is(role => role.Name.Name == "Developer" + Arg.Is(role => role.Name.Value == "Developer" + && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), Arg @@ -26,13 +29,14 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds() var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new CreateRoleCommand("Developer", claims), + new CreateRoleCommand("Developer", claims, isDefault), cancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(roleId); await repository.Received(1).AddAsync( - Arg.Is(role => role.Name.Name == "Developer" + Arg.Is(role => role.Name.Value == "Developer" + && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs index df346894..420e2fc2 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs @@ -8,8 +8,11 @@ namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Application.Roles.Update; public sealed class UpdateRoleHandlerTests { - [Fact(DisplayName = "Update role handler should delegate update to the write repository when repository succeeds")] - public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() + [Theory( + DisplayName = "Update role handler should delegate update to the write repository when repository succeeds")] + [InlineData(false)] + [InlineData(true)] + public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds(bool isDefault) { var roleId = Guid.CreateVersion7(); IReadOnlyCollection claims = [new Claim("permission", "heroes.manage")]; @@ -17,7 +20,8 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() repository.UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Name == "Developer" + && role.Name.Value == "Developer" + && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), Arg.Any()) @@ -26,14 +30,15 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new UpdateRoleCommand(roleId, "Developer", claims), + new UpdateRoleCommand(roleId, "Developer", claims, isDefault), cancellationToken); result.IsSuccess.ShouldBeTrue(); await repository.Received(1).UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Name == "Developer" + && role.Name.Value == "Developer" + && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs deleted file mode 100644 index a67ecac0..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/Enumerations/RoleNameTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; - -namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.Enumerations; - -public sealed class RoleNameTests -{ - [Fact(DisplayName = "Default roles should retain their identifier order when called")] - public void GetAll_Should_ReturnRolesInIdOrder_When_Called() - { - var roles = RoleName.GetAll(); - - roles.ShouldBe([RoleName.Developer, RoleName.Moderator, RoleName.Player]); - } - - [Theory(DisplayName = "Default role should resolve its name when id is known")] - [InlineData(1, "Developer", "Разработчик")] - [InlineData(2, "Moderator", "Модератор")] - [InlineData(3, "Player", "Игрок")] - public void FromId_Should_ReturnRole_When_IdIsKnown(int id, string name, string displayName) - { - var role = RoleName.FromId(id); - - role.Name.ShouldBe(name); - role.DisplayName.ShouldBe(displayName); - } - - [Fact(DisplayName = "Default role should reject an unknown identifier when id is unknown")] - public void FromId_Should_Throw_When_IdIsUnknown() - { - Action action = () => RoleName.FromId(0); - - action.ShouldThrow(); - } - - [Theory(DisplayName = "Default role should resolve a trimmed name when name is known")] - [InlineData("Developer", 1)] - [InlineData(" Moderator ", 2)] - [InlineData("Player", 3)] - public void FromName_Should_ReturnRole_When_NameIsKnown(string name, int id) - { - var role = RoleName.FromName(name); - - role.Id.ShouldBe(id); - } - - [Theory(DisplayName = "Default role should reject an invalid name when name is invalid")] - [InlineData("admin")] - [InlineData("developer")] - [InlineData(" ")] - public void FromName_Should_Throw_When_NameIsInvalid(string name) - { - Action action = () => RoleName.FromName(name); - - action.ShouldThrow(); - } - - [Theory(DisplayName = "Default role lookup should report a match when id is provided")] - [InlineData(1, "Developer")] - [InlineData(2, "Moderator")] - [InlineData(3, "Player")] - [InlineData(0, null)] - public void TryFromId_Should_ReportMatch_When_IdIsProvided(int id, string? expectedName) - { - var found = RoleName.TryFromId(id, out var role); - - found.ShouldBe(expectedName is not null); - (role?.Name).ShouldBe(expectedName); - } - - [Theory(DisplayName = "Default role lookup should report a match when name is provided")] - [InlineData("Developer", 1)] - [InlineData(" Moderator ", 2)] - [InlineData("Player", 3)] - [InlineData("admin", null)] - [InlineData("developer", null)] - [InlineData(" ", null)] - public void TryFromName_Should_ReportMatch_When_NameIsProvided(string name, int? expectedId) - { - var found = RoleName.TryFromName(name, out var role); - - found.ShouldBe(expectedId.HasValue); - (role?.Id).ShouldBe(expectedId); - } - - [Fact(DisplayName = "Default role should be player when called")] - public void GetDefault_Should_ReturnPlayer_When_Called() - { - var role = RoleName.GetDefault(); - - role.ShouldBe(RoleName.Player); - } - - [Theory(DisplayName = "Role name factory should trim known names when name is known")] - [InlineData("Developer", 1)] - [InlineData(" Developer ", 1)] - [InlineData("Moderator", 2)] - [InlineData("Player", 3)] - public void Create_Should_ReturnRole_When_NameIsKnown(string name, int id) - { - var result = RoleName.Create(name); - - result.IsSuccess.ShouldBeTrue(); - result.Value.ShouldBe(RoleName.FromId(id)); - } - - [Theory(DisplayName = "Role name factory should reject missing names when value is empty")] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Create_Should_ReturnValidationFailure_When_ValueIsEmpty(string? value) - { - var result = RoleName.Create(value!); - - result.ShouldHaveSingleError(ErrorType.Validation, "Role name is required.") - .ShouldHaveField(nameof(RoleName)); - } - - [Theory(DisplayName = "Role name factory should reject unknown names when value is unknown")] - [InlineData("admin")] - [InlineData("developer")] - [InlineData("MODERATOR")] - public void Create_Should_ReturnValidationFailure_When_ValueIsUnknown(string value) - { - var result = RoleName.Create(value); - - result.ShouldHaveSingleError(ErrorType.Validation, $"Role name '{value}' is invalid.") - .ShouldHaveField(nameof(RoleName)); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs index 85f34463..171a3e71 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs @@ -1,24 +1,28 @@ using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Entities.RoleClaims.ValueObjects; -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Enumerations; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles; public sealed class RoleTests { - [Fact(DisplayName = "Create should restore persisted role state when persisted values are valid")] - public void Create_Should_RestoreState_When_PersistedValuesAreValid() + [Theory(DisplayName = "Create should restore persisted role state when persisted values are valid")] + [InlineData(false)] + [InlineData(true)] + public void Create_Should_RestoreState_When_PersistedValuesAreValid(bool isDefault) { var id = Guid.CreateVersion7(); var result = Role.Create( RoleId.Create(id).Value, RoleName.Create(" Developer ").Value, - [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)]); + [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)], + isDefault); result.Id.Value.ShouldBe(id); - result.Name.Name.ShouldBe("Developer"); + result.Name.Value.ShouldBe("Developer"); + result.IsDefault.ShouldBe(isDefault); var claim = result.Claims.ShouldHaveSingleItem(); claim.Type.Value.ShouldBe("permission"); claim.Value.Value.ShouldBe("heroes.manage"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs new file mode 100644 index 00000000..103a0e3f --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs @@ -0,0 +1,48 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; + +namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.ValueObjects; + +public sealed class RoleNameTests +{ + [Fact(DisplayName = "Role name should trim a valid value when role name is valid")] + public void Create_Should_TrimValue_When_RoleNameIsValid() + { + var result = RoleName.Create(" CustomManager "); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Value.ShouldBe("CustomManager"); + } + + [Theory(DisplayName = "Role name should reject an empty value when role name is empty")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_Should_ReturnValidationFailure_When_RoleNameIsEmpty(string? value) + { + var result = RoleName.Create(value!); + + result.ShouldHaveSingleError(ErrorType.Validation, "Role name is required.") + .ShouldHaveField(nameof(RoleName)); + } + + [Fact(DisplayName = "Role name should reject a value over the maximum length when role name is too long")] + public void Create_Should_ReturnValidationFailure_When_RoleNameIsTooLong() + { + var result = RoleName.Create(new string('a', RoleName.MaxLength + 1)); + + result.ShouldHaveSingleError( + ErrorType.Validation, + $"Role name cannot be longer than {RoleName.MaxLength} characters.") + .ShouldHaveField(nameof(RoleName)); + } + + [Fact(DisplayName = "Role name should format its value when converted to string")] + public void ToString_Should_FormatValue_When_ConvertedToString() + { + var roleName = RoleName.Create("CustomManager").Value; + + var result = roleName.ToString(); + + result.ShouldBe("RoleName { Value = CustomManager }"); + } +} From e6e4912661115204df4c00c84ad9ae43077eead1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 20:19:29 +0400 Subject: [PATCH 21/34] style: format default role tests --- .../Roles/DefaultRoleEndpointTests.cs | 12 ++++++--- .../Mappers/ApplicationRoleMapperTests.cs | 4 +-- .../Roles/Write/RolesRepositoryTests.cs | 25 +++++++++++++------ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs index c1081187..f19029ac 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs @@ -21,7 +21,8 @@ public async Task SaveRole_Should_SwitchDefault_When_CustomRoleIsSelected(bool c Guid id; if (create) { - id = (await client.CreateAsync(cancellationToken, + id = (await client.CreateAsync( + cancellationToken, new CreateRoleRequest("CustomDefault", [], IsDefault: true))).Id; } else @@ -39,7 +40,8 @@ await client.UpdateAsync( details.Name.ShouldBe("RenamedDefault"); details.IsDefault.ShouldBeTrue(); (await client.GetDetailsAsync(previous.Id, cancellationToken)).IsDefault.ShouldBeFalse(); - using var response = await Client.PostAsJsonAsync("/api/v1/auth/register", + using var response = await Client.PostAsJsonAsync( + "/api/v1/auth/register", new RegisterUserRequest("default@example.com", "new-user", "StrongPassword1!"), JsonOptions, cancellationToken); response.StatusCode.ShouldBe(HttpStatusCode.Created); @@ -59,11 +61,13 @@ public async Task SaveRole_Should_ReturnConflict_When_CurrentDefaultWouldBeRemov var client = new RoleApiTestClient(Fixture); var roles = await client.GetListAsync(cancellationToken); var current = roles.Items.Single(role => role.IsDefault); - using var request = new HttpRequestMessage(delete ? HttpMethod.Delete : HttpMethod.Put, + using var request = new HttpRequestMessage( + delete ? HttpMethod.Delete : HttpMethod.Put, $"/api/v1/roles/{current.Id}"); if (!delete) { - request.Content = JsonContent.Create(new UpdateRoleRequest("Player", [], IsDefault: false), + request.Content = JsonContent.Create( + new UpdateRoleRequest("Player", [], IsDefault: false), options: JsonOptions); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs index d069d820..c4bf950c 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs @@ -39,8 +39,8 @@ public void ToDomain_Should_NormalizeAndDeduplicateClaims_When_ValuesAreValid(bo result.IsSuccess.ShouldBeTrue(); result.Value.Name.Value.ShouldBe("Developer"); result.Value.IsDefault.ShouldBe(isDefault); - ApplicationRoleMapper.ToDbModel(result.Value, DateTime.UtcNow, DateTime.UtcNow) - .IsDefault.ShouldBe(isDefault); + var mapped = ApplicationRoleMapper.ToDbModel(result.Value, DateTime.UtcNow, DateTime.UtcNow); + mapped.IsDefault.ShouldBe(isDefault); var claim = result.Value.Claims.ShouldHaveSingleItem(); claim.Type.Value.ShouldBe("permission"); claim.Value.Value.ShouldBe("heroes.manage"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index dc752970..178347a7 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -125,8 +125,10 @@ public async Task SaveAsync_Should_SwitchDefault_When_DifferentRoleIsSelected(bo var previous = await dbContext.Set().SingleAsync(role => role.IsDefault, cancellationToken); var targetId = create ? Guid.CreateVersion7() - : await dbContext.Set().Where(role => role.Name == "Moderator") - .Select(role => role.Id).SingleAsync(cancellationToken); + : await dbContext.Set() + .Where(role => role.Name == "Moderator") + .Select(role => role.Id) + .SingleAsync(cancellationToken); var role = IntegrationTestData.CreateRole(targetId, "CustomDefault", [], isDefault: true); var repository = scope.ServiceProvider.GetRequiredService(); @@ -139,8 +141,10 @@ public async Task SaveAsync_Should_SwitchDefault_When_DifferentRoleIsSelected(bo (await repository.UpdateAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); } - var defaults = await dbContext.Set().AsNoTracking() - .Where(item => item.IsDefault).ToArrayAsync(cancellationToken); + var defaults = await dbContext.Set() + .AsNoTracking() + .Where(item => item.IsDefault) + .ToArrayAsync(cancellationToken); defaults.ShouldHaveSingleItem().Id.ShouldBe(targetId); previous.IsDefault.ShouldBeFalse(); (await repository.DeleteAsync(previous.Id, cancellationToken)).IsSuccess.ShouldBeTrue(); @@ -157,8 +161,10 @@ public async Task SaveAsync_Should_RetainDefault_When_DuplicateNameRejectsChange var dbContext = scope.ServiceProvider.GetRequiredService(); var targetId = create ? Guid.CreateVersion7() - : await dbContext.Set().Where(role => role.Name == "Moderator") - .Select(role => role.Id).SingleAsync(cancellationToken); + : await dbContext.Set() + .Where(role => role.Name == "Moderator") + .Select(role => role.Id) + .SingleAsync(cancellationToken); var role = IntegrationTestData.CreateRole(targetId, "Developer", [], isDefault: true); var repository = scope.ServiceProvider.GetRequiredService(); @@ -167,8 +173,11 @@ public async Task SaveAsync_Should_RetainDefault_When_DuplicateNameRejectsChange : (await repository.UpdateAsync(role, cancellationToken)).IsFailure; result.ShouldBeTrue(); - var defaultName = await dbContext.Set().AsNoTracking().Where(item => item.IsDefault) - .Select(item => item.Name).SingleAsync(cancellationToken); + var defaultName = await dbContext.Set() + .AsNoTracking() + .Where(item => item.IsDefault) + .Select(item => item.Name) + .SingleAsync(cancellationToken); defaultName.ShouldBe("Player"); } From e50a8e45ac71a738e9d2f62e1ca87845154d1449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 20:21:22 +0400 Subject: [PATCH 22/34] fix: follow domain and test conventions for default roles --- .../Roles/Create/CreateRoleHandler.cs | 2 +- .../Roles/Update/UpdateRoleHandler.cs | 2 +- .../Roles/Role.cs | 15 ++++++---- .../Roles/ValueObjects/RoleDefaultStatus.cs | 16 ++++++++++ .../Core/IdentityWriteDbContext.cs | 2 +- .../Write/Mappers/ApplicationRoleMapper.cs | 2 +- .../Features/Roles/Write/RolesRepository.cs | 30 +++++++++++-------- .../Roles/DefaultRoleEndpointTests.cs | 4 +-- .../Services/UserCredentialsServiceTests.cs | 2 +- .../Migrations/SeedRolesAndClaimsTests.cs | 10 +++++-- .../Roles/Write/RolesRepositoryTests.cs | 6 ++-- .../Roles/IntegrationTestData.cs | 2 +- .../Domain/Roles/RoleTests.cs | 2 +- .../ValueObjects/RoleDefaultStatusTests.cs | 28 +++++++++++++++++ 14 files changed, 92 insertions(+), 31 deletions(-) create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs index f2c2901c..511faedb 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs @@ -41,7 +41,7 @@ public Task> HandleAsync( id: RoleId.New(), name: nameResult.Value, claims: claims, - isDefault: command.IsDefault); + defaultStatus: RoleDefaultStatus.From(isDefault: command.IsDefault)); return rolesRepository.AddAsync( role: role, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs index d509a48a..eedd6660 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs @@ -42,7 +42,7 @@ public Task HandleAsync( id: idResult.Value, name: nameResult.Value, claims: claims, - isDefault: command.IsDefault); + defaultStatus: RoleDefaultStatus.From(isDefault: command.IsDefault)); return rolesRepository.UpdateAsync( role: role, diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs index 6bbcc98f..58549aea 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs @@ -11,16 +11,18 @@ public sealed class Role : AggregateRoot private Role( RoleId id, RoleName name, - bool isDefault) + RoleDefaultStatus defaultStatus) : base(id) { Name = name; - IsDefault = isDefault; + DefaultStatus = defaultStatus; } public RoleName Name { get; private set; } - public bool IsDefault { get; private set; } + public RoleDefaultStatus DefaultStatus { get; private set; } + + public bool IsDefault => DefaultStatus.IsDefault; public IReadOnlyCollection Claims => _claims.AsReadOnly(); @@ -28,9 +30,12 @@ public static Role Create( RoleId id, RoleName name, IEnumerable claims, - bool isDefault = false) + RoleDefaultStatus? defaultStatus = null) { - var role = new Role(id: id, name: name, isDefault: isDefault); + var role = new Role( + id: id, + name: name, + defaultStatus: defaultStatus ?? RoleDefaultStatus.From(isDefault: false)); foreach (var claim in claims) { diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs new file mode 100644 index 00000000..95a6abd1 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs @@ -0,0 +1,16 @@ +namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; + +public sealed partial class RoleDefaultStatus : ValueObject +{ + private RoleDefaultStatus(bool isDefault) + { + IsDefault = isDefault; + } + + public bool IsDefault { get; } + + public static RoleDefaultStatus From(bool isDefault) + { + return new RoleDefaultStatus(isDefault: isDefault); + } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs index f4a8fac9..028693d3 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs @@ -99,7 +99,7 @@ private static void ConfigureAspNetIdentity(ModelBuilder modelBuilder) builder.Property(role => role.Name).HasMaxLength(RoleName.MaxLength).IsRequired(); builder.Property(role => role.NormalizedName).HasMaxLength(RoleName.MaxLength).IsRequired(); - builder.Property(role => role.IsDefault).HasDefaultValue(false); + builder.Property(role => role.IsDefault).HasDefaultValue(value: false); builder.HasIndex(role => role.IsDefault) .IsUnique() .HasFilter("is_default"); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs index 10d89009..d8b2f283 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs @@ -82,6 +82,6 @@ public static Result ToDomain(ApplicationRole role) id: idResult.Value, name: nameResult.Value, claims: domainClaims, - isDefault: role.IsDefault)); + defaultStatus: RoleDefaultStatus.From(isDefault: role.IsDefault))); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs index 8e7e2922..27dff1d3 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs @@ -40,7 +40,10 @@ public async Task> AddAsync( return IdentityResultMapper.ToResult(result: identityResult); } - await SetDefaultAsync(applicationRole, role.IsDefault, cancellationToken); + await SetDefaultAsync( + role: applicationRole, + isDefault: role.IsDefault, + cancellationToken: cancellationToken); if (transaction is not null) { await transaction.CommitAsync(cancellationToken); @@ -91,7 +94,10 @@ public async Task UpdateAsync( return IdentityResultMapper.ToResult(result: identityResult); } - await SetDefaultAsync(applicationRole, role.IsDefault, cancellationToken); + await SetDefaultAsync( + role: applicationRole, + isDefault: role.IsDefault, + cancellationToken: cancellationToken); if (transaction is not null) { await transaction.CommitAsync(cancellationToken); @@ -149,6 +155,16 @@ public async Task DeleteAsync( return Result.Success(); } + private static Result DefaultRoleConflict() + { + return Result.Failure(error: Error.Conflict(message: "Select another default role first.")); + } + + private static Result RoleNotFound() + { + return Result.Failure(error: Error.NotFound(message: "Role was not found.")); + } + private Task LockRolesAsync(CancellationToken cancellationToken) { return dbContext.Database.ExecuteSqlRawAsync( @@ -179,16 +195,6 @@ private async Task SetDefaultAsync( await dbContext.SaveChangesAsync(cancellationToken); } - private static Result DefaultRoleConflict() - { - return Result.Failure(error: Error.Conflict(message: "Select another default role first.")); - } - - private static Result RoleNotFound() - { - return Result.Failure(error: Error.NotFound(message: "Role was not found.")); - } - private void SyncClaims( ApplicationRole applicationRole, Role role) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs index f19029ac..c4a5fb3b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs @@ -8,7 +8,7 @@ namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features public sealed class DefaultRoleEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) { - [Theory(DisplayName = "Role requests should switch the default when a custom role is selected")] + [Theory(DisplayName = "Role requests should switch the default when custom role is selected")] [InlineData(false)] [InlineData(true)] public async Task SaveRole_Should_SwitchDefault_When_CustomRoleIsSelected(bool create) @@ -52,7 +52,7 @@ await client.UpdateAsync( user.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(id); } - [Theory(DisplayName = "Role requests should return conflict when the current default would be removed")] + [Theory(DisplayName = "Role requests should return conflict when current default would be removed")] [InlineData(false)] [InlineData(true)] public async Task SaveRole_Should_ReturnConflict_When_CurrentDefaultWouldBeRemoved(bool delete) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index c89df5c4..7a8d7f15 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs @@ -62,7 +62,7 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr domainEvent.ConfirmationToken.ShouldNotBeNullOrWhiteSpace(); } - [Fact(DisplayName = "Registration should assign the configured role when the default has a custom name")] + [Fact(DisplayName = "Registration should assign the configured role when default has custom name")] public async Task RegisterAsync_Should_AssignConfiguredRole_When_DefaultHasCustomName() { var cancellationToken = TestContext.Current.CancellationToken; diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs index e91da116..15c4e6b4 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Npgsql; + using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; @@ -68,7 +70,7 @@ public async Task Up_Should_PreserveIdsAndCustomClaims_When_RolesAlreadyExist() roles.Single(role => role.Name == "Player").IsDefault.ShouldBeFalse(); } - [Fact(DisplayName = "Default role index should reject a second default when one already exists")] + [Fact(DisplayName = "Default role index should reject a second default when default exists")] public async Task SaveChangesAsync_Should_RejectSecondDefault_When_DefaultExists() { var cancellationToken = TestContext.Current.CancellationToken; @@ -79,6 +81,10 @@ public async Task SaveChangesAsync_Should_RejectSecondDefault_When_DefaultExists .SingleAsync(role => role.Name == "Moderator", cancellationToken); moderator.IsDefault = true; - await Should.ThrowAsync(() => dbContext.SaveChangesAsync(cancellationToken)); + var exception = await Should.ThrowAsync( + () => dbContext.SaveChangesAsync(cancellationToken)); + + var postgresException = exception.InnerException.ShouldBeOfType(); + postgresException.ConstraintName.ShouldBe("ix_asp_net_roles_is_default"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index 178347a7..91a3abba 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -113,7 +113,7 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); } - [Theory(DisplayName = "Role persistence should switch the default when a different role is selected")] + [Theory(DisplayName = "Role persistence should switch the default when different role is selected")] [InlineData(false)] [InlineData(true)] public async Task SaveAsync_Should_SwitchDefault_When_DifferentRoleIsSelected(bool create) @@ -150,7 +150,7 @@ public async Task SaveAsync_Should_SwitchDefault_When_DifferentRoleIsSelected(bo (await repository.DeleteAsync(previous.Id, cancellationToken)).IsSuccess.ShouldBeTrue(); } - [Theory(DisplayName = "Role persistence should retain the default when a duplicate name rejects the change")] + [Theory(DisplayName = "Role persistence should retain the default when duplicate name rejects change")] [InlineData(false)] [InlineData(true)] public async Task SaveAsync_Should_RetainDefault_When_DuplicateNameRejectsChange(bool create) @@ -181,7 +181,7 @@ public async Task SaveAsync_Should_RetainDefault_When_DuplicateNameRejectsChange defaultName.ShouldBe("Player"); } - [Theory(DisplayName = "Role persistence should reject removal when the role is the current default")] + [Theory(DisplayName = "Role persistence should reject removal when current default would be removed")] [InlineData(false)] [InlineData(true)] public async Task SaveAsync_Should_ReturnConflict_When_CurrentDefaultWouldBeRemoved(bool delete) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs index e4221fac..03b9d332 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs @@ -19,6 +19,6 @@ internal static Role CreateRole( claims: claims.Select(claim => RoleClaim.Create( type: ClaimType.Create(value: claim.Type).Value, value: ClaimValue.Create(value: claim.Value).Value)), - isDefault: isDefault); + defaultStatus: RoleDefaultStatus.From(isDefault: isDefault)); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs index 171a3e71..b71ab757 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs @@ -18,7 +18,7 @@ public void Create_Should_RestoreState_When_PersistedValuesAreValid(bool isDefau RoleId.Create(id).Value, RoleName.Create(" Developer ").Value, [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)], - isDefault); + RoleDefaultStatus.From(isDefault)); result.Id.Value.ShouldBe(id); result.Name.Value.ShouldBe("Developer"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs new file mode 100644 index 00000000..4d808f30 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs @@ -0,0 +1,28 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; + +namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.ValueObjects; + +public sealed class RoleDefaultStatusTests +{ + [Theory(DisplayName = "Role default status should preserve the flag when boolean is provided")] + [InlineData(false)] + [InlineData(true)] + public void From_Should_PreserveFlag_When_BooleanIsProvided(bool isDefault) + { + var status = RoleDefaultStatus.From(isDefault); + + status.IsDefault.ShouldBe(isDefault); + } + + [Theory(DisplayName = "Role default status should format its flag when converted to string")] + [InlineData(false, "RoleDefaultStatus { IsDefault = False }")] + [InlineData(true, "RoleDefaultStatus { IsDefault = True }")] + public void ToString_Should_FormatFlag_When_ConvertedToString(bool isDefault, string expected) + { + var status = RoleDefaultStatus.From(isDefault); + + var result = status.ToString(); + + result.ShouldBe(expected); + } +} From 9a56724e0a7cc06f4724d0c1df1a8417aa0fba93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 20:22:00 +0400 Subject: [PATCH 23/34] style: align registration test arguments --- .../Presentation/Features/Roles/DefaultRoleEndpointTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs index c4a5fb3b..9b3f2e3f 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs @@ -43,7 +43,8 @@ await client.UpdateAsync( using var response = await Client.PostAsJsonAsync( "/api/v1/auth/register", new RegisterUserRequest("default@example.com", "new-user", "StrongPassword1!"), - JsonOptions, cancellationToken); + JsonOptions, + cancellationToken); response.StatusCode.ShouldBe(HttpStatusCode.Created); var registered = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); registered.ShouldNotBeNull(); From b9d6a151b44f35ce4fcebaf2d3b1af96b571a3bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 20:29:16 +0400 Subject: [PATCH 24/34] fix: preserve role lock task result --- .../Persistence/Features/Roles/Write/RolesRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs index 27dff1d3..88b9d327 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs @@ -165,7 +165,7 @@ private static Result RoleNotFound() return Result.Failure(error: Error.NotFound(message: "Role was not found.")); } - private Task LockRolesAsync(CancellationToken cancellationToken) + private Task LockRolesAsync(CancellationToken cancellationToken) { return dbContext.Database.ExecuteSqlRawAsync( "LOCK TABLE identity.asp_net_roles IN SHARE ROW EXCLUSIVE MODE", cancellationToken); From 1e0dfccae1cfa1269903a3a9b936ae131a5a4327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 20:48:05 +0400 Subject: [PATCH 25/34] refactor: validate explicit default role assignments --- README.md | 6 +- .../Roles/Create/CreateRoleCommand.cs | 4 +- .../Create/CreateRoleCommandValidator.cs | 3 + .../Roles/Create/CreateRoleHandler.cs | 30 ++++-- .../Roles/GetDetails/RoleDetailsReadModel.cs | 4 +- .../Roles/GetList/RoleListItemReadModel.cs | 2 +- .../Roles/Update/UpdateRoleCommand.cs | 4 +- .../Update/UpdateRoleCommandValidator.cs | 3 + .../Roles/Update/UpdateRoleHandler.cs | 30 ++++-- .../Roles/Abstractions/IRolesRepository.cs | 2 + .../Roles/Role.cs | 14 +-- .../Roles/ValueObjects/RoleDefaultFlag.cs | 16 ++++ .../Roles/ValueObjects/RoleDefaultStatus.cs | 16 ---- .../GetDetails/RoleDetailsReadModelMapper.cs | 4 +- .../Write/Mappers/ApplicationRoleMapper.cs | 7 +- .../Features/Roles/Write/RolesRepository.cs | 95 ++++--------------- .../Roles/Create/CreateRoleRequest.cs | 4 +- .../GetDetails/GetRoleDetailsResponse.cs | 4 +- .../Roles/GetList/RoleListItemResponse.cs | 2 +- .../Roles/Update/UpdateRoleRequest.cs | 4 +- .../Claims/ClaimAssignmentEndpointTests.cs | 7 +- .../Roles/Create/CreateRoleEndpointTests.cs | 1 + .../Roles/DefaultRoleEndpointTests.cs | 84 ++++++++++++---- .../Features/Roles/RoleApiTestClient.cs | 4 +- .../Features/Roles/RoleNameEndpointTests.cs | 2 +- .../Roles/Update/UpdateRoleEndpointTests.cs | 1 + .../Roles/Write/RolesRepositoryTests.cs | 74 ++++++++++++--- .../Roles/IntegrationTestData.cs | 4 +- .../Create/CreateRoleCommandValidatorTests.cs | 4 +- .../Roles/Create/CreateRoleHandlerTests.cs | 38 +++++++- .../GetDetails/GetRoleDetailsHandlerTests.cs | 2 +- .../Roles/GetList/GetRolesHandlerTests.cs | 2 +- .../Update/UpdateRoleCommandValidatorTests.cs | 3 +- .../Roles/Update/UpdateRoleHandlerTests.cs | 59 +++++++++++- .../Domain/Roles/RoleTests.cs | 9 +- .../ValueObjects/RoleDefaultFlagTests.cs | 29 ++++++ .../ValueObjects/RoleDefaultStatusTests.cs | 28 ------ 37 files changed, 392 insertions(+), 213 deletions(-) create mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultFlag.cs delete mode 100644 src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultFlagTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs diff --git a/README.md b/README.md index bc273d22..e0313ae0 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,10 @@ Player has no administrative claims and is the initial default role. Startup doe Existing users are not reassigned automatically. Role names are arbitrary nonempty values up to 128 characters, trimmed without changing case. -Role create/update requests and list/details responses include `isDefault`. Selecting a new default -switches it atomically; the current default cannot be deleted or unset directly (HTTP 409). +Role create/update requests require `isDefault` before `claims`; list/details responses include the flag. +Selecting a default while another role has the flag returns HTTP 409. To change it, clear the previous +flag and then select the new role. Editing the current default is allowed; deleting it returns HTTP 409. +The unique database index also prevents duplicate defaults in concurrent requests. Registration selects the role marked `isDefault`, independently of its name. User create/update requests require `roleIds` (GUID array), just like `claims`; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs index 9e94da4c..1975e8e1 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommand.cs @@ -4,5 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; public sealed record CreateRoleCommand( string Name, - IReadOnlyCollection Claims, - bool IsDefault = false) : ICommand>; + bool IsDefault, + IReadOnlyCollection Claims) : ICommand>; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs index 6383d385..0f573f62 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleCommandValidator.cs @@ -11,6 +11,9 @@ public CreateRoleCommandValidator() RuleFor(command => command.Name) .MustBeValidDomainValue(RoleName.Create); + RuleFor(command => command.IsDefault) + .MustBeValidDomainValue(RoleDefaultFlag.Create); + RuleFor(command => command.Claims) .NotNull(); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs index 511faedb..768ba60a 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Create/CreateRoleHandler.cs @@ -9,16 +9,17 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; public sealed class CreateRoleHandler(IRolesRepository rolesRepository) : ICommandHandler> { - public Task> HandleAsync( + public async Task> HandleAsync( CreateRoleCommand command, CancellationToken cancellationToken) { var nameResult = RoleName.Create(value: command.Name); - var validationResult = Result.Combine(nameResult); + var defaultFlagResult = RoleDefaultFlag.Create(value: command.IsDefault); + var validationResult = Result.Combine(nameResult, defaultFlagResult); if (validationResult.IsFailure) { - return Task.FromResult(Result.Failure(errors: validationResult.Errors)); + return Result.Failure(errors: validationResult.Errors); } var claims = new List(); @@ -31,19 +32,34 @@ public Task> HandleAsync( if (claimResult.IsFailure) { - return Task.FromResult(Result.Failure(errors: claimResult.Errors)); + return Result.Failure(errors: claimResult.Errors); } claims.Add(RoleClaim.Create(type: typeResult.Value, value: valueResult.Value)); } + if (command.IsDefault) + { + var defaultRoleResult = await rolesRepository.GetDefaultAsync(cancellationToken); + if (defaultRoleResult.IsFailure) + { + return Result.Failure(errors: defaultRoleResult.Errors); + } + + if (defaultRoleResult.Value is not null) + { + return Result.Failure( + error: Error.Conflict(message: "Default role already exists.")); + } + } + var role = Role.Create( id: RoleId.New(), name: nameResult.Value, - claims: claims, - defaultStatus: RoleDefaultStatus.From(isDefault: command.IsDefault)); + defaultFlag: defaultFlagResult.Value, + claims: claims); - return rolesRepository.AddAsync( + return await rolesRepository.AddAsync( role: role, cancellationToken: cancellationToken); } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs index 6862e526..08b0ac02 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetDetails/RoleDetailsReadModel.cs @@ -5,5 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.GetDetails; public sealed record RoleDetailsReadModel( Guid Id, string Name, - IReadOnlyCollection Claims, - bool IsDefault = false) : IReadModel; + bool IsDefault, + IReadOnlyCollection Claims) : IReadModel; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs index d1e790d9..4fbd5462 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/GetList/RoleListItemReadModel.cs @@ -3,4 +3,4 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.GetList; public sealed record RoleListItemReadModel( Guid Id, string Name, - bool IsDefault = false) : IReadModel; + bool IsDefault) : IReadModel; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs index d67592da..6383e3c7 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommand.cs @@ -5,5 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; public sealed record UpdateRoleCommand( Guid Id, string Name, - IReadOnlyCollection Claims, - bool IsDefault = false) : ICommand; + bool IsDefault, + IReadOnlyCollection Claims) : ICommand; diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs index 7d3b234c..07c09d46 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleCommandValidator.cs @@ -15,6 +15,9 @@ public UpdateRoleCommandValidator() RuleFor(command => command.Name) .MustBeValidDomainValue(RoleName.Create); + RuleFor(command => command.IsDefault) + .MustBeValidDomainValue(RoleDefaultFlag.Create); + RuleFor(command => command.Claims) .NotNull(); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs index eedd6660..e2b21498 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Application/Roles/Update/UpdateRoleHandler.cs @@ -9,17 +9,18 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; public sealed class UpdateRoleHandler(IRolesRepository rolesRepository) : ICommandHandler { - public Task HandleAsync( + public async Task HandleAsync( UpdateRoleCommand command, CancellationToken cancellationToken) { var idResult = RoleId.Create(value: command.Id); var nameResult = RoleName.Create(value: command.Name); - var validationResult = Result.Combine(idResult, nameResult); + var defaultFlagResult = RoleDefaultFlag.Create(value: command.IsDefault); + var validationResult = Result.Combine(idResult, nameResult, defaultFlagResult); if (validationResult.IsFailure) { - return Task.FromResult(Result.Failure(errors: validationResult.Errors)); + return Result.Failure(errors: validationResult.Errors); } var claims = new List(); @@ -32,19 +33,34 @@ public Task HandleAsync( if (claimResult.IsFailure) { - return Task.FromResult(Result.Failure(errors: claimResult.Errors)); + return Result.Failure(errors: claimResult.Errors); } claims.Add(RoleClaim.Create(type: typeResult.Value, value: valueResult.Value)); } + if (command.IsDefault) + { + var defaultRoleResult = await rolesRepository.GetDefaultAsync(cancellationToken); + if (defaultRoleResult.IsFailure) + { + return Result.Failure(errors: defaultRoleResult.Errors); + } + + if (defaultRoleResult.Value is { } defaultRole && defaultRole.Id != idResult.Value) + { + return Result.Failure( + error: Error.Conflict(message: "Default role already exists.")); + } + } + var role = Role.Create( id: idResult.Value, name: nameResult.Value, - claims: claims, - defaultStatus: RoleDefaultStatus.From(isDefault: command.IsDefault)); + defaultFlag: defaultFlagResult.Value, + claims: claims); - return rolesRepository.UpdateAsync( + return await rolesRepository.UpdateAsync( role: role, cancellationToken: cancellationToken); } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Abstractions/IRolesRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Abstractions/IRolesRepository.cs index 9212c167..4ccbd329 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Abstractions/IRolesRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Abstractions/IRolesRepository.cs @@ -2,6 +2,8 @@ namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; public interface IRolesRepository { + Task> GetDefaultAsync(CancellationToken cancellationToken); + Task> AddAsync( Role role, CancellationToken cancellationToken); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs index 58549aea..d0160f88 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs @@ -11,31 +11,31 @@ public sealed class Role : AggregateRoot private Role( RoleId id, RoleName name, - RoleDefaultStatus defaultStatus) + RoleDefaultFlag defaultFlag) : base(id) { Name = name; - DefaultStatus = defaultStatus; + DefaultFlag = defaultFlag; } public RoleName Name { get; private set; } - public RoleDefaultStatus DefaultStatus { get; private set; } + public RoleDefaultFlag DefaultFlag { get; private set; } - public bool IsDefault => DefaultStatus.IsDefault; + public bool IsDefault => DefaultFlag.Value; public IReadOnlyCollection Claims => _claims.AsReadOnly(); public static Role Create( RoleId id, RoleName name, - IEnumerable claims, - RoleDefaultStatus? defaultStatus = null) + RoleDefaultFlag defaultFlag, + IEnumerable claims) { var role = new Role( id: id, name: name, - defaultStatus: defaultStatus ?? RoleDefaultStatus.From(isDefault: false)); + defaultFlag: defaultFlag); foreach (var claim in claims) { diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultFlag.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultFlag.cs new file mode 100644 index 00000000..32562398 --- /dev/null +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultFlag.cs @@ -0,0 +1,16 @@ +namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; + +public sealed partial class RoleDefaultFlag : ValueObject +{ + private RoleDefaultFlag(bool value) + { + Value = value; + } + + public bool Value { get; } + + public static Result Create(bool value) + { + return Result.Success(value: new RoleDefaultFlag(value: value)); + } +} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs deleted file mode 100644 index 95a6abd1..00000000 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleDefaultStatus.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; - -public sealed partial class RoleDefaultStatus : ValueObject -{ - private RoleDefaultStatus(bool isDefault) - { - IsDefault = isDefault; - } - - public bool IsDefault { get; } - - public static RoleDefaultStatus From(bool isDefault) - { - return new RoleDefaultStatus(isDefault: isDefault); - } -} diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs index 19776599..749a103e 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Read/GetDetails/RoleDetailsReadModelMapper.cs @@ -14,11 +14,11 @@ public static IQueryable ProjectTo( return query.Select(role => new RoleDetailsReadModel( Id: role.Id, Name: role.Name!, + IsDefault: role.IsDefault, Claims: role.Claims .Select(claim => new Claim( type: claim.ClaimType!, value: claim.ClaimValue!)) - .ToArray(), - IsDefault: role.IsDefault)); + .ToArray())); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs index d8b2f283..c3eb3f13 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapper.cs @@ -55,7 +55,8 @@ public static Result ToDomain(ApplicationRole role) { var idResult = RoleId.Create(value: role.Id); var nameResult = RoleName.Create(value: role.Name!); - var validationResult = Result.Combine(idResult, nameResult); + var defaultFlagResult = RoleDefaultFlag.Create(value: role.IsDefault); + var validationResult = Result.Combine(idResult, nameResult, defaultFlagResult); if (validationResult.IsFailure) { @@ -81,7 +82,7 @@ public static Result ToDomain(ApplicationRole role) return Result.Success(value: Role.Create( id: idResult.Value, name: nameResult.Value, - claims: domainClaims, - defaultStatus: RoleDefaultStatus.From(isDefault: role.IsDefault))); + defaultFlag: defaultFlagResult.Value, + claims: domainClaims)); } } diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs index 88b9d327..47bad8d7 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs @@ -17,22 +17,33 @@ public sealed class RolesRepository( TimeProvider timeProvider) : IRolesRepository { + public async Task> GetDefaultAsync(CancellationToken cancellationToken) + { + var applicationRole = await roleManager.Roles + .AsNoTracking() + .Include(role => role.Claims) + .SingleOrDefaultAsync(role => role.IsDefault, cancellationToken); + if (applicationRole is null) + { + return Result.Success(value: null); + } + + var roleResult = ApplicationRoleMapper.ToDomain(role: applicationRole); + return roleResult.IsSuccess + ? Result.Success(value: roleResult.Value) + : Result.Failure(errors: roleResult.Errors); + } + public async Task> AddAsync( Role role, CancellationToken cancellationToken) { - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - await LockRolesAsync(cancellationToken); - var nowUtc = timeProvider.GetUtcNow().UtcDateTime; var applicationRole = ApplicationRoleMapper.ToDbModel( role: role, createdAt: nowUtc, updatedAt: nowUtc); - applicationRole.IsDefault = false; var identityResult = await roleManager.CreateAsync(role: applicationRole); if (!identityResult.Succeeded) @@ -40,15 +51,6 @@ public async Task> AddAsync( return IdentityResultMapper.ToResult(result: identityResult); } - await SetDefaultAsync( - role: applicationRole, - isDefault: role.IsDefault, - cancellationToken: cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - aggregateTracker.Track(role); return Result.Success(value: applicationRole.Id); @@ -58,11 +60,6 @@ public async Task UpdateAsync( Role role, CancellationToken cancellationToken) { - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - await LockRolesAsync(cancellationToken); - var applicationRole = await roleManager.Roles .Include(item => item.Claims) .SingleOrDefaultAsync(item => item.Id == role.Id.Value, cancellationToken); @@ -72,17 +69,10 @@ public async Task UpdateAsync( return RoleNotFound(); } - if (applicationRole.IsDefault && !role.IsDefault) - { - return DefaultRoleConflict(); - } - - var wasDefault = applicationRole.IsDefault; ApplicationRoleMapper.MapToDbModel( role: role, dbModel: applicationRole, updatedAt: timeProvider.GetUtcNow().UtcDateTime); - applicationRole.IsDefault = wasDefault; SyncClaims( applicationRole: applicationRole, role: role); @@ -94,15 +84,6 @@ public async Task UpdateAsync( return IdentityResultMapper.ToResult(result: identityResult); } - await SetDefaultAsync( - role: applicationRole, - isDefault: role.IsDefault, - cancellationToken: cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - aggregateTracker.Track(role); return Result.Success(); @@ -112,11 +93,6 @@ public async Task DeleteAsync( Guid id, CancellationToken cancellationToken) { - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - await LockRolesAsync(cancellationToken); - var applicationRole = await roleManager.Roles .Include(role => role.Claims) .SingleOrDefaultAsync(role => role.Id == id, cancellationToken); @@ -145,11 +121,6 @@ public async Task DeleteAsync( return IdentityResultMapper.ToResult(result: identityResult); } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - aggregateTracker.Track(roleResult.Value); return Result.Success(); @@ -157,7 +128,7 @@ public async Task DeleteAsync( private static Result DefaultRoleConflict() { - return Result.Failure(error: Error.Conflict(message: "Select another default role first.")); + return Result.Failure(error: Error.Conflict(message: "Unset the default role before deleting it.")); } private static Result RoleNotFound() @@ -165,36 +136,6 @@ private static Result RoleNotFound() return Result.Failure(error: Error.NotFound(message: "Role was not found.")); } - private Task LockRolesAsync(CancellationToken cancellationToken) - { - return dbContext.Database.ExecuteSqlRawAsync( - "LOCK TABLE identity.asp_net_roles IN SHARE ROW EXCLUSIVE MODE", cancellationToken); - } - - private async Task SetDefaultAsync( - ApplicationRole role, - bool isDefault, - CancellationToken cancellationToken) - { - if (!isDefault || role.IsDefault) - { - return; - } - - var previousDefaults = await dbContext.Set() - .Where(item => item.IsDefault && item.Id != role.Id) - .ToArrayAsync(cancellationToken); - foreach (var previous in previousDefaults) - { - previous.IsDefault = false; - previous.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; - } - await dbContext.SaveChangesAsync(cancellationToken); - - role.IsDefault = true; - await dbContext.SaveChangesAsync(cancellationToken); - } - private void SyncClaims( ApplicationRole applicationRole, Role role) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs index 8161c9d0..f6762310 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs @@ -4,5 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; public sealed record CreateRoleRequest( string Name, - IReadOnlyCollection Claims, - bool IsDefault = false); + bool IsDefault, + IReadOnlyCollection Claims); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs index 0d127289..5b87a301 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetDetails/GetRoleDetailsResponse.cs @@ -5,5 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.GetDetail public sealed record GetRoleDetailsResponse( Guid Id, string Name, - IReadOnlyCollection Claims, - bool IsDefault = false); + bool IsDefault, + IReadOnlyCollection Claims); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs index 17cb891c..ef6d05ce 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/GetList/RoleListItemResponse.cs @@ -3,4 +3,4 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.GetList; public sealed record RoleListItemResponse( Guid Id, string Name, - bool IsDefault = false); + bool IsDefault); diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs index 51b466ea..388f12e0 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Update/UpdateRoleRequest.cs @@ -4,5 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; public sealed record UpdateRoleRequest( string Name, - IReadOnlyCollection Claims, - bool IsDefault = false); + bool IsDefault, + IReadOnlyCollection Claims); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs index c9242317..6e81d46b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs @@ -77,11 +77,11 @@ public async Task AssignRoleClaims_Should_PersistSectionOnly_When_SectionIsKnown Claim[] claims = [new(ApplicationClaimTypes.Section, "identity")]; var role = await client.RecreateAsync( cancellationToken, - new CreateRoleRequest("Moderator", update ? [] : claims)); + new CreateRoleRequest("Moderator", false, update ? [] : claims)); if (update) { - await client.UpdateAsync(role.Id, new UpdateRoleRequest("Moderator", claims), cancellationToken); + await client.UpdateAsync(role.Id, new UpdateRoleRequest("Moderator", false, claims), cancellationToken); } var details = await client.GetDetailsAsync(role.Id, cancellationToken); @@ -120,7 +120,8 @@ await client.UpdateAsync( private static JsonObject CreatePayload(bool role, Claim claim) { return role - ? JsonSerializer.SerializeToNode(new CreateRoleRequest("Moderator", [claim]), JsonOptions)!.AsObject() + ? JsonSerializer.SerializeToNode(new CreateRoleRequest("Moderator", false, [claim]), JsonOptions)! + .AsObject() : JsonSerializer.SerializeToNode( UserApiTestClient.CreateDefaultRequest() with { Claims = [claim] }, JsonOptions)! .AsObject(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs index 7432adf2..a13ac1b1 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs @@ -16,6 +16,7 @@ public async Task PostRoles_Should_PersistRole_When_RequestIsValid() cancellationToken, new CreateRoleRequest( " Moderator ", + false, [new Claim("permission", "compendium.heroes.update")])); var role = await RoleDatabaseTestHelper.FindAsync( Fixture, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs index 9b3f2e3f..369168db 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Auth.Register; @@ -8,31 +10,30 @@ namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features public sealed class DefaultRoleEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) { - [Theory(DisplayName = "Role requests should switch the default when custom role is selected")] + [Theory(DisplayName = "Role requests should assign the default when previous flag is cleared")] [InlineData(false)] [InlineData(true)] - public async Task SaveRole_Should_SwitchDefault_When_CustomRoleIsSelected(bool create) + public async Task SaveRole_Should_AssignDefault_When_PreviousFlagIsCleared(bool create) { var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); var initialRoles = await client.GetListAsync(cancellationToken); var previous = initialRoles.Items.Single(role => role.IsDefault); - previous.Name.ShouldBe("Player"); + await client.UpdateAsync(previous.Id, new UpdateRoleRequest(previous.Name, false, []), cancellationToken); Guid id; if (create) { id = (await client.CreateAsync( cancellationToken, - new CreateRoleRequest("CustomDefault", [], IsDefault: true))).Id; + new CreateRoleRequest("CustomDefault", true, []))).Id; } else { id = initialRoles.Items.Single(role => role.Name == "Moderator").Id; - await client.UpdateAsync( - id, new UpdateRoleRequest("CustomDefault", [], IsDefault: true), cancellationToken); + await client.UpdateAsync(id, new UpdateRoleRequest("CustomDefault", true, []), cancellationToken); } - await client.UpdateAsync(id, new UpdateRoleRequest("RenamedDefault", [], IsDefault: true), cancellationToken); + await client.UpdateAsync(id, new UpdateRoleRequest("RenamedDefault", true, []), cancellationToken); var roles = await client.GetListAsync(cancellationToken); roles.Items.Single(role => role.IsDefault).Id.ShouldBe(id); @@ -53,28 +54,75 @@ await client.UpdateAsync( user.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(id); } - [Theory(DisplayName = "Role requests should return conflict when current default would be removed")] + [Theory(DisplayName = "Role requests should return conflict when another default exists")] [InlineData(false)] [InlineData(true)] - public async Task SaveRole_Should_ReturnConflict_When_CurrentDefaultWouldBeRemoved(bool delete) + public async Task SaveRole_Should_ReturnConflict_When_AnotherDefaultExists(bool create) { var cancellationToken = TestContext.Current.CancellationToken; var client = new RoleApiTestClient(Fixture); var roles = await client.GetListAsync(cancellationToken); - var current = roles.Items.Single(role => role.IsDefault); - using var request = new HttpRequestMessage( - delete ? HttpMethod.Delete : HttpMethod.Put, - $"/api/v1/roles/{current.Id}"); - if (!delete) + var id = roles.Items.Single(role => role.Name == "Moderator").Id; + var path = create ? "/api/v1/roles" : $"/api/v1/roles/{id}"; + using var request = new HttpRequestMessage(create ? HttpMethod.Post : HttpMethod.Put, path) { - request.Content = JsonContent.Create( - new UpdateRoleRequest("Player", [], IsDefault: false), - options: JsonOptions); - } + Content = JsonContent.Create(new CreateRoleRequest("CustomDefault", true, []), options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + var existing = await client.GetListAsync(cancellationToken); + existing.TotalCount.ShouldBe(3); + existing.Items.Single(role => role.IsDefault).Name.ShouldBe("Player"); + (await client.GetDetailsAsync(id, cancellationToken)).Name.ShouldBe("Moderator"); + } + + [Fact(DisplayName = "Role deletion should return conflict when role is default")] + public async Task DeleteRole_Should_ReturnConflict_When_RoleIsDefault() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var current = roles.Items.Single(role => role.IsDefault); + using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/roles/{current.Id}"); using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); response.StatusCode.ShouldBe(HttpStatusCode.Conflict); (await client.GetDetailsAsync(current.Id, cancellationToken)).IsDefault.ShouldBeTrue(); } + + [Theory(DisplayName = "Role requests should return bad request when flag is missing or null")] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task SaveRole_Should_ReturnBadRequest_When_FlagIsMissingOrNull(bool update, bool omit) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var id = roles.Items.Single(role => role.Name == "Moderator").Id; + var payload = JsonSerializer.SerializeToNode(new CreateRoleRequest("CustomDefault", false, []), JsonOptions)! + .AsObject(); + if (omit) + { + payload.Remove("isDefault"); + } + else + { + payload["isDefault"] = null; + } + var path = update ? $"/api/v1/roles/{id}" : "/api/v1/roles"; + using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await client.GetDetailsAsync(id, cancellationToken)).Name.ShouldBe("Moderator"); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs index 6f2518bb..7f2d19f3 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleApiTestClient.cs @@ -14,7 +14,7 @@ internal async Task RecreateAsync( CancellationToken cancellationToken, CreateRoleRequest? request = null) { - request ??= new CreateRoleRequest("Moderator", []); + request ??= new CreateRoleRequest("Moderator", false, []); var roles = await GetListAsync(cancellationToken); var existing = roles.Items.SingleOrDefault(role => string.Equals( role.Name, request.Name.Trim(), StringComparison.OrdinalIgnoreCase)); @@ -30,7 +30,7 @@ internal async Task CreateAsync( CancellationToken cancellationToken, CreateRoleRequest? request = null) { - request ??= new CreateRoleRequest("Moderator", []); + request ??= new CreateRoleRequest("Moderator", false, []); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, RolesPath) { Content = JsonContent.Create(request, options: TestJsonSerializerOptions.Web) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs index ef433199..3171c586 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs @@ -17,7 +17,7 @@ public async Task SaveRole_Should_ReturnBadRequest_When_NameIsEmpty(bool update) var path = update ? $"/api/v1/roles/{roleId}" : "/api/v1/roles"; using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) { - Content = JsonContent.Create(new CreateRoleRequest(" ", []), options: JsonOptions) + Content = JsonContent.Create(new CreateRoleRequest(" ", false, []), options: JsonOptions) }; using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index 5f748112..e4ed4be3 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -17,6 +17,7 @@ await client.UpdateAsync( createdRole.Id, new UpdateRoleRequest( "CustomManager", + false, [new Claim("permission", "compendium.heroes.update")]), cancellationToken); var role = await RoleDatabaseTestHelper.FindAsync( diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index 91a3abba..e16fc3cd 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -113,10 +113,10 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); } - [Theory(DisplayName = "Role persistence should switch the default when different role is selected")] + [Theory(DisplayName = "Role persistence should assign the default when previous flag is cleared")] [InlineData(false)] [InlineData(true)] - public async Task SaveAsync_Should_SwitchDefault_When_DifferentRoleIsSelected(bool create) + public async Task SaveAsync_Should_AssignDefault_When_PreviousFlagIsCleared(bool create) { var cancellationToken = TestContext.Current.CancellationToken; await Fixture.SeedRolesAsync(cancellationToken); @@ -131,6 +131,9 @@ public async Task SaveAsync_Should_SwitchDefault_When_DifferentRoleIsSelected(bo .SingleAsync(cancellationToken); var role = IntegrationTestData.CreateRole(targetId, "CustomDefault", [], isDefault: true); var repository = scope.ServiceProvider.GetRequiredService(); + (await repository.UpdateAsync( + IntegrationTestData.CreateRole(previous.Id, previous.Name!, [], isDefault: false), cancellationToken)) + .IsSuccess.ShouldBeTrue(); if (create) { @@ -181,10 +184,8 @@ public async Task SaveAsync_Should_RetainDefault_When_DuplicateNameRejectsChange defaultName.ShouldBe("Player"); } - [Theory(DisplayName = "Role persistence should reject removal when current default would be removed")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveAsync_Should_ReturnConflict_When_CurrentDefaultWouldBeRemoved(bool delete) + [Fact(DisplayName = "DeleteAsync should return conflict when role is default")] + public async Task DeleteAsync_Should_ReturnConflict_When_RoleIsDefault() { var cancellationToken = TestContext.Current.CancellationToken; await Fixture.SeedRolesAsync(cancellationToken); @@ -193,18 +194,69 @@ public async Task SaveAsync_Should_ReturnConflict_When_CurrentDefaultWouldBeRemo var role = await dbContext.Set().SingleAsync(item => item.IsDefault, cancellationToken); var repository = scope.ServiceProvider.GetRequiredService(); - var result = delete - ? await repository.DeleteAsync(role.Id, cancellationToken) - : await repository.UpdateAsync( - IntegrationTestData.CreateRole(role.Id, "Renamed", [], isDefault: false), cancellationToken); + var result = await repository.DeleteAsync(role.Id, cancellationToken); result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); - var existing = await dbContext.Set().AsNoTracking() + var existing = await dbContext.Set() + .AsNoTracking() .SingleAsync(item => item.Id == role.Id, cancellationToken); existing.IsDefault.ShouldBeTrue(); existing.Name.ShouldBe("Player"); } + [Fact(DisplayName = "GetDefaultAsync should return null when default is missing")] + public async Task GetDefaultAsync_Should_ReturnNull_When_DefaultIsMissing() + { + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var result = await repository.GetDefaultAsync(TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeNull(); + } + + [Fact(DisplayName = "GetDefaultAsync should return a role with claims when default exists")] + public async Task GetDefaultAsync_Should_ReturnRoleWithClaims_When_DefaultExists() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var role = IntegrationTestData.CreateRole( + Guid.CreateVersion7(), "CustomDefault", [("permission", "compendium.heroes.read")], isDefault: true); + (await repository.AddAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); + + var result = await repository.GetDefaultAsync(cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldNotBeNull(); + result.Value.Id.ShouldBe(role.Id); + result.Value.IsDefault.ShouldBeTrue(); + result.Value.Claims.ShouldHaveSingleItem().Value.Value.ShouldBe("compendium.heroes.read"); + } + + [Fact(DisplayName = "GetDefaultAsync should return validation errors when stored default is invalid")] + public async Task GetDefaultAsync_Should_ReturnValidationErrors_When_StoredDefaultIsInvalid() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Set().Add(new ApplicationRole + { + Id = Guid.Empty, + Name = "InvalidDefault", + NormalizedName = "INVALIDDEFAULT", + IsDefault = true + }); + await dbContext.SaveChangesAsync(cancellationToken); + var repository = scope.ServiceProvider.GetRequiredService(); + + var result = await repository.GetDefaultAsync(cancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); + } + private async Task AddRoleAsync( string name, IReadOnlyCollection claims, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs index 03b9d332..2199d629 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs @@ -16,9 +16,9 @@ internal static Role CreateRole( return Role.Create( id: RoleId.Create(value: id).Value, name: RoleName.Create(value: name).Value, + defaultFlag: RoleDefaultFlag.Create(value: isDefault).Value, claims: claims.Select(claim => RoleClaim.Create( type: ClaimType.Create(value: claim.Type).Value, - value: ClaimValue.Create(value: claim.Value).Value)), - defaultStatus: RoleDefaultStatus.From(isDefault: isDefault)); + value: ClaimValue.Create(value: claim.Value).Value))); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs index e65ab29b..8efa27ba 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleCommandValidatorTests.cs @@ -12,7 +12,7 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() var validator = new CreateRoleCommandValidator(); var result = validator.Validate( - new CreateRoleCommand("Developer", [new Claim("permission", "compendium.heroes.update")])); + new CreateRoleCommand("Developer", false, [new Claim("permission", "compendium.heroes.update")])); result.IsValid.ShouldBeTrue(); } @@ -23,7 +23,7 @@ public void Validate_Should_ReturnErrors_When_CommandIsInvalid() var validator = new CreateRoleCommandValidator(); var result = validator.Validate( - new CreateRoleCommand("", [new Claim("", "")])); + new CreateRoleCommand("", false, [new Claim("", "")])); result.Errors.ShouldContain(error => error.PropertyName == nameof(CreateRoleCommand.Name)); result.Errors diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs index 6ccc6850..14fae688 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs @@ -3,6 +3,7 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Application.Roles.Create; @@ -17,6 +18,7 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds(bool i var roleId = Guid.CreateVersion7(); IReadOnlyCollection claims = [new Claim("permission", "heroes.manage")]; var repository = Substitute.For(); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(value: null)); repository.AddAsync( Arg.Is(role => role.Name.Value == "Developer" && role.IsDefault == isDefault @@ -29,7 +31,7 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds(bool i var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new CreateRoleCommand("Developer", claims, isDefault), + new CreateRoleCommand("Developer", isDefault, claims), cancellationToken); result.IsSuccess.ShouldBeTrue(); @@ -52,15 +54,47 @@ public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_W string claimValue) { var repository = Substitute.For(); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(value: null)); var handler = new CreateRoleHandler(repository); var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new CreateRoleCommand(name, [new Claim(claimType, claimValue)]), + new CreateRoleCommand(name, false, [new Claim(claimType, claimValue)]), cancellationToken); result.IsFailure.ShouldBeTrue(); result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); await repository.DidNotReceiveWithAnyArgs().AddAsync(null!, cancellationToken); } + + [Fact(DisplayName = "Create role handler should return conflict when another default exists")] + public async Task HandleAsync_Should_ReturnConflict_When_AnotherDefaultExists() + { + var repository = Substitute.For(); + var current = Role.Create( + RoleId.New(), RoleName.Create("Player").Value, RoleDefaultFlag.Create(true).Value, []); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(current)); + var handler = new CreateRoleHandler(repository); + var cancellationToken = TestContext.Current.CancellationToken; + + var result = await handler.HandleAsync(new CreateRoleCommand("CustomDefault", true, []), cancellationToken); + + result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); + await repository.DidNotReceiveWithAnyArgs().AddAsync(null!, cancellationToken); + } + + [Fact(DisplayName = "Create role handler should propagate errors when default lookup fails")] + public async Task HandleAsync_Should_PropagateErrors_When_DefaultLookupFails() + { + var repository = Substitute.For(); + var error = Error.Validation(message: "Invalid default role."); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Failure(error)); + var handler = new CreateRoleHandler(repository); + var cancellationToken = TestContext.Current.CancellationToken; + + var result = await handler.HandleAsync(new CreateRoleCommand("CustomDefault", true, []), cancellationToken); + + result.Errors.ShouldBe([error]); + await repository.DidNotReceiveWithAnyArgs().AddAsync(null!, cancellationToken); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs index 04c923f5..20fc2957 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetDetails/GetRoleDetailsHandlerTests.cs @@ -9,7 +9,7 @@ public sealed class GetRoleDetailsHandlerTests public async Task HandleAsync_Should_ReturnRole_When_RoleExists() { var roleId = Guid.CreateVersion7(); - var readModel = new RoleDetailsReadModel(roleId, "Developer", []); + var readModel = new RoleDetailsReadModel(roleId, "Developer", false, []); var rolesReadRepository = Substitute.For(); rolesReadRepository.GetDetailsByIdAsync(roleId, Arg.Any()) .Returns(readModel); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs index fddd2539..a3be0384 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/GetList/GetRolesHandlerTests.cs @@ -11,7 +11,7 @@ public async Task HandleAsync_Should_ReturnPage_When_RepositorySucceeds() var pagination = new PaginationParameters(1, 20); var sorting = SortingParameters.None; var page = PaginationResult.Create( - [new RoleListItemReadModel(Guid.CreateVersion7(), "Developer")], + [new RoleListItemReadModel(Guid.CreateVersion7(), "Developer", false)], 1, 20, 1); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs index bd3fb7c1..a7fb8611 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleCommandValidatorTests.cs @@ -15,6 +15,7 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() new UpdateRoleCommand( Guid.CreateVersion7(), "Developer", + false, [new Claim("permission", "compendium.heroes.update")])); result.IsValid.ShouldBeTrue(); @@ -26,7 +27,7 @@ public void Validate_Should_ReturnErrors_When_CommandIsInvalid() var validator = new UpdateRoleCommandValidator(); var result = validator.Validate( - new UpdateRoleCommand(Guid.Empty, "", [new Claim("", "")])); + new UpdateRoleCommand(Guid.Empty, "", false, [new Claim("", "")])); result.Errors.ShouldContain(error => error.PropertyName == nameof(UpdateRoleCommand.Id)); result.Errors.ShouldContain(error => error.PropertyName == nameof(UpdateRoleCommand.Name)); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs index 420e2fc2..e6a33c0a 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs @@ -3,6 +3,7 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Application.Roles.Update; @@ -17,6 +18,7 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds(bool var roleId = Guid.CreateVersion7(); IReadOnlyCollection claims = [new Claim("permission", "heroes.manage")]; var repository = Substitute.For(); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(value: null)); repository.UpdateAsync( Arg.Is( role => role.Id.Value == roleId @@ -30,7 +32,7 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds(bool var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new UpdateRoleCommand(roleId, "Developer", claims, isDefault), + new UpdateRoleCommand(roleId, "Developer", isDefault, claims), cancellationToken); result.IsSuccess.ShouldBeTrue(); @@ -54,15 +56,68 @@ public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_W string claimValue) { var repository = Substitute.For(); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(value: null)); var handler = new UpdateRoleHandler(repository); var cancellationToken = TestContext.Current.CancellationToken; var result = await handler.HandleAsync( - new UpdateRoleCommand(Guid.CreateVersion7(), name, [new Claim(claimType, claimValue)]), + new UpdateRoleCommand(Guid.CreateVersion7(), name, false, [new Claim(claimType, claimValue)]), cancellationToken); result.IsFailure.ShouldBeTrue(); result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); await repository.DidNotReceiveWithAnyArgs().UpdateAsync(null!, cancellationToken); } + + [Fact(DisplayName = "Update role handler should return conflict when another default exists")] + public async Task HandleAsync_Should_ReturnConflict_When_AnotherDefaultExists() + { + var repository = Substitute.For(); + var current = Role.Create( + RoleId.New(), RoleName.Create("Player").Value, RoleDefaultFlag.Create(true).Value, []); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(current)); + var handler = new UpdateRoleHandler(repository); + var cancellationToken = TestContext.Current.CancellationToken; + + var result = await handler.HandleAsync( + new UpdateRoleCommand(Guid.CreateVersion7(), "CustomDefault", true, []), cancellationToken); + + result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); + await repository.DidNotReceiveWithAnyArgs().UpdateAsync(null!, cancellationToken); + } + + [Fact(DisplayName = "Update role handler should propagate errors when default lookup fails")] + public async Task HandleAsync_Should_PropagateErrors_When_DefaultLookupFails() + { + var repository = Substitute.For(); + var error = Error.Validation(message: "Invalid default role."); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Failure(error)); + var handler = new UpdateRoleHandler(repository); + var cancellationToken = TestContext.Current.CancellationToken; + + var result = await handler.HandleAsync( + new UpdateRoleCommand(Guid.CreateVersion7(), "CustomDefault", true, []), cancellationToken); + + result.Errors.ShouldBe([error]); + await repository.DidNotReceiveWithAnyArgs().UpdateAsync(null!, cancellationToken); + } + + [Fact(DisplayName = "Update role handler should preserve the default when current default is edited")] + public async Task HandleAsync_Should_PreserveDefault_When_CurrentDefaultIsEdited() + { + var repository = Substitute.For(); + var current = Role.Create( + RoleId.New(), RoleName.Create("Player").Value, RoleDefaultFlag.Create(true).Value, []); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(current)); + repository.UpdateAsync(Arg.Any(), Arg.Any()).Returns(Result.Success()); + var handler = new UpdateRoleHandler(repository); + var cancellationToken = TestContext.Current.CancellationToken; + + var result = await handler.HandleAsync( + new UpdateRoleCommand(current.Id.Value, "RenamedDefault", true, []), cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + await repository.Received(1).UpdateAsync( + Arg.Is(role => role.Id == current.Id && role.IsDefault), cancellationToken); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs index b71ab757..e505e313 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs @@ -17,8 +17,8 @@ public void Create_Should_RestoreState_When_PersistedValuesAreValid(bool isDefau var result = Role.Create( RoleId.Create(id).Value, RoleName.Create(" Developer ").Value, - [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)], - RoleDefaultStatus.From(isDefault)); + RoleDefaultFlag.Create(isDefault).Value, + [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)]); result.Id.Value.ShouldBe(id); result.Name.Value.ShouldBe("Developer"); @@ -33,7 +33,8 @@ public void Create_Should_IsolateCollectionStorage_When_InputCollectionsChange() { var claim = RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value); List claims = [claim]; - var role = Role.Create(RoleId.New(), RoleName.Create("Developer").Value, claims); + var role = Role.Create( + RoleId.New(), RoleName.Create("Developer").Value, RoleDefaultFlag.Create(false).Value, claims); claims.Clear(); @@ -93,6 +94,6 @@ public void Claims_Should_RejectExternalMutations_When_AClaimIsGranted() private static Role CreateRole() { - return Role.Create(RoleId.New(), RoleName.Create("Developer").Value, []); + return Role.Create(RoleId.New(), RoleName.Create("Developer").Value, RoleDefaultFlag.Create(false).Value, []); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultFlagTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultFlagTests.cs new file mode 100644 index 00000000..27d00c41 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultFlagTests.cs @@ -0,0 +1,29 @@ +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; + +namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.ValueObjects; + +public sealed class RoleDefaultFlagTests +{ + [Theory(DisplayName = "Role default flag should preserve the value when boolean is provided")] + [InlineData(false)] + [InlineData(true)] + public void Create_Should_PreserveValue_When_BooleanIsProvided(bool value) + { + var result = RoleDefaultFlag.Create(value); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Value.ShouldBe(value); + } + + [Theory(DisplayName = "Role default flag should format its value when converted to string")] + [InlineData(false, "RoleDefaultFlag { Value = False }")] + [InlineData(true, "RoleDefaultFlag { Value = True }")] + public void ToString_Should_FormatValue_When_ConvertedToString(bool value, string expected) + { + var flag = RoleDefaultFlag.Create(value).Value; + + var result = flag.ToString(); + + result.ShouldBe(expected); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs deleted file mode 100644 index 4d808f30..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleDefaultStatusTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.ValueObjects; - -namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.ValueObjects; - -public sealed class RoleDefaultStatusTests -{ - [Theory(DisplayName = "Role default status should preserve the flag when boolean is provided")] - [InlineData(false)] - [InlineData(true)] - public void From_Should_PreserveFlag_When_BooleanIsProvided(bool isDefault) - { - var status = RoleDefaultStatus.From(isDefault); - - status.IsDefault.ShouldBe(isDefault); - } - - [Theory(DisplayName = "Role default status should format its flag when converted to string")] - [InlineData(false, "RoleDefaultStatus { IsDefault = False }")] - [InlineData(true, "RoleDefaultStatus { IsDefault = True }")] - public void ToString_Should_FormatFlag_When_ConvertedToString(bool isDefault, string expected) - { - var status = RoleDefaultStatus.From(isDefault); - - var result = status.ToString(); - - result.ShouldBe(expected); - } -} From ed2d0568291d0564a8029ad8880143ba65b839aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 20:50:06 +0400 Subject: [PATCH 26/34] style: format default flag reset assertion --- .../Features/Roles/Write/RolesRepositoryTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index e16fc3cd..c1d880bc 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -131,9 +131,9 @@ public async Task SaveAsync_Should_AssignDefault_When_PreviousFlagIsCleared(bool .SingleAsync(cancellationToken); var role = IntegrationTestData.CreateRole(targetId, "CustomDefault", [], isDefault: true); var repository = scope.ServiceProvider.GetRequiredService(); - (await repository.UpdateAsync( - IntegrationTestData.CreateRole(previous.Id, previous.Name!, [], isDefault: false), cancellationToken)) - .IsSuccess.ShouldBeTrue(); + var cleared = await repository.UpdateAsync( + IntegrationTestData.CreateRole(previous.Id, previous.Name!, [], isDefault: false), cancellationToken); + cleared.IsSuccess.ShouldBeTrue(); if (create) { From 32a17fcdf45e0f972984654106060ccdcb9435a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 21:04:20 +0400 Subject: [PATCH 27/34] fix: restore original role name normalization --- README.md | 2 +- .../Roles/ValueObjects/RoleName.cs | 13 ++++++++----- .../Roles/Create/CreateRoleEndpointTests.cs | 2 +- .../Features/Roles/DefaultRoleEndpointTests.cs | 2 +- .../Roles/GetDetails/GetRoleDetailsEndpointTests.cs | 2 +- .../Roles/Update/UpdateRoleEndpointTests.cs | 2 +- .../Features/Roles/Read/RolesReadRepositoryTests.cs | 4 ++-- .../Write/Mappers/ApplicationRoleMapperTests.cs | 2 +- .../Features/Roles/Write/RolesRepositoryTests.cs | 4 ++-- .../Roles/Create/CreateRoleHandlerTests.cs | 4 ++-- .../Roles/Update/UpdateRoleHandlerTests.cs | 4 ++-- .../Domain/Roles/RoleTests.cs | 2 +- .../Domain/Roles/ValueObjects/RoleNameTests.cs | 10 +++++----- 13 files changed, 28 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e0313ae0..f3808b14 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ permissions and both sections; Moderator receives Compendium permissions and its Player has no administrative claims and is the initial default role. Startup does not reseed roles. Existing users are not reassigned automatically. -Role names are arbitrary nonempty values up to 128 characters, trimmed without changing case. +Role names are arbitrary nonempty values up to 128 characters, trimmed and normalized to lowercase. Role create/update requests require `isDefault` before `claims`; list/details responses include the flag. Selecting a default while another role has the flag returns HTTP 409. To change it, clear the previous flag and then select the new role. Editing the current default is allowed; deleting it returns HTTP 409. diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs index f18f0803..08d74cce 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/ValueObjects/RoleName.cs @@ -16,16 +16,19 @@ public static Result Create(string value) if (string.IsNullOrWhiteSpace(value)) { return Result.Failure( - error: Error.Validation(message: "Role name is required.") + error: Error.Validation(message: "Role name cannot be empty.") .WithField(nameof(RoleName))); } - var normalizedValue = value.Trim(); + var normalizedValue = value.Trim().ToLowerInvariant(); - return normalizedValue.Length <= MaxLength - ? Result.Success(value: new RoleName(value: normalizedValue)) - : Result.Failure( + if (normalizedValue.Length > MaxLength) + { + return Result.Failure( error: Error.Validation(message: $"Role name cannot be longer than {MaxLength} characters.") .WithField(nameof(RoleName))); + } + + return Result.Success(value: new RoleName(value: normalizedValue)); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs index a13ac1b1..e10b8ad7 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs @@ -24,7 +24,7 @@ public async Task PostRoles_Should_PersistRole_When_RequestIsValid() cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("Moderator"); + role.Name.ShouldBe("moderator"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs index 369168db..9382f91c 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs @@ -38,7 +38,7 @@ public async Task SaveRole_Should_AssignDefault_When_PreviousFlagIsCleared(bool var roles = await client.GetListAsync(cancellationToken); roles.Items.Single(role => role.IsDefault).Id.ShouldBe(id); var details = await client.GetDetailsAsync(id, cancellationToken); - details.Name.ShouldBe("RenamedDefault"); + details.Name.ShouldBe("renameddefault"); details.IsDefault.ShouldBeTrue(); (await client.GetDetailsAsync(previous.Id, cancellationToken)).IsDefault.ShouldBeFalse(); using var response = await Client.PostAsJsonAsync( diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs index 5806e00b..4787b3d0 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/GetDetails/GetRoleDetailsEndpointTests.cs @@ -13,6 +13,6 @@ public async Task GetRoleDetails_Should_ReturnRole_When_RoleExists() var response = await client.GetDetailsAsync(createdRole.Id, cancellationToken); response.Id.ShouldBe(createdRole.Id); - response.Name.ShouldBe("Moderator"); + response.Name.ShouldBe("moderator"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index e4ed4be3..86490e8c 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -26,7 +26,7 @@ [new Claim("permission", "compendium.heroes.update")]), cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("CustomManager"); + role.Name.ShouldBe("custommanager"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs index a803a452..b0981a55 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs @@ -20,7 +20,7 @@ public async Task GetDetailsByIdAsync_Should_ReturnDetails_When_RoleExists() var details = await repository.GetDetailsByIdAsync(roleId, cancellationToken); details.ShouldNotBeNull(); - details.Name.ShouldBe("Developer"); + details.Name.ShouldBe("developer"); } [Fact(DisplayName = "GetPageAsync should return roles sorted by name when roles exist")] @@ -38,7 +38,7 @@ public async Task GetPageAsync_Should_ReturnSortedPage_When_RolesExist() cancellationToken); page.TotalCount.ShouldBe(2); - page.Items.Select(item => item.Name).ShouldBe(["Developer", "Player"]); + page.Items.Select(item => item.Name).ShouldBe(["developer", "player"]); } [Fact(DisplayName = "ExistsByIdAsync should return true for an existing role when role exists")] diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs index c4bf950c..279d593a 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/Mappers/ApplicationRoleMapperTests.cs @@ -37,7 +37,7 @@ public void ToDomain_Should_NormalizeAndDeduplicateClaims_When_ValuesAreValid(bo var result = ApplicationRoleMapper.ToDomain(dbModel); result.IsSuccess.ShouldBeTrue(); - result.Value.Name.Value.ShouldBe("Developer"); + result.Value.Name.Value.ShouldBe("developer"); result.Value.IsDefault.ShouldBe(isDefault); var mapped = ApplicationRoleMapper.ToDbModel(result.Value, DateTime.UtcNow, DateTime.UtcNow); mapped.IsDefault.ShouldBe(isDefault); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index c1d880bc..7fffe355 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -29,7 +29,7 @@ [new Claim("permission", "heroes.read")], .AsNoTracking() .SingleAsync(item => item.Id == roleId, cancellationToken); - role.Name.ShouldBe("Developer"); + role.Name.ShouldBe("developer"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("heroes.read"); } @@ -64,7 +64,7 @@ [new Claim("permission", "heroes.read")], .AsNoTracking() .SingleAsync(item => item.Id == roleId, cancellationToken); - role.Name.ShouldBe("Moderator"); + role.Name.ShouldBe("moderator"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("heroes.manage"); } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs index 14fae688..ca7cc68b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Create/CreateRoleHandlerTests.cs @@ -20,7 +20,7 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds(bool i var repository = Substitute.For(); repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(value: null)); repository.AddAsync( - Arg.Is(role => role.Name.Value == "Developer" + Arg.Is(role => role.Name.Value == "developer" && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), @@ -37,7 +37,7 @@ public async Task HandleAsync_Should_ReturnRoleId_When_RepositorySucceeds(bool i result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(roleId); await repository.Received(1).AddAsync( - Arg.Is(role => role.Name.Value == "Developer" + Arg.Is(role => role.Name.Value == "developer" && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs index e6a33c0a..310b6ea6 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Roles/Update/UpdateRoleHandlerTests.cs @@ -22,7 +22,7 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds(bool repository.UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Value == "Developer" + && role.Name.Value == "developer" && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), @@ -39,7 +39,7 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds(bool await repository.Received(1).UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Value == "Developer" + && role.Name.Value == "developer" && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs index e505e313..060734c5 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs @@ -21,7 +21,7 @@ public void Create_Should_RestoreState_When_PersistedValuesAreValid(bool isDefau [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)]); result.Id.Value.ShouldBe(id); - result.Name.Value.ShouldBe("Developer"); + result.Name.Value.ShouldBe("developer"); result.IsDefault.ShouldBe(isDefault); var claim = result.Claims.ShouldHaveSingleItem(); claim.Type.Value.ShouldBe("permission"); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs index 103a0e3f..486c1277 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs @@ -4,13 +4,13 @@ namespace PANiXiDA.TacticalHeroes.Identity.UnitTests.Domain.Roles.ValueObjects; public sealed class RoleNameTests { - [Fact(DisplayName = "Role name should trim a valid value when role name is valid")] - public void Create_Should_TrimValue_When_RoleNameIsValid() + [Fact(DisplayName = "Role name should normalize a valid value when role name is valid")] + public void Create_Should_NormalizeValue_When_RoleNameIsValid() { var result = RoleName.Create(" CustomManager "); result.IsSuccess.ShouldBeTrue(); - result.Value.Value.ShouldBe("CustomManager"); + result.Value.Value.ShouldBe("custommanager"); } [Theory(DisplayName = "Role name should reject an empty value when role name is empty")] @@ -21,7 +21,7 @@ public void Create_Should_ReturnValidationFailure_When_RoleNameIsEmpty(string? v { var result = RoleName.Create(value!); - result.ShouldHaveSingleError(ErrorType.Validation, "Role name is required.") + result.ShouldHaveSingleError(ErrorType.Validation, "Role name cannot be empty.") .ShouldHaveField(nameof(RoleName)); } @@ -43,6 +43,6 @@ public void ToString_Should_FormatValue_When_ConvertedToString() var result = roleName.ToString(); - result.ShouldBe("RoleName { Value = CustomManager }"); + result.ShouldBe("RoleName { Value = custommanager }"); } } From f0f0e9f79be3f6f6c2bfd174d1c5749360b09bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 21:31:31 +0400 Subject: [PATCH 28/34] fix: invalidate sessions after role authorization changes --- README.md | 4 +- .../Features/Roles/Write/RolesRepository.cs | 48 ++++++++ .../Features/Users/Write/UsersRepository.cs | 10 +- .../RoleSessionInvalidationEndpointTests.cs | 105 ++++++++++++++++++ .../Write/RoleSessionInvalidationTests.cs | 100 +++++++++++++++++ .../Users/Write/UserRoleAssignmentTests.cs | 83 ++++++++++++++ 6 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs create mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs diff --git a/README.md b/README.md index f3808b14..e482cb43 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ get-details returns the selected IDs. Updates replace the complete role selectio `[]` means no roles. Omitted/null collections are rejected. Empty GUIDs, duplicates, and unknown role IDs are rejected before persistence. Use the existing roles list endpoint to populate selection options. Role changes rotate the security -stamp and revoke the user's tokens. Apply the Identity default-role and seed-data migrations; +stamp and revoke the user's tokens. Changes to an assigned role's name or claims, +and role deletion, also invalidate sessions for its members. Failed user updates preserve +existing assignments and sessions. Role writes and session invalidation share the command transaction. Apply the Identity default-role and seed-data migrations; no configuration changes are required. Rolling back seed data preserves roles and assignments. ## Request Validation diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs index 47bad8d7..d8a7d363 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Roles/Write/RolesRepository.cs @@ -7,12 +7,15 @@ using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.Mappers; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write; public sealed class RolesRepository( IdentityWriteDbContext dbContext, RoleManager roleManager, + UserManager userManager, + IOpenIddictTokenManager tokenManager, IAggregateTracker aggregateTracker, TimeProvider timeProvider) : IRolesRepository @@ -69,6 +72,9 @@ public async Task UpdateAsync( return RoleNotFound(); } + var currentClaims = applicationRole.Claims.Select(claim => (claim.ClaimType!, claim.ClaimValue!)).ToHashSet(); + var authorizationChanged = !string.Equals(applicationRole.Name, role.Name.Value, StringComparison.Ordinal) + || !currentClaims.SetEquals(role.Claims.Select(claim => (claim.Type.Value, claim.Value.Value))); ApplicationRoleMapper.MapToDbModel( role: role, dbModel: applicationRole, @@ -84,6 +90,16 @@ public async Task UpdateAsync( return IdentityResultMapper.ToResult(result: identityResult); } + if (authorizationChanged) + { + var users = await GetAssignedUsersAsync(role.Id.Value, cancellationToken); + var revocationResult = await RevokeSessionsAsync(users, cancellationToken); + if (revocationResult.IsFailure) + { + return revocationResult; + } + } + aggregateTracker.Track(role); return Result.Success(); @@ -114,6 +130,7 @@ public async Task DeleteAsync( return Result.Failure(errors: roleResult.Errors); } + var users = await GetAssignedUsersAsync(id, cancellationToken); var identityResult = await roleManager.DeleteAsync(applicationRole); if (!identityResult.Succeeded) @@ -121,6 +138,12 @@ public async Task DeleteAsync( return IdentityResultMapper.ToResult(result: identityResult); } + var revocationResult = await RevokeSessionsAsync(users, cancellationToken); + if (revocationResult.IsFailure) + { + return revocationResult; + } + aggregateTracker.Track(roleResult.Value); return Result.Success(); @@ -136,6 +159,31 @@ private static Result RoleNotFound() return Result.Failure(error: Error.NotFound(message: "Role was not found.")); } + private Task GetAssignedUsersAsync(Guid roleId, CancellationToken cancellationToken) + { + return dbContext.Set() + .Where(user => user.Roles.Any(role => role.RoleId == roleId)) + .ToArrayAsync(cancellationToken); + } + + private async Task RevokeSessionsAsync( + IReadOnlyCollection users, + CancellationToken cancellationToken) + { + foreach (var user in users) + { + var identityResult = await userManager.UpdateSecurityStampAsync(user); + if (!identityResult.Succeeded) + { + return IdentityResultMapper.ToResult(result: identityResult); + } + + await tokenManager.RevokeBySubjectAsync(user.Id.ToString(), cancellationToken); + } + + return Result.Success(); + } + private void SyncClaims( ApplicationRole applicationRole, Role role) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs index 0f00a251..17a76a76 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Write/UsersRepository.cs @@ -78,11 +78,6 @@ public async Task UpdateAsync( applicationUser: applicationUser, user: user); - if (user.Status.IsBlocked || rolesChanged) - { - await RevokeAllTokensAsync(user.Id.Value, cancellationToken); - } - var identityResult = rolesChanged ? await userManager.UpdateSecurityStampAsync(applicationUser) : await userManager.UpdateAsync(applicationUser); @@ -92,6 +87,11 @@ public async Task UpdateAsync( return IdentityResultMapper.ToResult(result: identityResult); } + if (user.Status.IsBlocked || rolesChanged) + { + await RevokeAllTokensAsync(user.Id.Value, cancellationToken); + } + aggregateTracker.Track(user); return Result.Success(); diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs new file mode 100644 index 00000000..ec061fe4 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs @@ -0,0 +1,105 @@ +using System.Net.Http.Headers; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; + +namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; + +public sealed class RoleSessionInvalidationEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) +{ + [Theory(DisplayName = "Role requests should revoke access when authorization changes")] + [InlineData("claims")] + [InlineData("name")] + [InlineData("delete")] + public async Task SaveRole_Should_RevokeAccess_When_AuthorizationChanges(string change) + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = new RoleApiTestClient(Fixture); + var role = await roles.CreateAsync( + cancellationToken, + new CreateRoleRequest( + "scoped", false, [new Claim("permission", "compendium.heroes.read")])); + var users = new UserApiTestClient(Fixture); + var user = await users.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); + using var userClient = OAuthAuthorizationRequestTestHelper.CreateOAuthClient(Fixture); + var tokens = await OAuthAuthorizationRequestTestHelper.IssueUserTokensAsync( + userClient, "hero@example.com", "StrongPassword1!", cancellationToken); + userClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var initial = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); + initial.StatusCode.ShouldBe(HttpStatusCode.OK); + + var permission = change == "claims" ? "compendium.heroes.update" : "compendium.heroes.read"; + if (change == "delete") + { + await roles.DeleteAsync(role.Id, cancellationToken); + } + else + { + await roles.UpdateAsync( + role.Id, + new UpdateRoleRequest( + change == "name" ? "renamed" : "scoped", + false, + [new Claim("permission", permission)]), + cancellationToken); + } + + using var response = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Theory(DisplayName = "Role requests should roll back changes when session invalidation fails")] + [InlineData(false)] + [InlineData(true)] + public async Task SaveRole_Should_RollBackChanges_When_SessionInvalidationFails(bool delete) + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = new RoleApiTestClient(Fixture); + var role = await roles.CreateAsync( + cancellationToken, + new CreateRoleRequest( + "scoped", false, [new Claim("permission", "compendium.heroes.read")])); + var users = new UserApiTestClient(Fixture); + var user = await users.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); + await using (var scope = Fixture.Services.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var stored = await dbContext.Set() + .SingleAsync(item => item.Id == user.Id, cancellationToken); + stored.UserName = string.Empty; + await dbContext.SaveChangesAsync(cancellationToken); + } + var originalUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); + originalUser.ShouldNotBeNull(); + using var request = new HttpRequestMessage( + delete ? HttpMethod.Delete : HttpMethod.Put, $"/api/v1/roles/{role.Id}"); + if (!delete) + { + request.Content = JsonContent.Create( + new UpdateRoleRequest( + "changed", false, [new Claim("permission", "compendium.heroes.update")]), + options: JsonOptions); + } + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var persistedRole = await roles.GetDetailsAsync(role.Id, cancellationToken); + persistedRole.Name.ShouldBe("scoped"); + persistedRole.Claims.ShouldHaveSingleItem().Value.ShouldBe("compendium.heroes.read"); + var persistedUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); + persistedUser.ShouldNotBeNull(); + persistedUser.SecurityStamp.ShouldBe(originalUser.SecurityStamp); + persistedUser.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(role.Id); + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs new file mode 100644 index 00000000..6b223304 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using OpenIddict.Abstractions; +using OpenIddict.EntityFrameworkCore.Models; + +using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; +using PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Roles; + +namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persistence.Features.Roles.Write; + +public sealed class RoleSessionInvalidationTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) +{ + [Theory(DisplayName = "Role writes should invalidate affected sessions when role changes vary")] + [InlineData("claims")] + [InlineData("name")] + [InlineData("delete")] + [InlineData("unchanged")] + [InlineData("default")] + public async Task SaveAsync_Should_InvalidateAffectedSessions_When_RoleChangesVary(string change) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var roleId = Guid.CreateVersion7(); + var role = IntegrationTestData.CreateRole(roleId, "original", [("permission", "compendium.heroes.read")]); + (await repository.AddAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); + var stamps = new Dictionary(); + var affected = new HashSet(); + for (var index = 0; index < 3; index++) + { + var user = new ApplicationUser + { + Id = Guid.CreateVersion7(), + Email = $"user{index}@example.com", + UserName = $"user{index}", + EmailConfirmed = true, + Roles = index < 2 ? [new ApplicationUserRole { RoleId = roleId }] : [] + }; + (await userManager.CreateAsync(user, "StrongPassword1!")).Succeeded.ShouldBeTrue(); + stamps.Add(user.Id, user.SecurityStamp); + if (index < 2) + { + affected.Add(user.Id); + } + foreach (var type in new[] + { + OpenIddictConstants.TokenTypeIdentifiers.AccessToken, + OpenIddictConstants.TokenTypeIdentifiers.RefreshToken + }) + { + await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = user.Id.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = type + }, + cancellationToken); + } + } + var target = IntegrationTestData.CreateRole( + id: roleId, + name: change == "name" ? "renamed" : "original", + claims: [("permission", change == "claims" ? "compendium.heroes.update" : "compendium.heroes.read")], + isDefault: change == "default"); + + var result = change == "delete" + ? await repository.DeleteAsync(roleId, cancellationToken) + : await repository.UpdateAsync(target, cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var users = await dbContext.Set() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + var tokens = await dbContext.Set>() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + tokens.Length.ShouldBe(6); + var authorizationChanged = change is "claims" or "name" or "delete"; + foreach (var user in users) + { + var revoked = authorizationChanged && affected.Contains(user.Id); + (user.SecurityStamp != stamps[user.Id]).ShouldBe(revoked); + tokens.Where(token => token.Subject == user.Id.ToString()).ShouldAllBe(token => token.Status == (revoked + ? OpenIddictConstants.Statuses.Revoked + : OpenIddictConstants.Statuses.Valid)); + } + if (change == "delete") + { + (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); + } + } +} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs index cd74c7a4..4188f004 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -1,7 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenIddict.Abstractions; +using OpenIddict.EntityFrameworkCore; using OpenIddict.EntityFrameworkCore.Models; using PANiXiDA.TacticalHeroes.Identity.Domain.Users; @@ -104,6 +106,87 @@ public async Task SaveAsync_Should_RejectRolesBeforeWrites_When_RoleDoesNotExist (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); } + [Theory(DisplayName = "User updates should preserve state and tokens when identity validation fails")] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task UpdateAsync_Should_PreserveStateAndTokens_When_IdentityValidationFails( + bool duplicateEmail, + bool disableBulk) + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService(); + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>() + .CurrentValue; + var originalDisableBulk = options.DisableBulkOperations; + options.DisableBulkOperations = disableBulk; + try + { + var roles = await dbContext.Set().ToDictionaryAsync( + role => role.Name!, role => role.Id, cancellationToken); + var id = Guid.CreateVersion7(); + var created = await repository.AddAsync( + CreateUser(id, [roles["Player"]]), "StrongPassword1!", cancellationToken); + created.IsSuccess.ShouldBeTrue(); + var other = IntegrationTestData.CreateUser( + id: Guid.CreateVersion7(), + email: "occupied@example.com", + isConfirmed: true, + roleIds: [], + claims: [], + userName: "occupied", + status: UserStatus.Active); + (await repository.AddAsync(other, "StrongPassword1!", cancellationToken)).IsSuccess.ShouldBeTrue(); + var original = await dbContext.Set() + .AsNoTracking() + .SingleAsync(user => user.Id == id, cancellationToken); + await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = id.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken + }, + cancellationToken); + var updated = IntegrationTestData.CreateUser( + id: id, + email: duplicateEmail ? "occupied@example.com" : "hero@example.com", + isConfirmed: false, + roleIds: [roles["Developer"]], + claims: [("permission", "identity.users.read")], + userName: duplicateEmail ? "hero" : "occupied", + status: UserStatus.Active); + + var result = await repository.UpdateAsync(updated, cancellationToken); + + result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); + var persisted = await dbContext.Set() + .Include(user => user.Roles) + .Include(user => user.Claims) + .AsNoTracking() + .SingleAsync(user => user.Id == id, cancellationToken); + persisted.Email.ShouldBe(original.Email); + persisted.UserName.ShouldBe(original.UserName); + persisted.EmailConfirmed.ShouldBe(original.EmailConfirmed); + persisted.SecurityStamp.ShouldBe(original.SecurityStamp); + persisted.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(roles["Player"]); + persisted.Claims.ShouldBeEmpty(); + var token = await dbContext.Set>() + .AsNoTracking() + .SingleAsync(cancellationToken); + token.Status.ShouldBe(OpenIddictConstants.Statuses.Valid); + } + finally + { + options.DisableBulkOperations = originalDisableBulk; + } + } + private static User CreateUser(Guid id, IReadOnlyCollection roleIds) => IntegrationTestData.CreateUser( id: id, From 193da399b1af36617b783b69ec622d5e160335da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 21:34:17 +0400 Subject: [PATCH 29/34] test: separate regression test setup and execution --- .../Persistence/Features/Users/Write/UserRoleAssignmentTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs index 4188f004..9396ff24 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs @@ -125,6 +125,7 @@ public async Task UpdateAsync_Should_PreserveStateAndTokens_When_IdentityValidat .CurrentValue; var originalDisableBulk = options.DisableBulkOperations; options.DisableBulkOperations = disableBulk; + try { var roles = await dbContext.Set().ToDictionaryAsync( From d0542db81199966b68a756c8816b244cb5399dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 21:44:36 +0400 Subject: [PATCH 30/34] fix: require a role when registering users --- .../Users/User.cs | 9 +++++++-- .../Services/UserCredentialsService.cs | 6 ++++-- .../Domain/Users/UserTests.cs | 18 ++++++++++++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/User.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/User.cs index 869f224d..c22183b5 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/User.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Users/User.cs @@ -34,13 +34,18 @@ private User( public static User Register( Email email, - UserName userName) + UserName userName, + RoleId roleId) { - return new User( + var user = new User( id: UserId.New(), email: email, userName: userName, status: UserStatus.Active); + + user.AssignRole(roleId); + + return user; } public static User Create( diff --git a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs index 56611f68..6164878b 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/IdentityProvider/Services/UserCredentialsService.cs @@ -56,8 +56,10 @@ public async Task> RegisterAsync( return Result.Failure(errors: roleIdResult.Errors); } - var user = User.Register(email: emailResult.Value, userName: userNameResult.Value); - user.AssignRole(roleIdResult.Value); + var user = User.Register( + email: emailResult.Value, + userName: userNameResult.Value, + roleId: roleIdResult.Value); var nowUtc = timeProvider.GetUtcNow().UtcDateTime; var applicationUser = ApplicationUserMapper.ToDbModel( user: user, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserTests.cs index 285a3273..b8b9d140 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Users/UserTests.cs @@ -13,16 +13,19 @@ public sealed class UserTests [Fact(DisplayName = "Register should create an unconfirmed user with normalized email when email is valid")] public void Register_Should_CreateUnconfirmedUser_When_EmailIsValid() { + var roleId = RoleId.New(); + var result = User.Register( - Email.Create(value: " HERO@Example.COM ").Value, - UserName.Create(value: " hero ").Value); + email: Email.Create(value: " HERO@Example.COM ").Value, + userName: UserName.Create(value: " hero ").Value, + roleId: roleId); result.Id.Value.ShouldNotBe(Guid.Empty); result.Email.Value.ShouldBe("hero@example.com"); result.UserName.Value.ShouldBe("hero"); result.Status.ShouldBe(UserStatus.Active); result.ConfirmationStatus.IsConfirmed.ShouldBeFalse(); - result.RoleIds.ShouldBeEmpty(); + result.RoleIds.ShouldHaveSingleItem().ShouldBe(roleId); result.Claims.ShouldBeEmpty(); result.GetDomainEvents().ShouldBeEmpty(); } @@ -229,6 +232,13 @@ public void Claims_Should_RejectExternalMutations_When_AClaimIsGranted() private static User CreateUser() { - return User.Register(Email.Create(value: "hero@example.com").Value, UserName.Create(value: " hero ").Value); + return User.Create( + id: UserId.New(), + email: Email.Create(value: "hero@example.com").Value, + userName: UserName.Create(value: " hero ").Value, + status: UserStatus.Active, + confirmationStatus: UserConfirmationStatus.Unconfirmed(), + roleIds: [], + claims: []); } } From 2c15ed6b84b9d580236d8461cb4ce1c735125c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 21:53:29 +0400 Subject: [PATCH 31/34] chore: simplify role docs and remove unused XML pin --- Directory.Packages.props | 1 - README.md | 27 ++++++--------------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3395666e..fe613583 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,6 @@ - diff --git a/README.md b/README.md index e482cb43..2cb3a2af 100644 --- a/README.md +++ b/README.md @@ -81,27 +81,12 @@ Frontend adaptation is separate. ## User Roles -Identity migrations seed `Developer`, `Moderator`, and `Player`. Developer receives all current -permissions and both sections; Moderator receives Compendium permissions and its section; -Player has no administrative claims and is the initial default role. Startup does not reseed roles. -Existing users are not reassigned automatically. - -Role names are arbitrary nonempty values up to 128 characters, trimmed and normalized to lowercase. -Role create/update requests require `isDefault` before `claims`; list/details responses include the flag. -Selecting a default while another role has the flag returns HTTP 409. To change it, clear the previous -flag and then select the new role. Editing the current default is allowed; deleting it returns HTTP 409. -The unique database index also prevents duplicate defaults in concurrent requests. -Registration selects the role marked `isDefault`, independently of its name. - -User create/update requests require `roleIds` (GUID array), just like `claims`; -get-details returns the selected IDs. Updates replace the complete role selection; -`[]` means no roles. Omitted/null collections are rejected. Empty GUIDs, -duplicates, and unknown role IDs are rejected before persistence. Use the existing -roles list endpoint to populate selection options. Role changes rotate the security -stamp and revoke the user's tokens. Changes to an assigned role's name or claims, -and role deletion, also invalidate sessions for its members. Failed user updates preserve -existing assignments and sessions. Role writes and session invalidation share the command transaction. Apply the Identity default-role and seed-data migrations; -no configuration changes are required. Rolling back seed data preserves roles and assignments. +Identity migrations seed `Developer` (all permissions/sections), `Moderator` (Compendium), +and `Player` (no administrative claims). Registration assigns the role marked `isDefault`, initially Player. +Role create/update requests require `isDefault`; clear the previous default before selecting another. + +User create/update requests require `roleIds`; updates replace all assignments, and `[]` clears them. +Role assignment changes, role name/claim changes, and role deletion invalidate affected users' sessions. ## Request Validation From c3c82c1c3e7a3de818d64909c0eb4659163931d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 22:06:53 +0400 Subject: [PATCH 32/34] test: organize role scenarios by endpoint --- .../Register/RegisterUserEndpointTests.cs | 31 +++ .../Roles/Create/CreateRoleEndpointTests.cs | 88 +++++++++ .../Roles/DefaultRoleEndpointTests.cs | 128 ------------- .../Roles/Delete/DeleteRoleEndpointTests.cs | 89 +++++++++ .../Features/Roles/RoleNameEndpointTests.cs | 30 --- .../RoleSessionInvalidationEndpointTests.cs | 105 ----------- .../Roles/Update/UpdateRoleEndpointTests.cs | 177 ++++++++++++++++++ .../Users/Create/CreateUserEndpointTests.cs | 65 +++++++ .../Users/Update/UpdateUserEndpointTests.cs | 91 +++++++++ .../Users/UserRoleAssignmentEndpointTests.cs | 130 ------------- 10 files changed, 541 insertions(+), 393 deletions(-) delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs index 2f794825..d89ce185 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Auth/Register/RegisterUserEndpointTests.cs @@ -1,5 +1,8 @@ +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Auth.Register; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Auth.Register; @@ -36,4 +39,32 @@ public async Task PostRegister_Should_PersistUnconfirmedUser_When_RequestIsValid user.EmailConfirmed.ShouldBeFalse(); user.Roles.ShouldHaveSingleItem().Role!.Name.ShouldBe("Player"); } + + [Fact(DisplayName = "POST auth register should assign the configured role when default role is renamed")] + public async Task PostRegister_Should_AssignConfiguredRole_When_DefaultRoleIsRenamed() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = new RoleApiTestClient(Fixture); + var previous = (await roles.GetListAsync(cancellationToken)).Items.Single(role => role.IsDefault); + await roles.UpdateAsync(previous.Id, new UpdateRoleRequest(previous.Name, false, []), cancellationToken); + var created = await roles.CreateAsync( + cancellationToken, new CreateRoleRequest("CustomDefault", true, [])); + await roles.UpdateAsync(created.Id, new UpdateRoleRequest("RenamedDefault", true, []), cancellationToken); + + using var response = await Client.PostAsJsonAsync( + "/api/v1/auth/register", + new RegisterUserRequest("default@example.com", "new-user", "StrongPassword1!"), + JsonOptions, + cancellationToken); + var registered = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Created); + registered.ShouldNotBeNull(); + var user = await UserDatabaseTestHelper.FindAsync(Fixture, registered.Id, cancellationToken); + user.ShouldNotBeNull(); + user.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(created.Id); + var details = await roles.GetDetailsAsync(created.Id, cancellationToken); + details.Name.ShouldBe("renameddefault"); + details.IsDefault.ShouldBeTrue(); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs index e10b8ad7..6336a06b 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs @@ -1,5 +1,9 @@ +using System.Text.Json; + +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.Create; @@ -27,4 +31,88 @@ public async Task PostRoles_Should_PersistRole_When_RequestIsValid() role.Name.ShouldBe("moderator"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } + + [Fact(DisplayName = "POST roles should assign the default when previous flag is cleared")] + public async Task PostRoles_Should_AssignDefault_When_PreviousFlagIsCleared() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var previous = roles.Items.Single(role => role.IsDefault); + await client.UpdateAsync(previous.Id, new UpdateRoleRequest(previous.Name, false, []), cancellationToken); + + var created = await client.CreateAsync( + cancellationToken, new CreateRoleRequest("CustomDefault", true, [])); + + var details = await client.GetDetailsAsync(created.Id, cancellationToken); + details.IsDefault.ShouldBeTrue(); + (await client.GetListAsync(cancellationToken)).Items.Single(role => role.IsDefault).Id.ShouldBe(created.Id); + (await client.GetDetailsAsync(previous.Id, cancellationToken)).IsDefault.ShouldBeFalse(); + } + + [Fact(DisplayName = "POST roles should return conflict when another default exists")] + public async Task PostRoles_Should_ReturnConflict_When_AnotherDefaultExists() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/roles") + { + Content = JsonContent.Create(new CreateRoleRequest("CustomDefault", true, []), options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + var roles = await client.GetListAsync(cancellationToken); + roles.TotalCount.ShouldBe(3); + roles.Items.Single(role => role.IsDefault).Name.ShouldBe("Player"); + } + + [Theory(DisplayName = "POST roles should return bad request when flag is missing or null")] + [InlineData(false)] + [InlineData(true)] + public async Task PostRoles_Should_ReturnBadRequest_When_FlagIsMissingOrNull(bool omit) + { + var cancellationToken = TestContext.Current.CancellationToken; + var payload = JsonSerializer.SerializeToNode(new CreateRoleRequest("CustomDefault", false, []), JsonOptions)! + .AsObject(); + if (omit) + { + payload.Remove("isDefault"); + } + else + { + payload["isDefault"] = null; + } + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/roles") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).TotalCount.ShouldBe(3); + } + + [Fact(DisplayName = "POST roles should reject empty names when name is empty")] + public async Task PostRoles_Should_ReturnBadRequest_When_NameIsEmpty() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var roleId = roles.Items.Single(role => role.Name == "Player").Id; + var path = "/api/v1/roles"; + using var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = JsonContent.Create(new CreateRoleRequest(" ", false, []), options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var details = await client.GetDetailsAsync(roleId, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + details.Name.ShouldBe("Player"); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs deleted file mode 100644 index 9382f91c..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/DefaultRoleEndpointTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Text.Json; - -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Auth.Register; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; - -namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; - -public sealed class DefaultRoleEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) -{ - [Theory(DisplayName = "Role requests should assign the default when previous flag is cleared")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveRole_Should_AssignDefault_When_PreviousFlagIsCleared(bool create) - { - var cancellationToken = TestContext.Current.CancellationToken; - var client = new RoleApiTestClient(Fixture); - var initialRoles = await client.GetListAsync(cancellationToken); - var previous = initialRoles.Items.Single(role => role.IsDefault); - await client.UpdateAsync(previous.Id, new UpdateRoleRequest(previous.Name, false, []), cancellationToken); - Guid id; - if (create) - { - id = (await client.CreateAsync( - cancellationToken, - new CreateRoleRequest("CustomDefault", true, []))).Id; - } - else - { - id = initialRoles.Items.Single(role => role.Name == "Moderator").Id; - await client.UpdateAsync(id, new UpdateRoleRequest("CustomDefault", true, []), cancellationToken); - } - - await client.UpdateAsync(id, new UpdateRoleRequest("RenamedDefault", true, []), cancellationToken); - - var roles = await client.GetListAsync(cancellationToken); - roles.Items.Single(role => role.IsDefault).Id.ShouldBe(id); - var details = await client.GetDetailsAsync(id, cancellationToken); - details.Name.ShouldBe("renameddefault"); - details.IsDefault.ShouldBeTrue(); - (await client.GetDetailsAsync(previous.Id, cancellationToken)).IsDefault.ShouldBeFalse(); - using var response = await Client.PostAsJsonAsync( - "/api/v1/auth/register", - new RegisterUserRequest("default@example.com", "new-user", "StrongPassword1!"), - JsonOptions, - cancellationToken); - response.StatusCode.ShouldBe(HttpStatusCode.Created); - var registered = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); - registered.ShouldNotBeNull(); - var user = await UserDatabaseTestHelper.FindAsync(Fixture, registered.Id, cancellationToken); - user.ShouldNotBeNull(); - user.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(id); - } - - [Theory(DisplayName = "Role requests should return conflict when another default exists")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveRole_Should_ReturnConflict_When_AnotherDefaultExists(bool create) - { - var cancellationToken = TestContext.Current.CancellationToken; - var client = new RoleApiTestClient(Fixture); - var roles = await client.GetListAsync(cancellationToken); - var id = roles.Items.Single(role => role.Name == "Moderator").Id; - var path = create ? "/api/v1/roles" : $"/api/v1/roles/{id}"; - using var request = new HttpRequestMessage(create ? HttpMethod.Post : HttpMethod.Put, path) - { - Content = JsonContent.Create(new CreateRoleRequest("CustomDefault", true, []), options: JsonOptions) - }; - - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.Conflict); - var existing = await client.GetListAsync(cancellationToken); - existing.TotalCount.ShouldBe(3); - existing.Items.Single(role => role.IsDefault).Name.ShouldBe("Player"); - (await client.GetDetailsAsync(id, cancellationToken)).Name.ShouldBe("Moderator"); - } - - [Fact(DisplayName = "Role deletion should return conflict when role is default")] - public async Task DeleteRole_Should_ReturnConflict_When_RoleIsDefault() - { - var cancellationToken = TestContext.Current.CancellationToken; - var client = new RoleApiTestClient(Fixture); - var roles = await client.GetListAsync(cancellationToken); - var current = roles.Items.Single(role => role.IsDefault); - using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/roles/{current.Id}"); - - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.Conflict); - (await client.GetDetailsAsync(current.Id, cancellationToken)).IsDefault.ShouldBeTrue(); - } - - [Theory(DisplayName = "Role requests should return bad request when flag is missing or null")] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public async Task SaveRole_Should_ReturnBadRequest_When_FlagIsMissingOrNull(bool update, bool omit) - { - var cancellationToken = TestContext.Current.CancellationToken; - var client = new RoleApiTestClient(Fixture); - var roles = await client.GetListAsync(cancellationToken); - var id = roles.Items.Single(role => role.Name == "Moderator").Id; - var payload = JsonSerializer.SerializeToNode(new CreateRoleRequest("CustomDefault", false, []), JsonOptions)! - .AsObject(); - if (omit) - { - payload.Remove("isDefault"); - } - else - { - payload["isDefault"] = null; - } - var path = update ? $"/api/v1/roles/{id}" : "/api/v1/roles"; - using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) - { - Content = JsonContent.Create(payload, options: JsonOptions) - }; - - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); - (await client.GetDetailsAsync(id, cancellationToken)).Name.ShouldBe("Moderator"); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs index ef7b3609..b44233f2 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs @@ -1,3 +1,15 @@ +using System.Net.Http.Headers; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.Delete; public sealed class DeleteRoleEndpointTests(FunctionalTestFixture fixture) @@ -18,4 +30,81 @@ public async Task DeleteRole_Should_RemoveRole_When_RoleExists() role.ShouldBeNull(); } + + [Fact(DisplayName = "DELETE role should return conflict when role is default")] + public async Task DeleteRole_Should_ReturnConflict_When_RoleIsDefault() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var current = roles.Items.Single(role => role.IsDefault); + using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/roles/{current.Id}"); + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + (await client.GetDetailsAsync(current.Id, cancellationToken)).IsDefault.ShouldBeTrue(); + } + + [Fact(DisplayName = "DELETE role should revoke access when authorization changes")] + public async Task DeleteRole_Should_RevokeAccess_When_AuthorizationChanges() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = new RoleApiTestClient(Fixture); + var role = await roles.CreateAsync( + cancellationToken, + new CreateRoleRequest( + "scoped", false, [new Claim("permission", "compendium.heroes.read")])); + var users = new UserApiTestClient(Fixture); + var user = await users.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); + using var userClient = OAuthAuthorizationRequestTestHelper.CreateOAuthClient(Fixture); + var tokens = await OAuthAuthorizationRequestTestHelper.IssueUserTokensAsync( + userClient, "hero@example.com", "StrongPassword1!", cancellationToken); + userClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var initial = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); + initial.StatusCode.ShouldBe(HttpStatusCode.OK); + + await roles.DeleteAsync(role.Id, cancellationToken); + + using var response = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact(DisplayName = "DELETE role should roll back changes when session invalidation fails")] + public async Task DeleteRole_Should_RollBackChanges_When_SessionInvalidationFails() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = new RoleApiTestClient(Fixture); + var role = await roles.CreateAsync( + cancellationToken, + new CreateRoleRequest( + "scoped", false, [new Claim("permission", "compendium.heroes.read")])); + var users = new UserApiTestClient(Fixture); + var user = await users.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); + await using (var scope = Fixture.Services.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var stored = await dbContext.Set() + .SingleAsync(item => item.Id == user.Id, cancellationToken); + stored.UserName = string.Empty; + await dbContext.SaveChangesAsync(cancellationToken); + } + var originalUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); + originalUser.ShouldNotBeNull(); + using var request = new HttpRequestMessage( + HttpMethod.Delete, $"/api/v1/roles/{role.Id}"); + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var persistedRole = await roles.GetDetailsAsync(role.Id, cancellationToken); + persistedRole.Name.ShouldBe("scoped"); + persistedRole.Claims.ShouldHaveSingleItem().Value.ShouldBe("compendium.heroes.read"); + var persistedUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); + persistedUser.ShouldNotBeNull(); + persistedUser.SecurityStamp.ShouldBe(originalUser.SecurityStamp); + persistedUser.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(role.Id); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs deleted file mode 100644 index 3171c586..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleNameEndpointTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; - -namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; - -public sealed class RoleNameEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) -{ - [Theory(DisplayName = "Role requests should reject empty names when name is empty")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveRole_Should_ReturnBadRequest_When_NameIsEmpty(bool update) - { - var cancellationToken = TestContext.Current.CancellationToken; - var client = new RoleApiTestClient(Fixture); - var roles = await client.GetListAsync(cancellationToken); - var roleId = roles.Items.Single(role => role.Name == "Player").Id; - var path = update ? $"/api/v1/roles/{roleId}" : "/api/v1/roles"; - using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) - { - Content = JsonContent.Create(new CreateRoleRequest(" ", false, []), options: JsonOptions) - }; - - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); - var details = await client.GetDetailsAsync(roleId, cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); - details.Name.ShouldBe("Player"); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs deleted file mode 100644 index ec061fe4..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/RoleSessionInvalidationEndpointTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Net.Http.Headers; - -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; - -namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; - -public sealed class RoleSessionInvalidationEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) -{ - [Theory(DisplayName = "Role requests should revoke access when authorization changes")] - [InlineData("claims")] - [InlineData("name")] - [InlineData("delete")] - public async Task SaveRole_Should_RevokeAccess_When_AuthorizationChanges(string change) - { - var cancellationToken = TestContext.Current.CancellationToken; - var roles = new RoleApiTestClient(Fixture); - var role = await roles.CreateAsync( - cancellationToken, - new CreateRoleRequest( - "scoped", false, [new Claim("permission", "compendium.heroes.read")])); - var users = new UserApiTestClient(Fixture); - var user = await users.CreateAsync( - cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); - using var userClient = OAuthAuthorizationRequestTestHelper.CreateOAuthClient(Fixture); - var tokens = await OAuthAuthorizationRequestTestHelper.IssueUserTokensAsync( - userClient, "hero@example.com", "StrongPassword1!", cancellationToken); - userClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); - using var initial = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); - initial.StatusCode.ShouldBe(HttpStatusCode.OK); - - var permission = change == "claims" ? "compendium.heroes.update" : "compendium.heroes.read"; - if (change == "delete") - { - await roles.DeleteAsync(role.Id, cancellationToken); - } - else - { - await roles.UpdateAsync( - role.Id, - new UpdateRoleRequest( - change == "name" ? "renamed" : "scoped", - false, - [new Claim("permission", permission)]), - cancellationToken); - } - - using var response = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); - response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); - } - - [Theory(DisplayName = "Role requests should roll back changes when session invalidation fails")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveRole_Should_RollBackChanges_When_SessionInvalidationFails(bool delete) - { - var cancellationToken = TestContext.Current.CancellationToken; - var roles = new RoleApiTestClient(Fixture); - var role = await roles.CreateAsync( - cancellationToken, - new CreateRoleRequest( - "scoped", false, [new Claim("permission", "compendium.heroes.read")])); - var users = new UserApiTestClient(Fixture); - var user = await users.CreateAsync( - cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); - await using (var scope = Fixture.Services.CreateAsyncScope()) - { - var dbContext = scope.ServiceProvider.GetRequiredService(); - var stored = await dbContext.Set() - .SingleAsync(item => item.Id == user.Id, cancellationToken); - stored.UserName = string.Empty; - await dbContext.SaveChangesAsync(cancellationToken); - } - var originalUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); - originalUser.ShouldNotBeNull(); - using var request = new HttpRequestMessage( - delete ? HttpMethod.Delete : HttpMethod.Put, $"/api/v1/roles/{role.Id}"); - if (!delete) - { - request.Content = JsonContent.Create( - new UpdateRoleRequest( - "changed", false, [new Claim("permission", "compendium.heroes.update")]), - options: JsonOptions); - } - - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); - var persistedRole = await roles.GetDetailsAsync(role.Id, cancellationToken); - persistedRole.Name.ShouldBe("scoped"); - persistedRole.Claims.ShouldHaveSingleItem().Value.ShouldBe("compendium.heroes.read"); - var persistedUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); - persistedUser.ShouldNotBeNull(); - persistedUser.SecurityStamp.ShouldBe(originalUser.SecurityStamp); - persistedUser.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(role.Id); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index 86490e8c..4b20cb7f 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -1,4 +1,15 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; +using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.Update; @@ -29,4 +40,170 @@ [new Claim("permission", "compendium.heroes.update")]), role.Name.ShouldBe("custommanager"); role.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } + + [Fact(DisplayName = "PUT role should assign the default when previous flag is cleared")] + public async Task PutRole_Should_AssignDefault_When_PreviousFlagIsCleared() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var previous = roles.Items.Single(role => role.IsDefault); + var id = roles.Items.Single(role => role.Name == "Moderator").Id; + await client.UpdateAsync(previous.Id, new UpdateRoleRequest(previous.Name, false, []), cancellationToken); + + await client.UpdateAsync(id, new UpdateRoleRequest("CustomDefault", true, []), cancellationToken); + + var details = await client.GetDetailsAsync(id, cancellationToken); + details.Name.ShouldBe("customdefault"); + details.IsDefault.ShouldBeTrue(); + (await client.GetListAsync(cancellationToken)).Items.Single(role => role.IsDefault).Id.ShouldBe(id); + (await client.GetDetailsAsync(previous.Id, cancellationToken)).IsDefault.ShouldBeFalse(); + } + + [Fact(DisplayName = "PUT role should return conflict when another default exists")] + public async Task PutRole_Should_ReturnConflict_When_AnotherDefaultExists() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var id = roles.Items.Single(role => role.Name == "Moderator").Id; + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/roles/{id}") + { + Content = JsonContent.Create(new UpdateRoleRequest("CustomDefault", true, []), options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + var existing = await client.GetListAsync(cancellationToken); + existing.TotalCount.ShouldBe(3); + existing.Items.Single(role => role.IsDefault).Name.ShouldBe("Player"); + (await client.GetDetailsAsync(id, cancellationToken)).Name.ShouldBe("Moderator"); + } + + [Theory(DisplayName = "PUT role should return bad request when flag is missing or null")] + [InlineData(false)] + [InlineData(true)] + public async Task PutRole_Should_ReturnBadRequest_When_FlagIsMissingOrNull(bool omit) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var id = roles.Items.Single(role => role.Name == "Moderator").Id; + var payload = JsonSerializer.SerializeToNode(new UpdateRoleRequest("CustomDefault", false, []), JsonOptions)! + .AsObject(); + if (omit) + { + payload.Remove("isDefault"); + } + else + { + payload["isDefault"] = null; + } + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/roles/{id}") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await client.GetDetailsAsync(id, cancellationToken)).Name.ShouldBe("Moderator"); + } + + [Fact(DisplayName = "PUT role should reject empty names when name is empty")] + public async Task PutRole_Should_ReturnBadRequest_When_NameIsEmpty() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var roles = await client.GetListAsync(cancellationToken); + var roleId = roles.Items.Single(role => role.Name == "Player").Id; + var path = $"/api/v1/roles/{roleId}"; + using var request = new HttpRequestMessage(HttpMethod.Put, path) + { + Content = JsonContent.Create(new UpdateRoleRequest(" ", false, []), options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var details = await client.GetDetailsAsync(roleId, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + details.Name.ShouldBe("Player"); + } + + [Theory(DisplayName = "PUT role should revoke access when authorization changes")] + [InlineData("claims")] + [InlineData("name")] + public async Task PutRole_Should_RevokeAccess_When_AuthorizationChanges(string change) + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = new RoleApiTestClient(Fixture); + var role = await roles.CreateAsync( + cancellationToken, + new CreateRoleRequest( + "scoped", false, [new Claim("permission", "compendium.heroes.read")])); + var users = new UserApiTestClient(Fixture); + var user = await users.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); + using var userClient = OAuthAuthorizationRequestTestHelper.CreateOAuthClient(Fixture); + var tokens = await OAuthAuthorizationRequestTestHelper.IssueUserTokensAsync( + userClient, "hero@example.com", "StrongPassword1!", cancellationToken); + userClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var initial = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); + initial.StatusCode.ShouldBe(HttpStatusCode.OK); + + var permission = change == "claims" ? "compendium.heroes.update" : "compendium.heroes.read"; + await roles.UpdateAsync( + role.Id, + new UpdateRoleRequest( + change == "name" ? "renamed" : "scoped", + false, + [new Claim("permission", permission)]), + cancellationToken); + + using var response = await userClient.GetAsync($"/api/v1/users/{user.Id}", cancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact(DisplayName = "PUT role should roll back changes when session invalidation fails")] + public async Task PutRole_Should_RollBackChanges_When_SessionInvalidationFails() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = new RoleApiTestClient(Fixture); + var role = await roles.CreateAsync( + cancellationToken, + new CreateRoleRequest( + "scoped", false, [new Claim("permission", "compendium.heroes.read")])); + var users = new UserApiTestClient(Fixture); + var user = await users.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [role.Id] }); + await using (var scope = Fixture.Services.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var stored = await dbContext.Set() + .SingleAsync(item => item.Id == user.Id, cancellationToken); + stored.UserName = string.Empty; + await dbContext.SaveChangesAsync(cancellationToken); + } + var originalUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); + originalUser.ShouldNotBeNull(); + using var request = new HttpRequestMessage( + HttpMethod.Put, $"/api/v1/roles/{role.Id}"); + request.Content = JsonContent.Create( + new UpdateRoleRequest( + "changed", false, [new Claim("permission", "compendium.heroes.update")]), + options: JsonOptions); + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var persistedRole = await roles.GetDetailsAsync(role.Id, cancellationToken); + persistedRole.Name.ShouldBe("scoped"); + persistedRole.Claims.ShouldHaveSingleItem().Value.ShouldBe("compendium.heroes.read"); + var persistedUser = await UserDatabaseTestHelper.FindAsync(Fixture, user.Id, cancellationToken); + persistedUser.ShouldNotBeNull(); + persistedUser.SecurityStamp.ShouldBe(originalUser.SecurityStamp); + persistedUser.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(role.Id); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs index efa5b4af..af7e8fb5 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs @@ -1,3 +1,7 @@ +using System.Text.Json; + +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Users.Create; @@ -33,4 +37,65 @@ [new Claim("permission", "compendium.heroes.read")], user.EmailConfirmed.ShouldBeTrue(); user.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.read"); } + + [Fact(DisplayName = "User creation should assign multiple roles when roles are selected")] + public async Task PostUsers_Should_AssignMultipleRoles_When_RolesAreSelected() + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; + Guid[] selected = + [ + roles.Single(role => role.Name == "Developer").Id, + roles.Single(role => role.Name == "Moderator").Id + ]; + var client = new UserApiTestClient(Fixture); + + var created = await client.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = selected }); + var details = await client.GetDetailsAsync(created.Id, cancellationToken); + + details.RoleIds.ShouldBe(selected, ignoreOrder: true); + var user = await UserDatabaseTestHelper.FindAsync(Fixture, created.Id, cancellationToken); + user.ShouldNotBeNull(); + user.Roles.Select(role => role.RoleId).ShouldBe(selected, ignoreOrder: true); + } + + [Theory(DisplayName = "POST users should reject invalid role selections when role selection is invalid")] + [InlineData("omitted")] + [InlineData("null")] + [InlineData("missing")] + [InlineData("empty")] + [InlineData("duplicate")] + public async Task PostUsers_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(string invalid) + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; + var developerId = roles.Single(role => role.Name == "Developer").Id; + Guid[] roleIds = invalid switch + { + "empty" => [Guid.Empty], + "duplicate" => [developerId, developerId], + _ => [developerId, Guid.CreateVersion7()] + }; + var payload = JsonSerializer.SerializeToNode( + UserApiTestClient.CreateDefaultRequest() with { RoleIds = roleIds }, JsonOptions)! + .AsObject(); + if (invalid == "omitted") + { + payload.Remove("roleIds"); + } + else if (invalid == "null") + { + payload["roleIds"] = null; + } + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/users") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs index 69a17688..6252f3bf 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs @@ -1,3 +1,7 @@ +using System.Text.Json; + +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; +using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Users.Update; @@ -35,4 +39,91 @@ [new Claim("permission", "compendium.heroes.update")], user.Status.ShouldBe("Blocked"); user.Claims.ShouldHaveSingleItem().ClaimValue.ShouldBe("compendium.heroes.update"); } + + [Theory(DisplayName = "User edits should replace or clear roles when role selections vary")] + [InlineData("replace")] + [InlineData("clear")] + public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(string mode) + { + var cancellationToken = TestContext.Current.CancellationToken; + var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; + var developerId = roles.Single(role => role.Name == "Developer").Id; + var moderatorId = roles.Single(role => role.Name == "Moderator").Id; + var client = new UserApiTestClient(Fixture); + var created = await client.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); + IReadOnlyCollection selected = mode == "clear" ? [] : [moderatorId]; + var payload = JsonSerializer.SerializeToNode( + new UpdateUserRequest( + Email: "edited@example.com", + UserName: "edited", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: selected), + JsonOptions)! + .AsObject(); + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{created.Id}") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var details = await client.GetDetailsAsync(created.Id, cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.NoContent); + details.Email.ShouldBe("edited@example.com"); + details.RoleIds.ShouldBe(selected, ignoreOrder: true); + } + + [Theory(DisplayName = "PUT user should reject invalid role selections when role selection is invalid")] + [InlineData("omitted")] + [InlineData("null")] + [InlineData("missing")] + [InlineData("empty")] + [InlineData("duplicate")] + public async Task PutUser_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(string invalid) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new UserApiTestClient(Fixture); + var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; + var developerId = roles.Single(role => role.Name == "Developer").Id; + Guid[] roleIds = invalid switch + { + "empty" => [Guid.Empty], + "duplicate" => [developerId, developerId], + _ => [developerId, Guid.CreateVersion7()] + }; + var user = await client.CreateAsync( + cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); + var payload = JsonSerializer.SerializeToNode( + new UpdateUserRequest( + Email: "rejected@example.com", + UserName: "hero", + IsConfirmed: true, + Claims: [], + Status: "Active", + RoleIds: roleIds), JsonOptions)! + .AsObject(); + if (invalid == "omitted") + { + payload.Remove("roleIds"); + } + else if (invalid == "null") + { + payload["roleIds"] = null; + } + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{user.Id}") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + var details = await client.GetDetailsAsync(user.Id, cancellationToken); + details.Email.ShouldBe("hero@example.com"); + details.RoleIds.ShouldBe([developerId]); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs deleted file mode 100644 index 2493b5f2..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRoleAssignmentEndpointTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Text.Json; - -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Users.Update; - -namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; - -public sealed class UserRoleAssignmentEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) -{ - [Fact(DisplayName = "User creation should assign multiple roles when roles are selected")] - public async Task PostUsers_Should_AssignMultipleRoles_When_RolesAreSelected() - { - var cancellationToken = TestContext.Current.CancellationToken; - var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; - Guid[] selected = - [ - roles.Single(role => role.Name == "Developer").Id, - roles.Single(role => role.Name == "Moderator").Id - ]; - var client = new UserApiTestClient(Fixture); - - var created = await client.CreateAsync( - cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = selected }); - var details = await client.GetDetailsAsync(created.Id, cancellationToken); - - details.RoleIds.ShouldBe(selected, ignoreOrder: true); - var user = await UserDatabaseTestHelper.FindAsync(Fixture, created.Id, cancellationToken); - user.ShouldNotBeNull(); - user.Roles.Select(role => role.RoleId).ShouldBe(selected, ignoreOrder: true); - } - - [Theory(DisplayName = "User edits should replace or clear roles when role selections vary")] - [InlineData("replace")] - [InlineData("clear")] - public async Task PutUser_Should_ApplyRoleSelection_When_RoleSelectionsVary(string mode) - { - var cancellationToken = TestContext.Current.CancellationToken; - var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; - var developerId = roles.Single(role => role.Name == "Developer").Id; - var moderatorId = roles.Single(role => role.Name == "Moderator").Id; - var client = new UserApiTestClient(Fixture); - var created = await client.CreateAsync( - cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); - IReadOnlyCollection selected = mode == "clear" ? [] : [moderatorId]; - var payload = JsonSerializer.SerializeToNode( - new UpdateUserRequest( - Email: "edited@example.com", - UserName: "edited", - IsConfirmed: true, - Claims: [], - Status: "Active", - RoleIds: selected), - JsonOptions)! - .AsObject(); - using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{created.Id}") - { - Content = JsonContent.Create(payload, options: JsonOptions) - }; - - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - var details = await client.GetDetailsAsync(created.Id, cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.NoContent); - details.Email.ShouldBe("edited@example.com"); - details.RoleIds.ShouldBe(selected, ignoreOrder: true); - } - - [Theory(DisplayName = "User requests should reject invalid role selections when role selection is invalid")] - [InlineData(false, "omitted")] - [InlineData(true, "omitted")] - [InlineData(false, "null")] - [InlineData(true, "null")] - [InlineData(false, "missing")] - [InlineData(true, "missing")] - [InlineData(false, "empty")] - [InlineData(true, "empty")] - [InlineData(false, "duplicate")] - [InlineData(true, "duplicate")] - public async Task SaveUser_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(bool update, string invalid) - { - var cancellationToken = TestContext.Current.CancellationToken; - var roles = (await new RoleApiTestClient(Fixture).GetListAsync(cancellationToken)).Items; - var developerId = roles.Single(role => role.Name == "Developer").Id; - Guid[] roleIds = invalid switch - { - "empty" => [Guid.Empty], - "duplicate" => [developerId, developerId], - _ => [developerId, Guid.CreateVersion7()] - }; - var payload = JsonSerializer.SerializeToNode( - UserApiTestClient.CreateDefaultRequest() with { RoleIds = roleIds }, JsonOptions)! - .AsObject(); - if (invalid == "omitted") - { - payload.Remove("roleIds"); - } - else if (invalid == "null") - { - payload["roleIds"] = null; - } - var path = "/api/v1/users"; - Guid? existingId = null; - if (update) - { - var client = new UserApiTestClient(Fixture); - var user = await client.CreateAsync( - cancellationToken, UserApiTestClient.CreateDefaultRequest() with { RoleIds = [developerId] }); - existingId = user.Id; - path = $"{path}/{user.Id}"; - payload.Remove("password"); - payload["email"] = "rejected@example.com"; - } - using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) - { - Content = JsonContent.Create(payload, options: JsonOptions) - }; - - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); - if (existingId is not null) - { - var details = await new UserApiTestClient(Fixture).GetDetailsAsync(existingId.Value, cancellationToken); - details.Email.ShouldBe("hero@example.com"); - details.RoleIds.ShouldBe([developerId]); - } - } -} From 11319940c34e062fd4d716bcd416053af6c8200b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 22:08:48 +0400 Subject: [PATCH 33/34] style: format role selection test arguments --- .../Features/Users/Update/UpdateUserEndpointTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs index 6252f3bf..019c3306 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs @@ -103,7 +103,8 @@ public async Task PutUser_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(st IsConfirmed: true, Claims: [], Status: "Active", - RoleIds: roleIds), JsonOptions)! + RoleIds: roleIds), + JsonOptions)! .AsObject(); if (invalid == "omitted") { From 0e028dd361c85295b979c7ab7ed070d98c69e0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sun, 18 Oct 2026 22:27:02 +0400 Subject: [PATCH 34/34] test: mirror endpoint and repository structure --- .../Claims/ClaimAssignmentEndpointTests.cs | 129 --------- .../OpenApi/IdentityOpenApiDocumentTests.cs | 29 ++ .../Roles/Create/CreateRoleEndpointTests.cs | 41 +++ .../Roles/Update/UpdateRoleEndpointTests.cs | 45 ++++ .../Users/Create/CreateUserEndpointTests.cs | 97 +++++++ .../Users/Update/UpdateUserEndpointTests.cs | 123 +++++++++ .../Users/UserRequestValidationTests.cs | 120 --------- .../Write/RoleSessionInvalidationTests.cs | 100 ------- .../Roles/Write/RolesRepositoryTests.cs | 247 +++++++++++++++--- .../Users/Write/UserRoleAssignmentTests.cs | 200 -------------- .../Users/Write/UsersRepositoryTests.cs | 197 ++++++++++++++ 11 files changed, 747 insertions(+), 581 deletions(-) delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs delete mode 100644 tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs deleted file mode 100644 index 6e81d46b..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles; -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; -using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Users.Update; - -using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; - -namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Claims; - -public sealed class ClaimAssignmentEndpointTests(FunctionalTestFixture fixture) : FunctionalTestBase(fixture) -{ - [Theory(DisplayName = "User and role requests should reject unknown managed claims when managed claim is unknown")] - [InlineData(false, false, "permission")] - [InlineData(false, true, "permission")] - [InlineData(true, false, "permission")] - [InlineData(true, true, "permission")] - [InlineData(false, false, "section")] - [InlineData(false, true, "section")] - [InlineData(true, false, "section")] - [InlineData(true, true, "section")] - public async Task AssignClaims_Should_ReturnBadRequest_When_ManagedClaimIsUnknown( - bool role, - bool update, - string type) - { - var cancellationToken = TestContext.Current.CancellationToken; - var path = role ? "/api/v1/roles" : "/api/v1/users"; - var payload = CreatePayload(role, new Claim(type, "unknown")); - if (update) - { - var id = role - ? (await new RoleApiTestClient(Fixture).RecreateAsync(cancellationToken)).Id - : (await new UserApiTestClient(Fixture).CreateAsync(cancellationToken)).Id; - path = $"{path}/{id}"; - payload.Remove("password"); - } - - using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) - { - Content = JsonContent.Create(payload, options: JsonOptions) - }; - using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); - using var problem = JsonDocument.Parse(body); - problem.RootElement.GetProperty("errors") - .GetProperty("claims[0].Value") - .EnumerateArray() - .Select(error => error.GetString()) - .ShouldBe(["Claim value is not present in its catalog."]); - if (update) - { - using var detailsRequest = new HttpRequestMessage(HttpMethod.Get, path); - using var detailsResponse = await OAuthServiceAccessTokenTestHelper.SendAsync( - Fixture, detailsRequest, cancellationToken); - detailsResponse.StatusCode.ShouldBe(HttpStatusCode.OK); - var detailsBody = await detailsResponse.Content.ReadAsStringAsync(cancellationToken); - using var details = JsonDocument.Parse(detailsBody); - details.RootElement.GetProperty("claims").GetArrayLength().ShouldBe(0); - } - } - - [Theory(DisplayName = "Role requests should persist a section without permissions when section is known")] - [InlineData(false)] - [InlineData(true)] - public async Task AssignRoleClaims_Should_PersistSectionOnly_When_SectionIsKnown(bool update) - { - var cancellationToken = TestContext.Current.CancellationToken; - var client = new RoleApiTestClient(Fixture); - Claim[] claims = [new(ApplicationClaimTypes.Section, "identity")]; - var role = await client.RecreateAsync( - cancellationToken, - new CreateRoleRequest("Moderator", false, update ? [] : claims)); - - if (update) - { - await client.UpdateAsync(role.Id, new UpdateRoleRequest("Moderator", false, claims), cancellationToken); - } - var details = await client.GetDetailsAsync(role.Id, cancellationToken); - - details.Claims.ShouldBe(claims); - } - - [Theory(DisplayName = "User requests should persist a section without permissions when section is known")] - [InlineData(false)] - [InlineData(true)] - public async Task AssignUserClaims_Should_PersistSectionOnly_When_SectionIsKnown(bool update) - { - var cancellationToken = TestContext.Current.CancellationToken; - var client = new UserApiTestClient(Fixture); - Claim[] claims = [new(ApplicationClaimTypes.Section, "compendium")]; - var request = UserApiTestClient.CreateDefaultRequest() with { Claims = update ? [] : claims }; - var user = await client.CreateAsync(cancellationToken, request); - - if (update) - { - await client.UpdateAsync( - user.Id, - new UpdateUserRequest( - Email: request.Email, - UserName: request.UserName, - IsConfirmed: request.IsConfirmed, - Claims: claims, - Status: request.Status, - RoleIds: []), - cancellationToken); - } - var details = await client.GetDetailsAsync(user.Id, cancellationToken); - - details.Claims.ShouldBe(claims); - } - - private static JsonObject CreatePayload(bool role, Claim claim) - { - return role - ? JsonSerializer.SerializeToNode(new CreateRoleRequest("Moderator", false, [claim]), JsonOptions)! - .AsObject() - : JsonSerializer.SerializeToNode( - UserApiTestClient.CreateDefaultRequest() with { Claims = [claim] }, JsonOptions)! - .AsObject(); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/OAuth/OpenApi/IdentityOpenApiDocumentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/OAuth/OpenApi/IdentityOpenApiDocumentTests.cs index 6d5e0366..ab587a61 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/OAuth/OpenApi/IdentityOpenApiDocumentTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/OAuth/OpenApi/IdentityOpenApiDocumentTests.cs @@ -127,4 +127,33 @@ public async Task GetIdentityOpenApiDocument_Should_IncludeOAuthEndpoints_When_R ], ignoreOrder: true); } + + [Theory(DisplayName = "Identity OpenAPI should preserve required user fields when requested")] + [InlineData("CreateUserRequest", true)] + [InlineData("UpdateUserRequest", false)] + public async Task GetIdentityOpenApiDocument_Should_PreserveUserJsonContract_When_Requested( + string schemaName, + bool includePassword) + { + var cancellationToken = TestContext.Current.CancellationToken; + using var client = Fixture.CreateClient(Environments.Development); + + using var response = await client.GetAsync("/openapi/identity-v1.json", cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, responseBody); + using var document = JsonDocument.Parse(responseBody); + var schema = document.RootElement.GetProperty("components").GetProperty("schemas").GetProperty(schemaName); + var requiredProperties = schema.GetProperty("required") + .EnumerateArray() + .Select(property => property.GetString()!) + .ToArray(); + string[] expectedProperties = includePassword + ? ["email", "userName", "password", "isConfirmed", "claims", "status", "roleIds"] + : ["email", "userName", "isConfirmed", "claims", "status", "roleIds"]; + requiredProperties.ShouldBe(expectedProperties, ignoreOrder: true); + var status = schema.GetProperty("properties").GetProperty("status"); + status.GetProperty("type").GetString().ShouldBe("string"); + status.TryGetProperty("maxLength", out _).ShouldBeFalse(); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs index 6336a06b..bba3e9ee 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Create/CreateRoleEndpointTests.cs @@ -5,6 +5,8 @@ using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.Create; public sealed class CreateRoleEndpointTests(FunctionalTestFixture fixture) @@ -115,4 +117,43 @@ public async Task PostRoles_Should_ReturnBadRequest_When_NameIsEmpty() response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); details.Name.ShouldBe("Player"); } + + [Theory(DisplayName = "POST roles should reject unknown managed claims when managed claim is unknown")] + [InlineData("permission")] + [InlineData("section")] + public async Task PostRoles_Should_ReturnBadRequest_When_ManagedClaimIsUnknown(string type) + { + var cancellationToken = TestContext.Current.CancellationToken; + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/roles") + { + Content = JsonContent.Create( + new CreateRoleRequest("Moderator", false, [new Claim(type, "unknown")]), + options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + using var problem = JsonDocument.Parse(body); + problem.RootElement.GetProperty("errors") + .GetProperty("claims[0].Value") + .EnumerateArray() + .Select(error => error.GetString()) + .ShouldBe(["Claim value is not present in its catalog."]); + } + + [Fact(DisplayName = "POST roles should persist a section without permissions when section is known")] + public async Task PostRoles_Should_PersistSectionOnly_When_SectionIsKnown() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + Claim[] claims = [new(ApplicationClaimTypes.Section, "identity")]; + + var role = await client.RecreateAsync( + cancellationToken, new CreateRoleRequest("Moderator", false, claims)); + var details = await client.GetDetailsAsync(role.Id, cancellationToken); + + details.Claims.ShouldBe(claims); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index 4b20cb7f..a2729038 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs @@ -12,6 +12,8 @@ using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.Update; public sealed class UpdateRoleEndpointTests(FunctionalTestFixture fixture) @@ -206,4 +208,47 @@ public async Task PutRole_Should_RollBackChanges_When_SessionInvalidationFails() persistedUser.SecurityStamp.ShouldBe(originalUser.SecurityStamp); persistedUser.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(role.Id); } + + [Theory(DisplayName = "PUT role should reject unknown managed claims when managed claim is unknown")] + [InlineData("permission")] + [InlineData("section")] + public async Task PutRole_Should_ReturnBadRequest_When_ManagedClaimIsUnknown(string type) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + var existing = await client.RecreateAsync(cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/roles/{existing.Id}") + { + Content = JsonContent.Create( + new UpdateRoleRequest("Moderator", false, [new Claim(type, "unknown")]), + options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + using var problem = JsonDocument.Parse(body); + problem.RootElement.GetProperty("errors") + .GetProperty("claims[0].Value") + .EnumerateArray() + .Select(error => error.GetString()) + .ShouldBe(["Claim value is not present in its catalog."]); + var details = await client.GetDetailsAsync(existing.Id, cancellationToken); + details.Claims.ShouldBeEmpty(); + } + + [Fact(DisplayName = "PUT role should persist a section without permissions when section is known")] + public async Task PutRole_Should_PersistSectionOnly_When_SectionIsKnown() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new RoleApiTestClient(Fixture); + Claim[] claims = [new(ApplicationClaimTypes.Section, "identity")]; + var role = await client.RecreateAsync(cancellationToken); + + await client.UpdateAsync(role.Id, new UpdateRoleRequest("Moderator", false, claims), cancellationToken); + var details = await client.GetDetailsAsync(role.Id, cancellationToken); + + details.Claims.ShouldBe(claims); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs index af7e8fb5..8f0316c4 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Create/CreateUserEndpointTests.cs @@ -5,6 +5,8 @@ using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Users.Create; +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users.Create; public sealed class CreateUserEndpointTests(FunctionalTestFixture fixture) @@ -98,4 +100,99 @@ public async Task PostUsers_Should_ReturnBadRequest_When_RoleSelectionIsInvalid( response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); } + + [Theory(DisplayName = "POST users should reject unknown managed claims when managed claim is unknown")] + [InlineData("permission")] + [InlineData("section")] + public async Task PostUsers_Should_ReturnBadRequest_When_ManagedClaimIsUnknown(string type) + { + var cancellationToken = TestContext.Current.CancellationToken; + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/users") + { + Content = JsonContent.Create( + UserApiTestClient.CreateDefaultRequest() with { Claims = [new Claim(type, "unknown")] }, + options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + using var problem = JsonDocument.Parse(body); + problem.RootElement.GetProperty("errors") + .GetProperty("claims[0].Value") + .EnumerateArray() + .Select(error => error.GetString()) + .ShouldBe(["Claim value is not present in its catalog."]); + } + + [Fact(DisplayName = "POST users should persist a section without permissions when section is known")] + public async Task PostUsers_Should_PersistSectionOnly_When_SectionIsKnown() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new UserApiTestClient(Fixture); + Claim[] claims = [new(ApplicationClaimTypes.Section, "compendium")]; + var request = UserApiTestClient.CreateDefaultRequest() with { Claims = claims }; + + var user = await client.CreateAsync(cancellationToken, request); + var details = await client.GetDetailsAsync(user.Id, cancellationToken); + + details.Claims.ShouldBe(claims); + } + + [Theory(DisplayName = "POST users should return domain validation errors when status is invalid")] + [InlineData("", "User status is required.")] + [InlineData(" ", "User status is required.")] + [InlineData("Deleted", "User status 'Deleted' is invalid.")] + public async Task PostUsers_Should_ReturnValidationProblem_When_StatusIsInvalid( + string status, + string expectedMessage) + { + var cancellationToken = TestContext.Current.CancellationToken; + var payload = JsonSerializer.SerializeToNode( + UserApiTestClient.CreateDefaultRequest() with { Status = status }, JsonOptions)! + .AsObject(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/users") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, responseBody); + using var problem = JsonDocument.Parse(responseBody); + var errors = problem.RootElement.GetProperty("errors") + .GetProperty("status") + .EnumerateArray() + .Select(error => error.GetString()) + .ToArray(); + errors.ShouldBe([expectedMessage]); + } + + [Theory(DisplayName = "POST users should return bad request when status is missing or null")] + [InlineData(false)] + [InlineData(true)] + public async Task PostUsers_Should_ReturnBadRequest_When_StatusIsMissingOrNull(bool omitStatus) + { + var cancellationToken = TestContext.Current.CancellationToken; + var payload = JsonSerializer.SerializeToNode(UserApiTestClient.CreateDefaultRequest(), JsonOptions)!.AsObject(); + if (omitStatus) + { + payload.Remove("status"); + } + else + { + payload["status"] = null; + } + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/users") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, responseBody); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs index 019c3306..7363429e 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/Update/UpdateUserEndpointTests.cs @@ -5,6 +5,8 @@ using PANiXiDA.TacticalHeroes.Identity.Presentation.Common; using PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Users.Update; +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users.Update; public sealed class UpdateUserEndpointTests(FunctionalTestFixture fixture) @@ -127,4 +129,125 @@ public async Task PutUser_Should_ReturnBadRequest_When_RoleSelectionIsInvalid(st details.Email.ShouldBe("hero@example.com"); details.RoleIds.ShouldBe([developerId]); } + + [Theory(DisplayName = "PUT user should reject unknown managed claims when managed claim is unknown")] + [InlineData("permission")] + [InlineData("section")] + public async Task PutUser_Should_ReturnBadRequest_When_ManagedClaimIsUnknown(string type) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new UserApiTestClient(Fixture); + var existing = await client.CreateAsync(cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{existing.Id}") + { + Content = JsonContent.Create( + new UpdateUserRequest( + Email: "hero@example.com", + UserName: "hero", + IsConfirmed: true, + Claims: [new Claim(type, "unknown")], + Status: "Active", + RoleIds: []), + options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, body); + using var problem = JsonDocument.Parse(body); + problem.RootElement.GetProperty("errors") + .GetProperty("claims[0].Value") + .EnumerateArray() + .Select(error => error.GetString()) + .ShouldBe(["Claim value is not present in its catalog."]); + var details = await client.GetDetailsAsync(existing.Id, cancellationToken); + details.Claims.ShouldBeEmpty(); + } + + [Fact(DisplayName = "PUT user should persist a section without permissions when section is known")] + public async Task PutUser_Should_PersistSectionOnly_When_SectionIsKnown() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new UserApiTestClient(Fixture); + Claim[] claims = [new(ApplicationClaimTypes.Section, "compendium")]; + var request = UserApiTestClient.CreateDefaultRequest(); + var user = await client.CreateAsync(cancellationToken, request); + + await client.UpdateAsync( + user.Id, + new UpdateUserRequest( + Email: request.Email, + UserName: request.UserName, + IsConfirmed: request.IsConfirmed, + Claims: claims, + Status: request.Status, + RoleIds: []), + cancellationToken); + var details = await client.GetDetailsAsync(user.Id, cancellationToken); + + details.Claims.ShouldBe(claims); + } + + [Theory(DisplayName = "PUT user should return domain validation errors when status is invalid")] + [InlineData("", "User status is required.")] + [InlineData(" ", "User status is required.")] + [InlineData("Deleted", "User status 'Deleted' is invalid.")] + public async Task PutUser_Should_ReturnValidationProblem_When_StatusIsInvalid( + string status, + string expectedMessage) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new UserApiTestClient(Fixture); + var user = await client.CreateAsync(cancellationToken); + var payload = JsonSerializer.SerializeToNode( + UserApiTestClient.CreateDefaultRequest() with { Status = status }, JsonOptions)! + .AsObject(); + payload.Remove("password"); + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{user.Id}") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, responseBody); + using var problem = JsonDocument.Parse(responseBody); + var errors = problem.RootElement.GetProperty("errors") + .GetProperty("status") + .EnumerateArray() + .Select(error => error.GetString()) + .ToArray(); + errors.ShouldBe([expectedMessage]); + } + + [Theory(DisplayName = "PUT user should return bad request when status is missing or null")] + [InlineData(false)] + [InlineData(true)] + public async Task PutUser_Should_ReturnBadRequest_When_StatusIsMissingOrNull(bool omitStatus) + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = new UserApiTestClient(Fixture); + var user = await client.CreateAsync(cancellationToken); + var payload = JsonSerializer.SerializeToNode(UserApiTestClient.CreateDefaultRequest(), JsonOptions)!.AsObject(); + if (omitStatus) + { + payload.Remove("status"); + } + else + { + payload["status"] = null; + } + payload.Remove("password"); + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{user.Id}") + { + Content = JsonContent.Create(payload, options: JsonOptions) + }; + + using var response = await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, responseBody); + } } diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs deleted file mode 100644 index 5b182a12..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -using Microsoft.Extensions.Hosting; - -using PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.OAuth; - -using static PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users.UsersApiConstants; - -namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users; - -public sealed class UserRequestValidationTests(FunctionalTestFixture fixture) - : FunctionalTestBase(fixture) -{ - [Theory(DisplayName = "User requests should return domain validation errors when status is invalid")] - [InlineData(false, "", "User status is required.")] - [InlineData(true, "", "User status is required.")] - [InlineData(false, " ", "User status is required.")] - [InlineData(true, " ", "User status is required.")] - [InlineData(false, "Deleted", "User status 'Deleted' is invalid.")] - [InlineData(true, "Deleted", "User status 'Deleted' is invalid.")] - public async Task SendUserRequest_Should_ReturnValidationProblem_When_StatusIsInvalid( - bool update, - string status, - string expectedMessage) - { - var cancellationToken = TestContext.Current.CancellationToken; - var payload = JsonSerializer.SerializeToNode( - UserApiTestClient.CreateDefaultRequest() with { Status = status }, JsonOptions)! - .AsObject(); - - using var response = await SendUserRequestAsync(update, payload, cancellationToken); - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, responseBody); - using var problem = JsonDocument.Parse(responseBody); - var errors = problem.RootElement.GetProperty("errors") - .GetProperty("status") - .EnumerateArray() - .Select(error => error.GetString()) - .ToArray(); - errors.ShouldBe([expectedMessage]); - } - - [Theory(DisplayName = "User requests should return bad request when status is missing or null")] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public async Task SendUserRequest_Should_ReturnBadRequest_When_StatusIsMissingOrNull(bool update, bool omitStatus) - { - var cancellationToken = TestContext.Current.CancellationToken; - var payload = JsonSerializer.SerializeToNode(UserApiTestClient.CreateDefaultRequest(), JsonOptions)!.AsObject(); - if (omitStatus) - { - payload.Remove("status"); - } - else - { - payload["status"] = null; - } - - using var response = await SendUserRequestAsync(update, payload, cancellationToken); - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, responseBody); - } - - [Theory( - DisplayName = "User OpenAPI schemas should preserve required fields without validation attributes when requested")] - [InlineData("CreateUserRequest", true)] - [InlineData("UpdateUserRequest", false)] - public async Task GetOpenApiDocument_Should_PreserveUserJsonContract_When_Requested( - string schemaName, - bool includePassword) - { - var cancellationToken = TestContext.Current.CancellationToken; - using var client = Fixture.CreateClient(Environments.Development); - - using var response = await client.GetAsync("/openapi/identity-v1.json", cancellationToken); - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - - response.StatusCode.ShouldBe(HttpStatusCode.OK, responseBody); - using var document = JsonDocument.Parse(responseBody); - var schema = document.RootElement.GetProperty("components").GetProperty("schemas").GetProperty(schemaName); - var requiredProperties = schema.GetProperty("required") - .EnumerateArray() - .Select(property => property.GetString()!) - .ToArray(); - string[] expectedProperties = includePassword - ? ["email", "userName", "password", "isConfirmed", "claims", "status", "roleIds"] - : ["email", "userName", "isConfirmed", "claims", "status", "roleIds"]; - requiredProperties.ShouldBe(expectedProperties, ignoreOrder: true); - var status = schema.GetProperty("properties").GetProperty("status"); - status.GetProperty("type").GetString().ShouldBe("string"); - status.TryGetProperty("maxLength", out _).ShouldBeFalse(); - } - - private async Task SendUserRequestAsync( - bool update, - JsonObject payload, - CancellationToken cancellationToken) - { - var path = UsersPath; - if (update) - { - var client = new UserApiTestClient(Fixture); - var user = await client.CreateAsync(cancellationToken); - path = $"{path}/{user.Id}"; - payload.Remove("password"); - } - - using var request = new HttpRequestMessage(update ? HttpMethod.Put : HttpMethod.Post, path) - { - Content = JsonContent.Create(payload, options: JsonOptions) - }; - - return await OAuthServiceAccessTokenTestHelper.SendAsync(Fixture, request, cancellationToken); - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs deleted file mode 100644 index 6b223304..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RoleSessionInvalidationTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -using OpenIddict.Abstractions; -using OpenIddict.EntityFrameworkCore.Models; - -using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; -using PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Roles; - -namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persistence.Features.Roles.Write; - -public sealed class RoleSessionInvalidationTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) -{ - [Theory(DisplayName = "Role writes should invalidate affected sessions when role changes vary")] - [InlineData("claims")] - [InlineData("name")] - [InlineData("delete")] - [InlineData("unchanged")] - [InlineData("default")] - public async Task SaveAsync_Should_InvalidateAffectedSessions_When_RoleChangesVary(string change) - { - var cancellationToken = TestContext.Current.CancellationToken; - await using var scope = Fixture.CreateScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var tokenManager = scope.ServiceProvider.GetRequiredService(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var roleId = Guid.CreateVersion7(); - var role = IntegrationTestData.CreateRole(roleId, "original", [("permission", "compendium.heroes.read")]); - (await repository.AddAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); - var stamps = new Dictionary(); - var affected = new HashSet(); - for (var index = 0; index < 3; index++) - { - var user = new ApplicationUser - { - Id = Guid.CreateVersion7(), - Email = $"user{index}@example.com", - UserName = $"user{index}", - EmailConfirmed = true, - Roles = index < 2 ? [new ApplicationUserRole { RoleId = roleId }] : [] - }; - (await userManager.CreateAsync(user, "StrongPassword1!")).Succeeded.ShouldBeTrue(); - stamps.Add(user.Id, user.SecurityStamp); - if (index < 2) - { - affected.Add(user.Id); - } - foreach (var type in new[] - { - OpenIddictConstants.TokenTypeIdentifiers.AccessToken, - OpenIddictConstants.TokenTypeIdentifiers.RefreshToken - }) - { - await tokenManager.CreateAsync( - new OpenIddictTokenDescriptor - { - Subject = user.Id.ToString(), - Status = OpenIddictConstants.Statuses.Valid, - Type = type - }, - cancellationToken); - } - } - var target = IntegrationTestData.CreateRole( - id: roleId, - name: change == "name" ? "renamed" : "original", - claims: [("permission", change == "claims" ? "compendium.heroes.update" : "compendium.heroes.read")], - isDefault: change == "default"); - - var result = change == "delete" - ? await repository.DeleteAsync(roleId, cancellationToken) - : await repository.UpdateAsync(target, cancellationToken); - - result.IsSuccess.ShouldBeTrue(); - var users = await dbContext.Set() - .AsNoTracking() - .ToArrayAsync(cancellationToken); - var tokens = await dbContext.Set>() - .AsNoTracking() - .ToArrayAsync(cancellationToken); - tokens.Length.ShouldBe(6); - var authorizationChanged = change is "claims" or "name" or "delete"; - foreach (var user in users) - { - var revoked = authorizationChanged && affected.Contains(user.Id); - (user.SecurityStamp != stamps[user.Id]).ShouldBe(revoked); - tokens.Where(token => token.Subject == user.Id.ToString()).ShouldAllBe(token => token.Status == (revoked - ? OpenIddictConstants.Statuses.Revoked - : OpenIddictConstants.Statuses.Valid)); - } - if (change == "delete") - { - (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); - } - } -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs index 7fffe355..d6dcf507 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Write/RolesRepositoryTests.cs @@ -1,11 +1,16 @@ using System.Security.Claims; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using OpenIddict.EntityFrameworkCore.Models; + using PANiXiDA.TacticalHeroes.Identity.Domain.Roles.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Roles; namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persistence.Features.Roles.Write; @@ -113,37 +118,54 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); } - [Theory(DisplayName = "Role persistence should assign the default when previous flag is cleared")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveAsync_Should_AssignDefault_When_PreviousFlagIsCleared(bool create) + [Fact(DisplayName = "AddAsync should assign the default when previous flag is cleared")] + public async Task AddAsync_Should_AssignDefault_When_PreviousFlagIsCleared() { var cancellationToken = TestContext.Current.CancellationToken; await Fixture.SeedRolesAsync(cancellationToken); await using var scope = Fixture.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var previous = await dbContext.Set().SingleAsync(role => role.IsDefault, cancellationToken); - var targetId = create - ? Guid.CreateVersion7() - : await dbContext.Set() - .Where(role => role.Name == "Moderator") - .Select(role => role.Id) - .SingleAsync(cancellationToken); + var targetId = Guid.CreateVersion7(); var role = IntegrationTestData.CreateRole(targetId, "CustomDefault", [], isDefault: true); var repository = scope.ServiceProvider.GetRequiredService(); var cleared = await repository.UpdateAsync( IntegrationTestData.CreateRole(previous.Id, previous.Name!, [], isDefault: false), cancellationToken); cleared.IsSuccess.ShouldBeTrue(); - if (create) - { - (await repository.AddAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); - } - else - { - (await repository.UpdateAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); - } + var result = await repository.AddAsync(role, cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var defaults = await dbContext.Set() + .AsNoTracking() + .Where(item => item.IsDefault) + .ToArrayAsync(cancellationToken); + defaults.ShouldHaveSingleItem().Id.ShouldBe(targetId); + previous.IsDefault.ShouldBeFalse(); + (await repository.DeleteAsync(previous.Id, cancellationToken)).IsSuccess.ShouldBeTrue(); + } + + [Fact(DisplayName = "UpdateAsync should assign the default when previous flag is cleared")] + public async Task UpdateAsync_Should_AssignDefault_When_PreviousFlagIsCleared() + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var previous = await dbContext.Set().SingleAsync(role => role.IsDefault, cancellationToken); + var targetId = await dbContext.Set() + .Where(role => role.Name == "Moderator") + .Select(role => role.Id) + .SingleAsync(cancellationToken); + var role = IntegrationTestData.CreateRole(targetId, "CustomDefault", [], isDefault: true); + var repository = scope.ServiceProvider.GetRequiredService(); + var cleared = await repository.UpdateAsync( + IntegrationTestData.CreateRole(previous.Id, previous.Name!, [], isDefault: false), cancellationToken); + cleared.IsSuccess.ShouldBeTrue(); + var result = await repository.UpdateAsync(role, cancellationToken); + + result.IsSuccess.ShouldBeTrue(); var defaults = await dbContext.Set() .AsNoTracking() .Where(item => item.IsDefault) @@ -153,29 +175,45 @@ public async Task SaveAsync_Should_AssignDefault_When_PreviousFlagIsCleared(bool (await repository.DeleteAsync(previous.Id, cancellationToken)).IsSuccess.ShouldBeTrue(); } - [Theory(DisplayName = "Role persistence should retain the default when duplicate name rejects change")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveAsync_Should_RetainDefault_When_DuplicateNameRejectsChange(bool create) + [Fact(DisplayName = "AddAsync should retain the default when duplicate name rejects change")] + public async Task AddAsync_Should_RetainDefault_When_DuplicateNameRejectsChange() + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var targetId = Guid.CreateVersion7(); + var role = IntegrationTestData.CreateRole(targetId, "Developer", [], isDefault: true); + var repository = scope.ServiceProvider.GetRequiredService(); + + var result = await repository.AddAsync(role, cancellationToken); + + result.IsFailure.ShouldBeTrue(); + var defaultName = await dbContext.Set() + .AsNoTracking() + .Where(item => item.IsDefault) + .Select(item => item.Name) + .SingleAsync(cancellationToken); + defaultName.ShouldBe("Player"); + } + + [Fact(DisplayName = "UpdateAsync should retain the default when duplicate name rejects change")] + public async Task UpdateAsync_Should_RetainDefault_When_DuplicateNameRejectsChange() { var cancellationToken = TestContext.Current.CancellationToken; await Fixture.SeedRolesAsync(cancellationToken); await using var scope = Fixture.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var targetId = create - ? Guid.CreateVersion7() - : await dbContext.Set() - .Where(role => role.Name == "Moderator") - .Select(role => role.Id) - .SingleAsync(cancellationToken); + var targetId = await dbContext.Set() + .Where(role => role.Name == "Moderator") + .Select(role => role.Id) + .SingleAsync(cancellationToken); var role = IntegrationTestData.CreateRole(targetId, "Developer", [], isDefault: true); var repository = scope.ServiceProvider.GetRequiredService(); - var result = create - ? (await repository.AddAsync(role, cancellationToken)).IsFailure - : (await repository.UpdateAsync(role, cancellationToken)).IsFailure; + var result = await repository.UpdateAsync(role, cancellationToken); - result.ShouldBeTrue(); + result.IsFailure.ShouldBeTrue(); var defaultName = await dbContext.Set() .AsNoTracking() .Where(item => item.IsDefault) @@ -257,6 +295,151 @@ public async Task GetDefaultAsync_Should_ReturnValidationErrors_When_StoredDefau result.Errors.ShouldAllBe(error => error.Type == ErrorType.Validation); } + [Theory(DisplayName = "UpdateAsync should invalidate affected sessions when role changes vary")] + [InlineData("claims")] + [InlineData("name")] + [InlineData("unchanged")] + [InlineData("default")] + public async Task UpdateAsync_Should_InvalidateAffectedSessions_When_RoleChangesVary(string change) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var roleId = Guid.CreateVersion7(); + var role = IntegrationTestData.CreateRole(roleId, "original", [("permission", "compendium.heroes.read")]); + (await repository.AddAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); + var stamps = new Dictionary(); + var affected = new HashSet(); + for (var index = 0; index < 3; index++) + { + var user = new ApplicationUser + { + Id = Guid.CreateVersion7(), + Email = $"user{index}@example.com", + UserName = $"user{index}", + EmailConfirmed = true, + Roles = index < 2 ? [new ApplicationUserRole { RoleId = roleId }] : [] + }; + (await userManager.CreateAsync(user, "StrongPassword1!")).Succeeded.ShouldBeTrue(); + stamps.Add(user.Id, user.SecurityStamp); + if (index < 2) + { + affected.Add(user.Id); + } + foreach (var type in new[] + { + OpenIddictConstants.TokenTypeIdentifiers.AccessToken, + OpenIddictConstants.TokenTypeIdentifiers.RefreshToken + }) + { + await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = user.Id.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = type + }, + cancellationToken); + } + } + var target = IntegrationTestData.CreateRole( + id: roleId, + name: change == "name" ? "renamed" : "original", + claims: [("permission", change == "claims" ? "compendium.heroes.update" : "compendium.heroes.read")], + isDefault: change == "default"); + + var result = await repository.UpdateAsync(target, cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var users = await dbContext.Set() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + var tokens = await dbContext.Set>() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + tokens.Length.ShouldBe(6); + var authorizationChanged = change is "claims" or "name"; + foreach (var user in users) + { + var revoked = authorizationChanged && affected.Contains(user.Id); + (user.SecurityStamp != stamps[user.Id]).ShouldBe(revoked); + tokens.Where(token => token.Subject == user.Id.ToString()).ShouldAllBe(token => token.Status == (revoked + ? OpenIddictConstants.Statuses.Revoked + : OpenIddictConstants.Statuses.Valid)); + } + } + + [Fact(DisplayName = "DeleteAsync should invalidate affected sessions when role has members")] + public async Task DeleteAsync_Should_InvalidateAffectedSessions_When_RoleHasMembers() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var roleId = Guid.CreateVersion7(); + var role = IntegrationTestData.CreateRole(roleId, "original", [("permission", "compendium.heroes.read")]); + (await repository.AddAsync(role, cancellationToken)).IsSuccess.ShouldBeTrue(); + var stamps = new Dictionary(); + var affected = new HashSet(); + for (var index = 0; index < 3; index++) + { + var user = new ApplicationUser + { + Id = Guid.CreateVersion7(), + Email = $"user{index}@example.com", + UserName = $"user{index}", + EmailConfirmed = true, + Roles = index < 2 ? [new ApplicationUserRole { RoleId = roleId }] : [] + }; + (await userManager.CreateAsync(user, "StrongPassword1!")).Succeeded.ShouldBeTrue(); + stamps.Add(user.Id, user.SecurityStamp); + if (index < 2) + { + affected.Add(user.Id); + } + foreach (var type in new[] + { + OpenIddictConstants.TokenTypeIdentifiers.AccessToken, + OpenIddictConstants.TokenTypeIdentifiers.RefreshToken + }) + { + await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = user.Id.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = type + }, + cancellationToken); + } + } + + var result = await repository.DeleteAsync(roleId, cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var users = await dbContext.Set() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + var tokens = await dbContext.Set>() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + tokens.Length.ShouldBe(6); + foreach (var user in users) + { + var revoked = affected.Contains(user.Id); + (user.SecurityStamp != stamps[user.Id]).ShouldBe(revoked); + tokens.Where(token => token.Subject == user.Id.ToString()).ShouldAllBe(token => token.Status == (revoked + ? OpenIddictConstants.Statuses.Revoked + : OpenIddictConstants.Statuses.Valid)); + } + (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); + } + private async Task AddRoleAsync( string name, IReadOnlyCollection claims, diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs deleted file mode 100644 index 9396ff24..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UserRoleAssignmentTests.cs +++ /dev/null @@ -1,200 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -using OpenIddict.Abstractions; -using OpenIddict.EntityFrameworkCore; -using OpenIddict.EntityFrameworkCore.Models; - -using PANiXiDA.TacticalHeroes.Identity.Domain.Users; -using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; -using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; -using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; -using PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Users; - -namespace PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Infrastructure.Persistence.Features.Users.Write; - -public sealed class UserRoleAssignmentTests(IntegrationTestFixture fixture) : IntegrationTestBase(fixture) -{ - [Theory(DisplayName = "User updates should synchronize roles and sessions when role assignments vary")] - [InlineData("same")] - [InlineData("replace")] - [InlineData("add")] - [InlineData("clear")] - public async Task UpdateAsync_Should_SynchronizeRolesAndSessions_When_RoleAssignmentsVary(string mode) - { - var cancellationToken = TestContext.Current.CancellationToken; - await using var scope = Fixture.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var repository = scope.ServiceProvider.GetRequiredService(); - await Fixture.SeedRolesAsync(cancellationToken); - var roles = await dbContext.Set().ToDictionaryAsync( - role => role.Name!, role => role.Id, StringComparer.OrdinalIgnoreCase, cancellationToken); - var userId = Guid.CreateVersion7(); - var created = await repository.AddAsync( - CreateUser(userId, [roles["Developer"]]), "StrongPassword1!", cancellationToken); - created.IsSuccess.ShouldBeTrue(); - var originalStamp = (await dbContext.Set() - .SingleAsync(user => user.Id == userId, cancellationToken)).SecurityStamp; - var targetRoles = mode switch - { - "same" => new[] { roles["Developer"] }, - "replace" => [roles["Moderator"]], - "add" => [roles["Developer"], roles["Player"]], - _ => [] - }; - var tokenManager = scope.ServiceProvider.GetRequiredService(); - var otherId = Guid.CreateVersion7(); - foreach (var subject in new[] { userId, otherId }) - { - await tokenManager.CreateAsync( - new OpenIddictTokenDescriptor - { - Subject = subject.ToString(), - Status = OpenIddictConstants.Statuses.Valid, - Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken - }, - cancellationToken); - } - - var result = await repository.UpdateAsync( - user: CreateUser(userId, targetRoles), - cancellationToken: cancellationToken); - - result.IsSuccess.ShouldBeTrue(); - var user = await dbContext.Set() - .Include(item => item.Roles) - .AsNoTracking() - .SingleAsync(item => item.Id == userId, cancellationToken); - user.Roles.Select(role => role.RoleId).ShouldBe(targetRoles, ignoreOrder: true); - var changed = mode is "replace" or "add" or "clear"; - (user.SecurityStamp != originalStamp).ShouldBe(changed); - var tokens = await dbContext.Set>() - .AsNoTracking() - .ToArrayAsync(cancellationToken); - tokens.Single(token => token.Subject == userId.ToString()).Status.ShouldBe(changed - ? OpenIddictConstants.Statuses.Revoked - : OpenIddictConstants.Statuses.Valid); - tokens.Single(token => token.Subject == otherId.ToString()).Status.ShouldBe(OpenIddictConstants.Statuses.Valid); - } - - [Theory(DisplayName = "User persistence should reject unknown roles before writes when role does not exist")] - [InlineData(false)] - [InlineData(true)] - public async Task SaveAsync_Should_RejectRolesBeforeWrites_When_RoleDoesNotExist(bool update) - { - var cancellationToken = TestContext.Current.CancellationToken; - await using var scope = Fixture.CreateScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var id = Guid.CreateVersion7(); - if (update) - { - var created = await repository.AddAsync(CreateUser(id, []), "StrongPassword1!", cancellationToken); - created.IsSuccess.ShouldBeTrue(); - } - var invalidUser = CreateUser(id, [Guid.CreateVersion7()]); - - var succeeded = update - ? (await repository.UpdateAsync(invalidUser, cancellationToken)).IsSuccess - : (await repository.AddAsync(invalidUser, "StrongPassword1!", cancellationToken)).IsSuccess; - - succeeded.ShouldBeFalse(); - (await dbContext.Set().CountAsync(cancellationToken)).ShouldBe(update ? 1 : 0); - (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); - } - - [Theory(DisplayName = "User updates should preserve state and tokens when identity validation fails")] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public async Task UpdateAsync_Should_PreserveStateAndTokens_When_IdentityValidationFails( - bool duplicateEmail, - bool disableBulk) - { - var cancellationToken = TestContext.Current.CancellationToken; - await Fixture.SeedRolesAsync(cancellationToken); - await using var scope = Fixture.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var repository = scope.ServiceProvider.GetRequiredService(); - var tokenManager = scope.ServiceProvider.GetRequiredService(); - var options = scope.ServiceProvider.GetRequiredService>() - .CurrentValue; - var originalDisableBulk = options.DisableBulkOperations; - options.DisableBulkOperations = disableBulk; - - try - { - var roles = await dbContext.Set().ToDictionaryAsync( - role => role.Name!, role => role.Id, cancellationToken); - var id = Guid.CreateVersion7(); - var created = await repository.AddAsync( - CreateUser(id, [roles["Player"]]), "StrongPassword1!", cancellationToken); - created.IsSuccess.ShouldBeTrue(); - var other = IntegrationTestData.CreateUser( - id: Guid.CreateVersion7(), - email: "occupied@example.com", - isConfirmed: true, - roleIds: [], - claims: [], - userName: "occupied", - status: UserStatus.Active); - (await repository.AddAsync(other, "StrongPassword1!", cancellationToken)).IsSuccess.ShouldBeTrue(); - var original = await dbContext.Set() - .AsNoTracking() - .SingleAsync(user => user.Id == id, cancellationToken); - await tokenManager.CreateAsync( - new OpenIddictTokenDescriptor - { - Subject = id.ToString(), - Status = OpenIddictConstants.Statuses.Valid, - Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken - }, - cancellationToken); - var updated = IntegrationTestData.CreateUser( - id: id, - email: duplicateEmail ? "occupied@example.com" : "hero@example.com", - isConfirmed: false, - roleIds: [roles["Developer"]], - claims: [("permission", "identity.users.read")], - userName: duplicateEmail ? "hero" : "occupied", - status: UserStatus.Active); - - var result = await repository.UpdateAsync(updated, cancellationToken); - - result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); - var persisted = await dbContext.Set() - .Include(user => user.Roles) - .Include(user => user.Claims) - .AsNoTracking() - .SingleAsync(user => user.Id == id, cancellationToken); - persisted.Email.ShouldBe(original.Email); - persisted.UserName.ShouldBe(original.UserName); - persisted.EmailConfirmed.ShouldBe(original.EmailConfirmed); - persisted.SecurityStamp.ShouldBe(original.SecurityStamp); - persisted.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(roles["Player"]); - persisted.Claims.ShouldBeEmpty(); - var token = await dbContext.Set>() - .AsNoTracking() - .SingleAsync(cancellationToken); - token.Status.ShouldBe(OpenIddictConstants.Statuses.Valid); - } - finally - { - options.DisableBulkOperations = originalDisableBulk; - } - } - - private static User CreateUser(Guid id, IReadOnlyCollection roleIds) => - IntegrationTestData.CreateUser( - id: id, - email: "hero@example.com", - isConfirmed: true, - roleIds: roleIds, - claims: [], - userName: "hero", - status: UserStatus.Active); -} diff --git a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UsersRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UsersRepositoryTests.cs index 1118b4dd..6a9acc17 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UsersRepositoryTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Users/Write/UsersRepositoryTests.cs @@ -2,10 +2,17 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.EntityFrameworkCore; +using OpenIddict.EntityFrameworkCore.Models; + +using PANiXiDA.TacticalHeroes.Identity.Domain.Users; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Abstractions; using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; +using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; using PANiXiDA.TacticalHeroes.Identity.IntegrationTests.Users; @@ -168,6 +175,196 @@ public async Task UnblockAsync_Should_ActivateUser_When_UserIsBlocked() (await ReadStatusAsync(userId, cancellationToken)).ShouldBe(UserStatus.Active.Name); } + [Theory(DisplayName = "User updates should synchronize roles and sessions when role assignments vary")] + [InlineData("same")] + [InlineData("replace")] + [InlineData("add")] + [InlineData("clear")] + public async Task UpdateAsync_Should_SynchronizeRolesAndSessions_When_RoleAssignmentsVary(string mode) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService(); + await Fixture.SeedRolesAsync(cancellationToken); + var roles = await dbContext.Set().ToDictionaryAsync( + role => role.Name!, role => role.Id, StringComparer.OrdinalIgnoreCase, cancellationToken); + var userId = Guid.CreateVersion7(); + var created = await repository.AddAsync( + CreateUser(userId, [roles["Developer"]]), "StrongPassword1!", cancellationToken); + created.IsSuccess.ShouldBeTrue(); + var originalStamp = (await dbContext.Set() + .SingleAsync(user => user.Id == userId, cancellationToken)).SecurityStamp; + var targetRoles = mode switch + { + "same" => new[] { roles["Developer"] }, + "replace" => [roles["Moderator"]], + "add" => [roles["Developer"], roles["Player"]], + _ => [] + }; + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var otherId = Guid.CreateVersion7(); + foreach (var subject in new[] { userId, otherId }) + { + await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = subject.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken + }, + cancellationToken); + } + + var result = await repository.UpdateAsync( + user: CreateUser(userId, targetRoles), + cancellationToken: cancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var user = await dbContext.Set() + .Include(item => item.Roles) + .AsNoTracking() + .SingleAsync(item => item.Id == userId, cancellationToken); + user.Roles.Select(role => role.RoleId).ShouldBe(targetRoles, ignoreOrder: true); + var changed = mode is "replace" or "add" or "clear"; + (user.SecurityStamp != originalStamp).ShouldBe(changed); + var tokens = await dbContext.Set>() + .AsNoTracking() + .ToArrayAsync(cancellationToken); + tokens.Single(token => token.Subject == userId.ToString()).Status.ShouldBe(changed + ? OpenIddictConstants.Statuses.Revoked + : OpenIddictConstants.Statuses.Valid); + tokens.Single(token => token.Subject == otherId.ToString()).Status.ShouldBe(OpenIddictConstants.Statuses.Valid); + } + + [Fact(DisplayName = "AddAsync should reject unknown roles before writes when role does not exist")] + public async Task AddAsync_Should_RejectRolesBeforeWrites_When_RoleDoesNotExist() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var id = Guid.CreateVersion7(); + var invalidUser = CreateUser(id, [Guid.CreateVersion7()]); + + var result = await repository.AddAsync(invalidUser, Password, cancellationToken); + + result.IsSuccess.ShouldBeFalse(); + (await dbContext.Set().CountAsync(cancellationToken)).ShouldBe(0); + (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); + } + + [Fact(DisplayName = "UpdateAsync should reject unknown roles before writes when role does not exist")] + public async Task UpdateAsync_Should_RejectRolesBeforeWrites_When_RoleDoesNotExist() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var scope = Fixture.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var id = Guid.CreateVersion7(); + var created = await repository.AddAsync(CreateUser(id, []), Password, cancellationToken); + created.IsSuccess.ShouldBeTrue(); + var invalidUser = CreateUser(id, [Guid.CreateVersion7()]); + + var result = await repository.UpdateAsync(invalidUser, cancellationToken); + + result.IsSuccess.ShouldBeFalse(); + (await dbContext.Set().CountAsync(cancellationToken)).ShouldBe(1); + (await dbContext.Set().AnyAsync(cancellationToken)).ShouldBeFalse(); + } + + [Theory(DisplayName = "User updates should preserve state and tokens when identity validation fails")] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task UpdateAsync_Should_PreserveStateAndTokens_When_IdentityValidationFails( + bool duplicateEmail, + bool disableBulk) + { + var cancellationToken = TestContext.Current.CancellationToken; + await Fixture.SeedRolesAsync(cancellationToken); + await using var scope = Fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService(); + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>() + .CurrentValue; + var originalDisableBulk = options.DisableBulkOperations; + options.DisableBulkOperations = disableBulk; + + try + { + var roles = await dbContext.Set().ToDictionaryAsync( + role => role.Name!, role => role.Id, cancellationToken); + var id = Guid.CreateVersion7(); + var created = await repository.AddAsync( + CreateUser(id, [roles["Player"]]), "StrongPassword1!", cancellationToken); + created.IsSuccess.ShouldBeTrue(); + var other = IntegrationTestData.CreateUser( + id: Guid.CreateVersion7(), + email: "occupied@example.com", + isConfirmed: true, + roleIds: [], + claims: [], + userName: "occupied", + status: UserStatus.Active); + (await repository.AddAsync(other, "StrongPassword1!", cancellationToken)).IsSuccess.ShouldBeTrue(); + var original = await dbContext.Set() + .AsNoTracking() + .SingleAsync(user => user.Id == id, cancellationToken); + await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = id.ToString(), + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.TokenTypeIdentifiers.AccessToken + }, + cancellationToken); + var updated = IntegrationTestData.CreateUser( + id: id, + email: duplicateEmail ? "occupied@example.com" : "hero@example.com", + isConfirmed: false, + roleIds: [roles["Developer"]], + claims: [("permission", "identity.users.read")], + userName: duplicateEmail ? "hero" : "occupied", + status: UserStatus.Active); + + var result = await repository.UpdateAsync(updated, cancellationToken); + + result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); + var persisted = await dbContext.Set() + .Include(user => user.Roles) + .Include(user => user.Claims) + .AsNoTracking() + .SingleAsync(user => user.Id == id, cancellationToken); + persisted.Email.ShouldBe(original.Email); + persisted.UserName.ShouldBe(original.UserName); + persisted.EmailConfirmed.ShouldBe(original.EmailConfirmed); + persisted.SecurityStamp.ShouldBe(original.SecurityStamp); + persisted.Roles.ShouldHaveSingleItem().RoleId.ShouldBe(roles["Player"]); + persisted.Claims.ShouldBeEmpty(); + var token = await dbContext.Set>() + .AsNoTracking() + .SingleAsync(cancellationToken); + token.Status.ShouldBe(OpenIddictConstants.Statuses.Valid); + } + finally + { + options.DisableBulkOperations = originalDisableBulk; + } + } + + private static User CreateUser(Guid id, IReadOnlyCollection roleIds) => + IntegrationTestData.CreateUser( + id: id, + email: "hero@example.com", + isConfirmed: true, + roleIds: roleIds, + claims: [], + userName: "hero", + status: UserStatus.Active); + private async Task AddUserAsync( string email, string userName,