diff --git a/README.md b/README.md index edf0f879..2cb3a2af 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,16 @@ 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 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 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..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,4 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Create; public sealed record CreateRoleCommand( string Name, + 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 e7a0fc73..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,18 +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, + 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 fcb60293..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,4 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.GetDetails; public sealed record RoleDetailsReadModel( Guid Id, string Name, + 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 ab086649..4fbd5462 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) : 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..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,4 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Application.Roles.Update; public sealed record UpdateRoleCommand( Guid Id, string Name, + 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 93ccffe1..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,18 +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, + defaultFlag: defaultFlagResult.Value, claims: claims); - return rolesRepository.UpdateAsync( + return await rolesRepository.UpdateAsync( role: role, cancellationToken: cancellationToken); } 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..990e83fe 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) : 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..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,4 +1,5 @@ using PANiXiDA.TacticalHeroes.Identity.Application.Claims; +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; @@ -32,6 +33,9 @@ public CreateUserCommandValidator() }) .SetValidator(new ClaimCatalogValidator()); + RuleFor(command => command.RoleIds) + .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 fc8b103e..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,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 = RoleId.Create(command.RoleIds); + var validationResult = Result.Combine(emailResult, userNameResult, statusResult, roleIdsResult); if (validationResult.IsFailure) { @@ -46,7 +48,7 @@ public Task> HandleAsync( userName: userNameResult.Value, status: statusResult.Value, confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed), - roleIds: [], + roleIds: roleIdsResult.Value, 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..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,7 +11,8 @@ public sealed record UserDetailsReadModel( bool IsConfirmed, string Status, string StatusDisplayName, - IReadOnlyCollection Claims) : IReadModel + IReadOnlyCollection Claims, + IReadOnlyCollection RoleIds) : IReadModel { public bool IsBlocked => string.Equals( Status, 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..2f2867b6 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) : 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..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,4 +1,5 @@ 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; @@ -33,6 +34,9 @@ public UpdateUserCommandValidator() }) .SetValidator(new ClaimCatalogValidator()); + RuleFor(command => command.RoleIds) + .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 6ea748f2..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,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 = RoleId.Create(command.RoleIds); + var validationResult = Result.Combine(idResult, emailResult, userNameResult, statusResult, roleIdsResult); if (validationResult.IsFailure) { @@ -47,7 +49,7 @@ public Task HandleAsync( userName: userNameResult.Value, status: statusResult.Value, confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed), - roleIds: [], + roleIds: roleIdsResult.Value, claims: claims); return usersRepository.UpdateAsync( 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 9a163c02..d0160f88 100644 --- a/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs +++ b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Domain/Roles/Role.cs @@ -10,22 +10,32 @@ public sealed class Role : AggregateRoot private Role( RoleId id, - RoleName name) + RoleName name, + RoleDefaultFlag defaultFlag) : base(id) { Name = name; + DefaultFlag = defaultFlag; } public RoleName Name { get; private set; } + public RoleDefaultFlag DefaultFlag { get; private set; } + + public bool IsDefault => DefaultFlag.Value; + public IReadOnlyCollection Claims => _claims.AsReadOnly(); public static Role Create( RoleId id, RoleName name, + RoleDefaultFlag defaultFlag, IEnumerable claims) { - var role = new Role(id: id, name: name); + var role = new Role( + id: id, + name: name, + defaultFlag: defaultFlag); foreach (var claim in claims) { 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/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/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 b36f1ede..6164878b 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,24 @@ public async Task> RegisterAsync( return Result.Failure(errors: validationResult.Errors); } - var user = User.Register(email: emailResult.Value, userName: userNameResult.Value); + var defaultRole = await roleManager.Roles + .SingleOrDefaultAsync(role => role.IsDefault, cancellationToken); + if (defaultRole is null) + { + return Result.Failure( + error: Error.NotFound(message: "Default role was not found.")); + } + + var roleIdResult = RoleId.Create(value: defaultRole.Id); + if (roleIdResult.IsFailure) + { + return Result.Failure(errors: roleIdResult.Errors); + } + + 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/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Core/IdentityWriteDbContext.cs index 01eddcee..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,6 +99,11 @@ 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(value: false); + builder.HasIndex(role => role.IsDefault) + .IsUnique() + .HasFilter("is_default"); + builder.HasMany(role => role.Users) .WithOne(user => user.Role) .HasForeignKey(user => user.RoleId) 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..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,6 +14,7 @@ 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!, 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 14a33255..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 @@ -34,6 +34,7 @@ public static void MapToDbModel( DateTime updatedAt) { dbModel.Name = role.Name.Value; + dbModel.IsDefault = role.IsDefault; dbModel.UpdatedAt = updatedAt; } @@ -54,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) { @@ -80,6 +82,7 @@ public static Result ToDomain(ApplicationRole role) return Result.Success(value: Role.Create( id: idResult.Value, name: nameResult.Value, + 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 d601f101..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,16 +7,36 @@ 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 { + 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) @@ -52,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, @@ -67,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(); @@ -85,6 +118,11 @@ public async Task DeleteAsync( return RoleNotFound(); } + if (applicationRole.IsDefault) + { + return DefaultRoleConflict(); + } + var roleResult = ApplicationRoleMapper.ToDomain(role: applicationRole); if (roleResult.IsFailure) @@ -92,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) @@ -99,16 +138,52 @@ 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(); } + private static Result DefaultRoleConflict() + { + return Result.Failure(error: Error.Conflict(message: "Unset the default role before deleting it.")); + } + 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/Read/GetDetails/UserDetailsReadModelMapper.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs index 2bf9bd48..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,6 +23,7 @@ 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..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 @@ -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, @@ -56,6 +63,13 @@ public async Task UpdateAsync( return UserNotFound(); } + var rolesResult = await ValidateRolesAsync(user, cancellationToken); + if (rolesResult.IsFailure) + { + return rolesResult; + } + + var rolesChanged = SyncRoles(applicationUser, user); ApplicationUserMapper.MapToDbModel( user: user, dbModel: applicationUser, @@ -64,18 +78,20 @@ public async Task UpdateAsync( applicationUser: applicationUser, user: user); - if (user.Status.IsBlocked) - { - 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) { return IdentityResultMapper.ToResult(result: identityResult); } + if (user.Status.IsBlocked || rolesChanged) + { + await RevokeAllTokensAsync(user.Id.Value, cancellationToken); + } + aggregateTracker.Track(user); return Result.Success(); @@ -164,6 +180,46 @@ 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.Where(role => !roleIds.Contains(role.RoleId)).ToArray()) + { + dbContext.Set().Remove(currentRole); + } + + foreach (var roleId in roleIds.Where(id => applicationUser.Roles.All(role => role.RoleId != id))) + { + 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/Roles/Create/CreateRoleRequest.cs b/src/Identity/PANiXiDA.TacticalHeroes.Identity.Presentation/Features/Roles/Create/CreateRoleRequest.cs index 49c62310..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,4 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Create; public sealed record CreateRoleRequest( string Name, + bool IsDefault, IReadOnlyCollection Claims); 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..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,4 +5,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.GetDetail public sealed record GetRoleDetailsResponse( Guid Id, string Name, + 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 be03c434..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 @@ -2,4 +2,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.GetList; public sealed record RoleListItemResponse( Guid Id, - string Name); + string Name, + 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 a0cf099a..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,4 +4,5 @@ namespace PANiXiDA.TacticalHeroes.Identity.Presentation.Features.Roles.Update; public sealed record UpdateRoleRequest( string Name, + bool IsDefault, IReadOnlyCollection Claims); 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..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 @@ -8,4 +8,5 @@ public sealed record CreateUserRequest( string Password, bool IsConfirmed, IReadOnlyCollection Claims, - string Status); + string Status, + 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 cde0e43f..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,4 +9,5 @@ public sealed record GetUserDetailsResponse( bool IsConfirmed, string Status, string StatusDisplayName, - IReadOnlyCollection Claims); + 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 902204f9..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 @@ -7,4 +7,5 @@ public sealed record UpdateUserRequest( string UserName, bool IsConfirmed, IReadOnlyCollection Claims, - string Status); + string Status, + IReadOnlyCollection RoleIds); 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/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..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; @@ -34,5 +37,34 @@ 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("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/Claims/ClaimAssignmentEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs deleted file mode 100644 index 406c9a9b..00000000 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Claims/ClaimAssignmentEndpointTests.cs +++ /dev/null @@ -1,125 +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).CreateAsync(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.CreateAsync(cancellationToken, new CreateRoleRequest("admin", update ? [] : claims)); - - if (update) - { - await client.UpdateAsync(role.Id, new UpdateRoleRequest("admin", 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), - 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("admin", [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 bdcb0404..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 @@ -1,5 +1,11 @@ +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; + +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.Create; @@ -12,10 +18,11 @@ 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 ", + false, [new Claim("permission", "compendium.heroes.update")])); var role = await RoleDatabaseTestHelper.FindAsync( Fixture, @@ -23,7 +30,130 @@ 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"); } + + [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"); + } + + [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/Delete/DeleteRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Delete/DeleteRoleEndpointTests.cs index 198b806f..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) @@ -8,7 +20,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( @@ -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/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 80f4c0f5..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 @@ -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(2); - response.Items.Select(role => role.Name).ShouldBe(["admin", "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..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 @@ -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", false, []); + 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", 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/Update/UpdateRoleEndpointTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Roles/Update/UpdateRoleEndpointTests.cs index 35273e22..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 @@ -1,6 +1,19 @@ +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; +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Roles.Update; public sealed class UpdateRoleEndpointTests(FunctionalTestFixture fixture) @@ -11,12 +24,13 @@ 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); await client.UpdateAsync( createdRole.Id, new UpdateRoleRequest( - "manager", + "CustomManager", + false, [new Claim("permission", "compendium.heroes.update")]), cancellationToken); var role = await RoleDatabaseTestHelper.FindAsync( @@ -25,7 +39,216 @@ [new Claim("permission", "compendium.heroes.update")]), cancellationToken); role.ShouldNotBeNull(); - role.Name.ShouldBe("manager"); + 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); + } + + [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 766bdbb6..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 @@ -1,6 +1,12 @@ +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; +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users.Create; public sealed class CreateUserEndpointTests(FunctionalTestFixture fixture) @@ -17,7 +23,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( @@ -32,4 +39,160 @@ [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); + } + + [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 48826677..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 @@ -1,6 +1,12 @@ +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; +using ApplicationClaimTypes = PANiXiDA.Core.Application.Authentication.ClaimTypes; + namespace PANiXiDA.TacticalHeroes.Identity.FunctionalTests.Presentation.Features.Users.Update; public sealed class UpdateUserEndpointTests(FunctionalTestFixture fixture) @@ -20,7 +26,8 @@ await client.UpdateAsync( "updated-hero", false, [new Claim("permission", "compendium.heroes.update")], - "Blocked"), + "Blocked", + RoleIds: []), cancellationToken); var user = await UserDatabaseTestHelper.FindAsync( Fixture, @@ -34,4 +41,213 @@ [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]); + } + + [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/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/UserDatabaseTestHelper.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserDatabaseTestHelper.cs index 476c86a7..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 @@ -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/UserRequestValidationTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/Features/Users/UserRequestValidationTests.cs deleted file mode 100644 index 43266312..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"] - : ["email", "userName", "isConfirmed", "claims", "status"]; - 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.FunctionalTests/Presentation/FunctionalTestFixture.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.FunctionalTests/Presentation/FunctionalTestFixture.cs index 87f5bf31..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,6 +100,12 @@ private void CreateCurrentClient() private async Task SeedIdentityProviderAsync(CancellationToken cancellationToken) { await using var scope = _factory.Services.CreateAsyncScope(); + 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/Services/UserCredentialsServiceTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/IdentityProvider/Services/UserCredentialsServiceTests.cs index 369c547a..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 @@ -32,6 +32,8 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr var service = scope.ServiceProvider.GetRequiredService(); var userManager = scope.ServiceProvider.GetRequiredService>(); + await Fixture.SeedRolesAsync(cancellationToken); + var result = await service.RegisterAsync( " REGISTER@Example.COM ", " registered-hero ", @@ -44,6 +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(["Player"]); var aggregateTracker = scope.ServiceProvider.GetRequiredService(); var trackedUser = aggregateTracker.GetAll() @@ -59,6 +62,27 @@ public async Task RegisterAsync_Should_PersistUnconfirmedUser_When_CredentialsAr domainEvent.ConfirmationToken.ShouldNotBeNullOrWhiteSpace(); } + [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; + 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)] @@ -84,6 +108,33 @@ 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 = "Player", + NormalizedName = "PLAYER", + IsDefault = true + }); + 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/Core/Migrations/SeedRolesAndClaimsTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs new file mode 100644 index 00000000..15c4e6b4 --- /dev/null +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Core/Migrations/SeedRolesAndClaimsTests.cs @@ -0,0 +1,90 @@ +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; + +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 default 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; + + 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/Read/RolesReadRepositoryTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Infrastructure/Persistence/Features/Roles/Read/RolesReadRepositoryTests.cs index bda25970..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 @@ -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..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 @@ -17,22 +17,30 @@ 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); } - [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(), - " ADMIN ", - [(" permission ", " heroes.manage "), ("permission", "heroes.manage")])); + " Developer ", + [(" permission ", " heroes.manage "), ("permission", "heroes.manage")]); + dbModel.IsDefault = isDefault; + + var result = ApplicationRoleMapper.ToDomain(dbModel); result.IsSuccess.ShouldBeTrue(); - result.Value.Name.Value.ShouldBe("admin"); + 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); 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..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; @@ -18,7 +23,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 +34,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 +43,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 +53,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 +69,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 +77,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 +103,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,13 +111,335 @@ public async Task AddAsync_Should_ReturnConflict_When_RoleNameAlreadyExists() var result = await repository.AddAsync( IntegrationTestData.CreateRole( id: Guid.CreateVersion7(), - name: "ADMIN", + name: "Developer", claims: []), cancellationToken); result.Errors.ShouldContain(error => error.Type == ErrorType.Conflict); } + [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 = 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(); + + 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) + .ToArrayAsync(cancellationToken); + defaults.ShouldHaveSingleItem().Id.ShouldBe(targetId); + previous.IsDefault.ShouldBeFalse(); + (await repository.DeleteAsync(previous.Id, cancellationToken)).IsSuccess.ShouldBeTrue(); + } + + [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 = 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 = await repository.UpdateAsync(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 = "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); + 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 = await repository.DeleteAsync(role.Id, 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"); + } + + [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); + } + + [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/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, 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 8756d967..2199d629 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.IntegrationTests/Roles/IntegrationTestData.cs @@ -10,11 +10,13 @@ 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, + 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))); 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..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("admin", [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 2d78d0cd..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 @@ -3,20 +3,25 @@ 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; 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.GetDefaultAsync(Arg.Any()).Returns(Result.Success(value: null)); repository.AddAsync( - Arg.Is(role => role.Name.Value == "admin" + 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 +31,14 @@ 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", isDefault, 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.Value == "developer" + && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), cancellationToken); @@ -41,22 +47,54 @@ 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, 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 d513f233..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, "admin", []); + 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 942d1576..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(), "admin")], + [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 472047be..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 @@ -14,7 +14,8 @@ public void Validate_Should_ReturnValidResult_When_CommandIsValid() var result = validator.Validate( new UpdateRoleCommand( Guid.CreateVersion7(), - "admin", + "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 db95e5bd..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 @@ -3,21 +3,27 @@ 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; 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")]; var repository = Substitute.For(); + repository.GetDefaultAsync(Arg.Any()).Returns(Result.Success(value: null)); repository.UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Value == "admin" + && role.Name.Value == "developer" + && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), Arg.Any()) @@ -26,14 +32,15 @@ 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", isDefault, claims), cancellationToken); result.IsSuccess.ShouldBeTrue(); await repository.Received(1).UpdateAsync( Arg.Is( role => role.Id.Value == roleId - && role.Name.Value == "admin" + && role.Name.Value == "developer" + && role.IsDefault == isDefault && role.Claims.Any( claim => claim.Type.Value == "permission" && claim.Value.Value == "heroes.manage")), cancellationToken); @@ -42,22 +49,75 @@ 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, 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/Application/Users/Create/CreateUserCommandValidatorTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Application/Users/Create/CreateUserCommandValidatorTests.cs index 7ff59681..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)); @@ -40,4 +48,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..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,11 +75,63 @@ public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_W "StrongPassword1!", false, [new Claim(claimType, claimValue)], - status), + status, + RoleIds: []), cancellationToken); result.IsFailure.ShouldBeTrue(); 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/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 2ffa58fc..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)); @@ -46,4 +48,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..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 @@ -34,7 +34,8 @@ public async Task HandleAsync_Should_ReturnSuccess_When_RepositorySucceeds() "hero", true, claims, - "Blocked"), + "Blocked", + RoleIds: []), cancellationToken); result.IsSuccess.ShouldBeTrue(); @@ -72,11 +73,62 @@ public async Task HandleAsync_Should_ReturnValidationFailuresWithoutPersisting_W userName, false, [new Claim(claimType, claimValue)], - status), + status, + RoleIds: []), cancellationToken); result.IsFailure.ShouldBeTrue(); 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()) + .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); + } + + [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(); + } } 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/Roles/RoleTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs index e90464c6..060734c5 100644 --- a/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs +++ b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/RoleTests.cs @@ -7,18 +7,22 @@ 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(" ADMIN ").Value, + RoleName.Create(" Developer ").Value, + RoleDefaultFlag.Create(isDefault).Value, [RoleClaim.Create(ClaimType.Create("permission").Value, ClaimValue.Create("heroes.manage").Value)]); result.Id.Value.ShouldBe(id); - result.Name.Value.ShouldBe("admin"); + 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"); @@ -29,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("admin").Value, claims); + var role = Role.Create( + RoleId.New(), RoleName.Create("Developer").Value, RoleDefaultFlag.Create(false).Value, claims); claims.Clear(); @@ -89,6 +94,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, 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/RoleNameTests.cs b/tests/Identity/PANiXiDA.TacticalHeroes.Identity.UnitTests/Domain/Roles/ValueObjects/RoleNameTests.cs index 3eb4144a..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 @@ -7,18 +7,19 @@ 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 "); + var result = RoleName.Create(" CustomManager "); result.IsSuccess.ShouldBeTrue(); - result.Value.Value.ShouldBe("admin"); + 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) + public void Create_Should_ReturnValidationFailure_When_RoleNameIsEmpty(string? value) { - var result = RoleName.Create(value); + var result = RoleName.Create(value!); result.ShouldHaveSingleError(ErrorType.Validation, "Role name cannot be empty.") .ShouldHaveField(nameof(RoleName)); @@ -38,10 +39,10 @@ public void Create_Should_ReturnValidationFailure_When_RoleNameIsTooLong() [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 roleName = RoleName.Create("CustomManager").Value; var result = roleName.ToString(); - result.ShouldBe("RoleName { Value = admin }"); + result.ShouldBe("RoleName { Value = custommanager }"); } } 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: []); } } 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..a6553d5d 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 ProjectReferences_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..fc3878a7 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. `ProjectReferences_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/"]