UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth #12
43
database/migrations/V009__activate_permisos_overrides.sql
Normal file
43
database/migrations/V009__activate_permisos_overrides.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- V009__activate_permisos_overrides.sql
|
||||
-- Activates Usuario.PermisosJson as explicit overrides {grant, deny} on top of role permissions.
|
||||
-- Idempotent: safe to run multiple times.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
|
||||
-- 1. Drop old default constraint if it exists (handles any previous shape)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
|
||||
PRINT 'Dropped DF_Usuario_Permisos.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. Re-add default constraint with canonical shape
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
ADD CONSTRAINT DF_Usuario_Permisos
|
||||
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
|
||||
PRINT 'Added DF_Usuario_Permisos with new shape {"grant":[],"deny":[]}.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Migrate legacy values to new canonical shape
|
||||
UPDATE dbo.Usuario
|
||||
SET PermisosJson = '{"grant":[],"deny":[]}'
|
||||
WHERE PermisosJson IN ('[]', '["*"]', '')
|
||||
OR PermisosJson IS NULL
|
||||
OR LTRIM(RTRIM(PermisosJson)) = '';
|
||||
|
||||
PRINT 'Migrated legacy PermisosJson rows to canonical shape.';
|
||||
GO
|
||||
@@ -1,26 +1,32 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization handler for <see cref="RequirePermissionAttribute"/>.
|
||||
/// Reads the "rol" claim from the authenticated user, queries <see cref="IRolPermisoRepository"/>
|
||||
/// for the role's assigned permissions, and succeeds if at least one matches (OR semantics).
|
||||
/// No caching — UDT-006 design decision D1: always authoritative from DB.
|
||||
/// UDT-009: Reads "rol" + "sub" claims, queries both IRolPermisoRepository
|
||||
/// and IUsuarioRepository, resolves effective permissions via PermisoResolver,
|
||||
/// and succeeds if at least one required permission matches (OR semantics).
|
||||
/// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3).
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationHandler
|
||||
: AuthorizationHandler<RequirePermissionAttribute>
|
||||
{
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||
private readonly IUsuarioRepository _usuarioRepo;
|
||||
private readonly ILogger<PermissionAuthorizationHandler> _logger;
|
||||
|
||||
public PermissionAuthorizationHandler(
|
||||
IRolPermisoRepository rolPermisoRepo,
|
||||
IUsuarioRepository usuarioRepo,
|
||||
ILogger<PermissionAuthorizationHandler> logger)
|
||||
{
|
||||
_rolPermisoRepo = rolPermisoRepo;
|
||||
_usuarioRepo = usuarioRepo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -28,13 +34,11 @@ public sealed class PermissionAuthorizationHandler
|
||||
AuthorizationHandlerContext context,
|
||||
RequirePermissionAttribute requirement)
|
||||
{
|
||||
// 1. Must be authenticated — defense-in-depth (AuthorizeAttribute already requires it)
|
||||
// 1. Must be authenticated — defense-in-depth
|
||||
if (context.User?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return; // implicit Fail — nothing Succeeded
|
||||
}
|
||||
return; // implicit Fail
|
||||
|
||||
// 2. Extract "rol" claim — JwtBearer is configured with RoleClaimType="rol"
|
||||
// 2. Extract "rol" claim
|
||||
var rolCodigo = context.User.FindFirst("rol")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(rolCodigo))
|
||||
{
|
||||
@@ -45,13 +49,32 @@ public sealed class PermissionAuthorizationHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Load permissions assigned to this role — no cache (UDT-006 D1)
|
||||
var permisos = await _rolPermisoRepo.GetByRolCodigoAsync(rolCodigo);
|
||||
var permisoCodes = permisos.Select(p => p.Codigo).ToHashSet(StringComparer.Ordinal);
|
||||
// 3. Extract "sub" claim — MapInboundClaims=false so it stays as "sub" (NOT NameIdentifier)
|
||||
var subClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
|
||||
?? context.User.FindFirst("sub")?.Value;
|
||||
|
||||
// 4. OR semantics — any single match is enough
|
||||
var matched = requirement.PermissionCodes
|
||||
.FirstOrDefault(code => permisoCodes.Contains(code));
|
||||
if (string.IsNullOrWhiteSpace(subClaim) || !int.TryParse(subClaim, out var userId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Authorization failed — token missing or non-numeric 'sub' claim for user {User}",
|
||||
context.User.Identity?.Name);
|
||||
context.Fail(new AuthorizationFailureReason(this, "missing_sub_claim"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Load role permissions — no cache (UDT-006 D1)
|
||||
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(rolCodigo);
|
||||
var rolPermisos = rolPermisoEntities.Select(p => p.Codigo);
|
||||
|
||||
// 5. Load user overrides — no cache (UDT-009 D3); null usuario → no overrides
|
||||
var usuario = await _usuarioRepo.GetByIdAsync(userId);
|
||||
var overrides = PermisosOverride.FromJson(usuario?.PermisosJson);
|
||||
|
||||
// 6. Resolve effective permissions
|
||||
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
|
||||
|
||||
// 7. OR semantics — any single match is enough
|
||||
var matched = requirement.PermissionCodes.FirstOrDefault(effective.Contains);
|
||||
|
||||
if (matched is not null)
|
||||
{
|
||||
@@ -59,11 +82,9 @@ public sealed class PermissionAuthorizationHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Stash required permission for ForbiddenProblemDetailsHandler (Batch 3)
|
||||
// 8. Stash required permission for ForbiddenProblemDetailsHandler
|
||||
if (context.Resource is HttpContext httpContext)
|
||||
{
|
||||
httpContext.Items["RequiredPermission"] = requirement.PermissionCodes[0];
|
||||
}
|
||||
|
||||
context.Fail(new AuthorizationFailureReason(this,
|
||||
$"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));
|
||||
|
||||
@@ -10,6 +10,7 @@ using SIGCM2.Application.Usuarios.Deactivate;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Application.Usuarios.List;
|
||||
using SIGCM2.Application.Usuarios.Reactivate;
|
||||
using SIGCM2.Application.Usuarios.Permisos;
|
||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||
using SIGCM2.Application.Usuarios.Update;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
@@ -225,10 +226,75 @@ public sealed class UsuariosController : ControllerBase
|
||||
var result = await _dispatcher.Send<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── UDT-009: Permisos endpoints ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets a usuario's role permissions, explicit grant/deny overrides, and computed effective set.
|
||||
/// Requires administracion:usuarios:gestionar.
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/permisos")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPermisos([FromRoute] int id)
|
||||
{
|
||||
var result = await _dispatcher.Send<GetUsuarioPermisosQuery, UsuarioPermisosDto>(
|
||||
new GetUsuarioPermisosQuery(id));
|
||||
return Ok(MapToPermisosResponse(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the grant/deny override sets for a usuario.
|
||||
/// Requires administracion:usuarios:gestionar.
|
||||
/// </summary>
|
||||
[HttpPut("{id:int}/permisos/overrides")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdatePermisosOverrides(
|
||||
[FromRoute] int id,
|
||||
[FromBody] UpdatePermisosOverridesRequest request)
|
||||
{
|
||||
var command = new UpdateUsuarioPermisosOverridesCommand(
|
||||
Id: id,
|
||||
Grant: request.Grant ?? [],
|
||||
Deny: request.Deny ?? []);
|
||||
|
||||
var result = await _dispatcher.Send<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>(command);
|
||||
return Ok(MapToPermisosResponse(result));
|
||||
}
|
||||
|
||||
private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto)
|
||||
=> new(
|
||||
RolPermisos: dto.RolPermisos,
|
||||
Overrides: new PermisosOverridesShape(dto.Grant, dto.Deny),
|
||||
Effective: dto.Effective);
|
||||
}
|
||||
|
||||
// ── request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>UDT-009: Response shape for permisos endpoints.</summary>
|
||||
public sealed record UsuarioPermisosResponse(
|
||||
IReadOnlyList<string> RolPermisos,
|
||||
PermisosOverridesShape Overrides,
|
||||
IReadOnlyList<string> Effective);
|
||||
|
||||
/// <summary>UDT-009: The grant/deny override shape nested in UsuarioPermisosResponse.</summary>
|
||||
public sealed record PermisosOverridesShape(
|
||||
IReadOnlyList<string> Grant,
|
||||
IReadOnlyList<string> Deny);
|
||||
|
||||
/// <summary>UDT-009: PUT permisos/overrides request body.</summary>
|
||||
public sealed record UpdatePermisosOverridesRequest(
|
||||
IReadOnlyList<string>? Grant,
|
||||
IReadOnlyList<string>? Deny);
|
||||
|
||||
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
|
||||
public sealed record CreateUsuarioRequest(
|
||||
string? Username,
|
||||
|
||||
@@ -169,6 +169,35 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// UDT-009: permiso override validation errors
|
||||
case InvalidPermisoCodesException ipce:
|
||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
{
|
||||
Type = "about:blank",
|
||||
Title = "invalid-permiso-codes",
|
||||
Status = 400,
|
||||
Extensions = { ["invalidCodes"] = ipce.InvalidCodes }
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case GrantDenyOverlapException gdoe:
|
||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
{
|
||||
Type = "about:blank",
|
||||
Title = "grant-deny-overlap",
|
||||
Status = 400,
|
||||
Extensions = { ["overlap"] = gdoe.Overlap }
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ValidationException validationEx:
|
||||
var errors = validationEx.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
|
||||
@@ -17,4 +17,7 @@ public interface IUsuarioRepository
|
||||
Task UpdateAsync(int id, UpdateUsuarioFields fields, DateTime fechaModificacion, CancellationToken ct = default);
|
||||
Task UpdatePasswordAsync(int id, string passwordHash, bool mustChangePassword, CancellationToken ct = default);
|
||||
Task<int> CountActiveAdminsAsync(CancellationToken ct = default);
|
||||
|
||||
// UDT-009
|
||||
Task UpdatePermisosJsonAsync(int id, string permisosJson, DateTime fechaModificacion, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
using SIGCM2.Domain.Security;
|
||||
@@ -75,10 +76,12 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
_logger.LogWarning(ex, "Failed to update UltimoLogin for usuario {Id} — login proceeds", usuario.Id);
|
||||
}
|
||||
|
||||
// UDT-006: permisos vienen de RolPermiso, no de Usuario.PermisosJson
|
||||
// Usuario.PermisosJson queda reservado para UDT-009 (overrides por usuario)
|
||||
var permisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol);
|
||||
var permisos = permisoEntities.Select(p => p.Codigo).ToArray();
|
||||
// UDT-009: permisos efectivos = (rol ∪ grant) \ deny via PermisoResolver
|
||||
var rolPermisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol);
|
||||
var rolPermisos = rolPermisoEntities.Select(p => p.Codigo);
|
||||
var overrides = PermisosOverride.FromJson(usuario.PermisosJson);
|
||||
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
|
||||
var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
||||
|
||||
return new LoginResponseDto(
|
||||
AccessToken: accessToken,
|
||||
|
||||
29
src/api/SIGCM2.Application/Common/PermisoResolver.cs
Normal file
29
src/api/SIGCM2.Application/Common/PermisoResolver.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Resolves effective permissions as (rolPermisos ∪ grant) \ deny.
|
||||
/// Static helper — no dependencies, pure algorithm, freely testable.
|
||||
/// </summary>
|
||||
public static class PermisoResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the effective permission set for a user.
|
||||
/// Algorithm: start with role permissions, add grant, remove deny.
|
||||
/// Deny always wins over grant (last operation). Idempotent on duplicates.
|
||||
/// Never throws.
|
||||
/// </summary>
|
||||
public static IReadOnlySet<string> Resolve(
|
||||
IEnumerable<string> rolPermisos,
|
||||
PermisosOverride overrides)
|
||||
{
|
||||
var set = new HashSet<string>(rolPermisos, StringComparer.Ordinal);
|
||||
|
||||
foreach (var g in overrides.Grant)
|
||||
set.Add(g);
|
||||
|
||||
foreach (var d in overrides.Deny)
|
||||
set.Remove(d);
|
||||
|
||||
return set;
|
||||
}
|
||||
}
|
||||
60
src/api/SIGCM2.Application/Common/PermisosOverride.cs
Normal file
60
src/api/SIGCM2.Application/Common/PermisosOverride.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Overrides explícitos sobre permisos heredados del rol.
|
||||
/// Shape: { "grant": [...], "deny": [...] }
|
||||
/// </summary>
|
||||
public sealed record PermisosOverride(
|
||||
[property: JsonPropertyName("grant")] IReadOnlyList<string> Grant,
|
||||
[property: JsonPropertyName("deny")] IReadOnlyList<string> Deny)
|
||||
{
|
||||
/// <summary>No overrides — empty grant and deny.</summary>
|
||||
public static readonly PermisosOverride Empty =
|
||||
new(Array.Empty<string>(), Array.Empty<string>());
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses <paramref name="json"/> tolerantly:
|
||||
/// - null / "" / whitespace → Empty
|
||||
/// - starts with '[' (legacy '[]' or '["*"]') → Empty (backward compat)
|
||||
/// - valid JSON object with grant/deny → parsed record
|
||||
/// - malformed or wrong-shape JSON → Empty (tolerant in runtime)
|
||||
/// </summary>
|
||||
public static PermisosOverride FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Empty;
|
||||
|
||||
var trimmed = json.Trim();
|
||||
|
||||
// Legacy: '[]' or '["*"]' — array shape, treat as no overrides
|
||||
if (trimmed.StartsWith('['))
|
||||
return Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<PermisosOverride>(trimmed, Options);
|
||||
if (parsed is null)
|
||||
return Empty;
|
||||
|
||||
return new PermisosOverride(
|
||||
parsed.Grant ?? Array.Empty<string>(),
|
||||
parsed.Deny ?? Array.Empty<string>());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Tolerant: malformed JSON → Empty (protects authorization handler)
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes to canonical JSON shape.</summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Application.Usuarios.List;
|
||||
using SIGCM2.Application.Usuarios.Reactivate;
|
||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||
using SIGCM2.Application.Usuarios.Permisos;
|
||||
using SIGCM2.Application.Usuarios.Update;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
@@ -57,6 +58,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<ChangeMyPasswordCommand, Unit>, ChangeMyPasswordCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>, ResetUsuarioPasswordCommandHandler>();
|
||||
|
||||
// Usuarios/Permisos (UDT-009)
|
||||
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||
|
||||
/// <summary>UDT-009: Query to get a user's role permissions, overrides, and effective set.</summary>
|
||||
public sealed record GetUsuarioPermisosQuery(int Id);
|
||||
@@ -0,0 +1,51 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Handles GET /api/v1/users/{id}/permisos.
|
||||
/// Resolves role permissions + overrides + effective set.
|
||||
/// </summary>
|
||||
public sealed class GetUsuarioPermisosQueryHandler
|
||||
: ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _usuarioRepo;
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||
|
||||
public GetUsuarioPermisosQueryHandler(
|
||||
IUsuarioRepository usuarioRepo,
|
||||
IRolPermisoRepository rolPermisoRepo)
|
||||
{
|
||||
_usuarioRepo = usuarioRepo;
|
||||
_rolPermisoRepo = rolPermisoRepo;
|
||||
}
|
||||
|
||||
public async Task<UsuarioPermisosDto> Handle(GetUsuarioPermisosQuery query)
|
||||
{
|
||||
var usuario = await _usuarioRepo.GetByIdAsync(query.Id)
|
||||
?? throw new UsuarioNotFoundException(query.Id);
|
||||
|
||||
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(usuario.Rol);
|
||||
var rolPermisos = rolPermisoEntities
|
||||
.Select(p => p.Codigo)
|
||||
.OrderBy(c => c, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var overrides = PermisosOverride.FromJson(usuario.PermisosJson);
|
||||
|
||||
var effective = PermisoResolver.Resolve(rolPermisos, overrides)
|
||||
.OrderBy(c => c, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new UsuarioPermisosDto(
|
||||
UsuarioId: usuario.Id,
|
||||
Rol: usuario.Rol,
|
||||
RolPermisos: rolPermisos,
|
||||
Grant: overrides.Grant,
|
||||
Deny: overrides.Deny,
|
||||
Effective: effective);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||
|
||||
/// <summary>UDT-009: Command to replace the grant/deny override sets for a usuario.</summary>
|
||||
public sealed record UpdateUsuarioPermisosOverridesCommand(
|
||||
int Id,
|
||||
IReadOnlyList<string> Grant,
|
||||
IReadOnlyList<string> Deny);
|
||||
@@ -0,0 +1,81 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Handles PUT /api/v1/users/{id}/permisos/overrides.
|
||||
/// Validates overlap and catalog existence, persists new overrides, returns updated effective set.
|
||||
/// </summary>
|
||||
public sealed class UpdateUsuarioPermisosOverridesCommandHandler
|
||||
: ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _usuarioRepo;
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||
private readonly IPermisoRepository _permisoRepo;
|
||||
|
||||
public UpdateUsuarioPermisosOverridesCommandHandler(
|
||||
IUsuarioRepository usuarioRepo,
|
||||
IRolPermisoRepository rolPermisoRepo,
|
||||
IPermisoRepository permisoRepo)
|
||||
{
|
||||
_usuarioRepo = usuarioRepo;
|
||||
_rolPermisoRepo = rolPermisoRepo;
|
||||
_permisoRepo = permisoRepo;
|
||||
}
|
||||
|
||||
public async Task<UsuarioPermisosDto> Handle(UpdateUsuarioPermisosOverridesCommand command)
|
||||
{
|
||||
var grant = command.Grant ?? [];
|
||||
var deny = command.Deny ?? [];
|
||||
|
||||
// 1. Overlap check — grant ∩ deny → 400
|
||||
var overlap = grant.Intersect(deny, StringComparer.Ordinal).ToArray();
|
||||
if (overlap.Length > 0)
|
||||
throw new GrantDenyOverlapException(overlap);
|
||||
|
||||
// 2. Catalog existence check
|
||||
var allCodes = grant.Concat(deny).Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (allCodes.Length > 0)
|
||||
{
|
||||
var existentes = await _permisoRepo.GetByCodigosAsync(allCodes);
|
||||
var existSet = existentes.Select(p => p.Codigo).ToHashSet(StringComparer.Ordinal);
|
||||
var faltantes = allCodes.Where(c => !existSet.Contains(c)).ToArray();
|
||||
if (faltantes.Length > 0)
|
||||
throw new InvalidPermisoCodesException(faltantes);
|
||||
}
|
||||
|
||||
// 3. Load usuario
|
||||
var usuario = await _usuarioRepo.GetByIdAsync(command.Id)
|
||||
?? throw new UsuarioNotFoundException(command.Id);
|
||||
|
||||
// 4. Persist — use WithPermisosJson to get updated FechaModificacion
|
||||
var newOverrides = new PermisosOverride(grant, deny);
|
||||
var updated = usuario.WithPermisosJson(newOverrides.ToJson());
|
||||
await _usuarioRepo.UpdatePermisosJsonAsync(
|
||||
updated.Id,
|
||||
updated.PermisosJson,
|
||||
updated.FechaModificacion!.Value);
|
||||
|
||||
// 5. Return updated effective set
|
||||
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(updated.Rol);
|
||||
var rolPermisos = rolPermisoEntities
|
||||
.Select(p => p.Codigo)
|
||||
.OrderBy(c => c, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var effective = PermisoResolver.Resolve(rolPermisos, newOverrides)
|
||||
.OrderBy(c => c, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new UsuarioPermisosDto(
|
||||
UsuarioId: updated.Id,
|
||||
Rol: updated.Rol,
|
||||
RolPermisos: rolPermisos,
|
||||
Grant: grant,
|
||||
Deny: deny,
|
||||
Effective: effective);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Response DTO for user permissions.
|
||||
/// Contains role permissions, explicit overrides, and computed effective permissions.
|
||||
/// </summary>
|
||||
public sealed record UsuarioPermisosDto(
|
||||
int UsuarioId,
|
||||
string Rol,
|
||||
IReadOnlyList<string> RolPermisos,
|
||||
IReadOnlyList<string> Grant,
|
||||
IReadOnlyList<string> Deny,
|
||||
IReadOnlyList<string> Effective);
|
||||
@@ -47,7 +47,7 @@ public sealed class Usuario
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating a new user (no Id — DB assigns via IDENTITY).
|
||||
/// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false.
|
||||
/// Defaults: Activo=true, PermisosJson={"grant":[],"deny":[]}, MustChangePassword=false.
|
||||
/// </summary>
|
||||
public static Usuario ForCreation(
|
||||
string username,
|
||||
@@ -65,7 +65,7 @@ public sealed class Usuario
|
||||
apellido: apellido,
|
||||
email: email,
|
||||
rol: rol,
|
||||
permisosJson: "[]",
|
||||
permisosJson: """{"grant":[],"deny":[]}""",
|
||||
activo: true,
|
||||
fechaModificacion: null,
|
||||
ultimoLogin: null,
|
||||
@@ -131,6 +131,26 @@ public sealed class Usuario
|
||||
ultimoLogin: UltimoLogin,
|
||||
mustChangePassword: value);
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Returns a new instance with PermisosJson replaced.
|
||||
/// Sets FechaModificacion = UtcNow.
|
||||
/// Accepts raw JSON string so Domain stays free of Application dependencies.
|
||||
/// </summary>
|
||||
public Usuario WithPermisosJson(string permisosJson)
|
||||
=> new(
|
||||
id: Id,
|
||||
username: Username,
|
||||
passwordHash: PasswordHash,
|
||||
nombre: Nombre,
|
||||
apellido: Apellido,
|
||||
email: Email,
|
||||
rol: Rol,
|
||||
permisosJson: permisosJson,
|
||||
activo: Activo,
|
||||
fechaModificacion: DateTime.UtcNow,
|
||||
ultimoLogin: UltimoLogin,
|
||||
mustChangePassword: MustChangePassword);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new instance with only UltimoLogin updated.
|
||||
/// Does NOT touch FechaModificacion.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Thrown when the same code appears in both grant and deny arrays.
|
||||
/// Maps to 400 { title: "grant-deny-overlap", overlap: [...] }.
|
||||
/// </summary>
|
||||
public sealed class GrantDenyOverlapException : Exception
|
||||
{
|
||||
public IReadOnlyList<string> Overlap { get; }
|
||||
|
||||
public GrantDenyOverlapException(IReadOnlyList<string> overlap)
|
||||
: base($"Los siguientes códigos aparecen en grant y deny simultáneamente: {string.Join(", ", overlap)}")
|
||||
{
|
||||
Overlap = overlap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Thrown when grant or deny arrays contain codes not in the Permiso catalog.
|
||||
/// Maps to 400 { title: "invalid-permiso-codes", invalidCodes: [...] }.
|
||||
/// </summary>
|
||||
public sealed class InvalidPermisoCodesException : Exception
|
||||
{
|
||||
public IReadOnlyList<string> InvalidCodes { get; }
|
||||
|
||||
public InvalidPermisoCodesException(IReadOnlyList<string> codes)
|
||||
: base($"Códigos de permiso inexistentes en el catálogo: {string.Join(", ", codes)}")
|
||||
{
|
||||
InvalidCodes = codes;
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,27 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
||||
return await connection.ExecuteScalarAsync<int>(sql);
|
||||
}
|
||||
|
||||
// UDT-009 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task UpdatePermisosJsonAsync(int id, string permisosJson, DateTime fechaModificacion, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE dbo.Usuario
|
||||
SET PermisosJson = @PermisosJson,
|
||||
FechaModificacion = @FechaModificacion
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
PermisosJson = permisosJson,
|
||||
FechaModificacion = fechaModificacion,
|
||||
Id = id
|
||||
});
|
||||
}
|
||||
|
||||
// ── mapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static Usuario MapRow(UsuarioRow row)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
@@ -44,13 +43,17 @@ public sealed class JwtService : IJwtService
|
||||
return principal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Generates an access token with minimal claims.
|
||||
/// Claim 'permisos' has been removed — authorization handler resolves permissions
|
||||
/// from DB per-request using IUsuarioRepository + PermisoResolver.
|
||||
/// Token claims: sub, jti, name, rol (+ standard iat/exp/nbf).
|
||||
/// </summary>
|
||||
public string GenerateAccessToken(Usuario usuario)
|
||||
{
|
||||
var signingKey = new RsaSecurityKey(_rsa);
|
||||
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
|
||||
|
||||
var permisos = DeserializePermisos(usuario.PermisosJson);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, usuario.Id.ToString()),
|
||||
@@ -59,10 +62,6 @@ public sealed class JwtService : IJwtService
|
||||
new("rol", usuario.Rol),
|
||||
};
|
||||
|
||||
// Add each permission as a separate claim
|
||||
foreach (var permiso in permisos)
|
||||
claims.Add(new Claim("permisos", permiso));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
@@ -78,16 +77,4 @@ public sealed class JwtService : IJwtService
|
||||
var token = handler.CreateToken(descriptor);
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
|
||||
private static string[] DeserializePermisos(string permisosJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<string[]>(permisosJson) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
87
src/web/package-lock.json
generated
87
src/web/package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "1.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -2636,6 +2637,92 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "1.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'sonner'
|
||||
import { AppRoutes } from './router'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -15,6 +16,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
<Toaster richColors closeButton position="top-right" />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
53
src/web/src/components/ui/tabs.tsx
Normal file
53
src/web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
7
src/web/src/features/users/api/getUserPermisos.ts
Normal file
7
src/web/src/features/users/api/getUserPermisos.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UsuarioPermisos } from '../types'
|
||||
|
||||
export async function getUserPermisos(id: number): Promise<UsuarioPermisos> {
|
||||
const response = await axiosClient.get<UsuarioPermisos>(`/api/v1/users/${id}/permisos`)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UsuarioPermisos, UpdatePermisosOverridesPayload } from '../types'
|
||||
|
||||
export async function updateUserPermisosOverrides(
|
||||
id: number,
|
||||
payload: UpdatePermisosOverridesPayload,
|
||||
): Promise<UsuarioPermisos> {
|
||||
const response = await axiosClient.put<UsuarioPermisos>(
|
||||
`/api/v1/users/${id}/permisos/overrides`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
201
src/web/src/features/users/components/PermisosEditor.tsx
Normal file
201
src/web/src/features/users/components/PermisosEditor.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { toast } from 'sonner'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUserPermisos } from '../hooks/useUserPermisos'
|
||||
import { useUpdateUserPermisosOverrides } from '../hooks/useUpdateUserPermisosOverrides'
|
||||
import { usePermisos } from '@/features/permisos/hooks/usePermisos'
|
||||
import type { PermisoOverrideState } from '../types'
|
||||
import type { PermisoDto } from '@/features/permisos/api/types'
|
||||
|
||||
interface PermisosEditorProps {
|
||||
userId: number
|
||||
}
|
||||
|
||||
function groupByModulo(permisos: PermisoDto[]): Map<string, PermisoDto[]> {
|
||||
const map = new Map<string, PermisoDto[]>()
|
||||
for (const p of permisos) {
|
||||
const modulo = p.codigo.split(':')[0] ?? p.modulo
|
||||
if (!map.has(modulo)) map.set(modulo, [])
|
||||
map.get(modulo)!.push(p)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function resolveErrorMessage(err: unknown): string {
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { title?: string; invalidCodes?: string[]; overlap?: string[] }
|
||||
if (data.title === 'invalid-permiso-codes') {
|
||||
const codes = data.invalidCodes?.join(', ') ?? ''
|
||||
return `Códigos de permiso inválidos: ${codes}`
|
||||
}
|
||||
if (data.title === 'grant-deny-overlap') {
|
||||
const codes = data.overlap?.join(', ') ?? ''
|
||||
return `Los siguientes permisos están en grant y deny al mismo tiempo: ${codes}`
|
||||
}
|
||||
return 'No se pudieron guardar los cambios.'
|
||||
}
|
||||
return 'No se pudieron guardar los cambios.'
|
||||
}
|
||||
|
||||
export function PermisosEditor({ userId }: PermisosEditorProps) {
|
||||
const { data: permisoData, isLoading: loadingPermisos } = useUserPermisos(userId)
|
||||
const { data: catalogo, isLoading: loadingCatalogo } = usePermisos()
|
||||
const mutation = useUpdateUserPermisosOverrides(userId)
|
||||
|
||||
// Map<codigopermiso, PermisoOverrideState>
|
||||
const [states, setStates] = useState<Map<string, PermisoOverrideState>>(new Map())
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
// Initialize state from loaded data
|
||||
useEffect(() => {
|
||||
if (!permisoData) return
|
||||
const map = new Map<string, PermisoOverrideState>()
|
||||
// Start all known codes as 'heredado'
|
||||
for (const c of permisoData.rolPermisos) map.set(c, 'heredado')
|
||||
// Apply grant overrides
|
||||
for (const c of permisoData.overrides.grant) map.set(c, 'concedido')
|
||||
// Apply deny overrides
|
||||
for (const c of permisoData.overrides.deny) map.set(c, 'denegado')
|
||||
setStates(map)
|
||||
setSaveError(null)
|
||||
}, [permisoData])
|
||||
|
||||
if (loadingPermisos || loadingCatalogo) {
|
||||
return <p className="text-sm text-muted-foreground">Cargando permisos...</p>
|
||||
}
|
||||
|
||||
if (!permisoData || !catalogo) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>No se pudieron cargar los permisos.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
// Build complete set of all relevant permission codes from catalog
|
||||
// Filter catalog to only show permisos that appear in rolPermisos, grant, deny, or all catalog
|
||||
const allCodes = new Set([
|
||||
...permisoData.rolPermisos,
|
||||
...permisoData.overrides.grant,
|
||||
...permisoData.overrides.deny,
|
||||
])
|
||||
|
||||
// Use catalog for grouping and names, showing all permisos known plus any from overrides
|
||||
const relevantPermisos = catalogo.filter(
|
||||
(p) => allCodes.has(p.codigo) || catalogo.length <= 30,
|
||||
)
|
||||
|
||||
const grupos = groupByModulo(relevantPermisos)
|
||||
|
||||
function getState(codigo: string): PermisoOverrideState {
|
||||
return states.get(codigo) ?? 'heredado'
|
||||
}
|
||||
|
||||
function setState(codigo: string, state: PermisoOverrideState) {
|
||||
setSaveError(null)
|
||||
setStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(codigo, state)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const grant: string[] = []
|
||||
const deny: string[] = []
|
||||
|
||||
for (const [codigo, state] of states.entries()) {
|
||||
if (state === 'concedido') grant.push(codigo)
|
||||
else if (state === 'denegado') deny.push(codigo)
|
||||
}
|
||||
|
||||
mutation.mutate(
|
||||
{ grant, deny },
|
||||
{
|
||||
onError: (err) => {
|
||||
const msg = resolveErrorMessage(err)
|
||||
setSaveError(msg)
|
||||
toast.error(msg)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveError(null)
|
||||
toast.success('Permisos actualizados correctamente')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{saveError && (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{saveError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{Array.from(grupos.entries()).map(([modulo, permisos]) => (
|
||||
<section key={modulo}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3 capitalize">
|
||||
{modulo}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{permisos.map((p) => {
|
||||
const currentState = getState(p.codigo)
|
||||
return (
|
||||
<div
|
||||
key={p.codigo}
|
||||
data-testid={`permiso-row-${p.codigo}`}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col min-w-0 mr-4">
|
||||
<span className="text-sm font-medium">{p.nombre}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground/70">{p.codigo}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{(['heredado', 'concedido', 'denegado'] as PermisoOverrideState[]).map(
|
||||
(state) => (
|
||||
<button
|
||||
key={state}
|
||||
type="button"
|
||||
role="button"
|
||||
aria-label={state.charAt(0).toUpperCase() + state.slice(1)}
|
||||
aria-pressed={currentState === state}
|
||||
onClick={() => setState(p.codigo, state)}
|
||||
className={`
|
||||
px-2 py-1 rounded text-xs font-medium transition-colors capitalize
|
||||
${
|
||||
currentState === state
|
||||
? state === 'heredado'
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: state === 'concedido'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-accent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{state}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateUserPermisosOverrides } from '../api/updateUserPermisosOverrides'
|
||||
import type { UpdatePermisosOverridesPayload, UsuarioPermisos } from '../types'
|
||||
import { userPermisosQueryKey } from './useUserPermisos'
|
||||
import { userQueryKey } from './useUser'
|
||||
|
||||
export function useUpdateUserPermisosOverrides(userId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<UsuarioPermisos, Error, UpdatePermisosOverridesPayload>({
|
||||
mutationFn: (payload) => updateUserPermisosOverrides(userId, payload),
|
||||
onSuccess: () => {
|
||||
// Invalidate the specific user's permisos query
|
||||
queryClient.invalidateQueries({ queryKey: userPermisosQueryKey(userId) })
|
||||
// Invalidate the user detail query (in case effective permisos affect UI elsewhere)
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKey(userId) })
|
||||
// Invalidate the users list
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
15
src/web/src/features/users/hooks/useUserPermisos.ts
Normal file
15
src/web/src/features/users/hooks/useUserPermisos.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getUserPermisos } from '../api/getUserPermisos'
|
||||
import type { UsuarioPermisos } from '../types'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
|
||||
export const userPermisosQueryKey = (id: number) => ['users', id, 'permisos'] as const
|
||||
|
||||
export function useUserPermisos(id: number): UseQueryResult<UsuarioPermisos> {
|
||||
return useQuery({
|
||||
queryKey: userPermisosQueryKey(id),
|
||||
queryFn: () => getUserPermisos(id),
|
||||
staleTime: 15_000,
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import { useUser } from '../hooks/useUser'
|
||||
import { useUpdateUser } from '../hooks/useUpdateUser'
|
||||
import { ResetPasswordModal } from '../components/ResetPasswordModal'
|
||||
import { PermisosEditor } from '../components/PermisosEditor'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const editSchema = z.object({
|
||||
@@ -111,12 +113,14 @@ export function UserEditPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const isSelf = loggedUserId === userId
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Editar Usuario</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{loggedUserId !== userId && <ResetPasswordModal userId={userId} />}
|
||||
{!isSelf && <ResetPasswordModal userId={userId} />}
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/usuarios')}>
|
||||
Volver
|
||||
</Button>
|
||||
@@ -129,6 +133,15 @@ export function UserEditPage() {
|
||||
<p className="text-sm font-mono bg-muted rounded px-3 py-2">{user.username}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="perfil">
|
||||
<TabsList>
|
||||
<TabsTrigger value="perfil">Perfil</TabsTrigger>
|
||||
<TabsTrigger value="permisos" disabled={isSelf}>
|
||||
Permisos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="perfil">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||
{backendError && (
|
||||
@@ -228,6 +241,12 @@ export function UserEditPage() {
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="permisos">
|
||||
<PermisosEditor userId={userId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,3 +46,21 @@ export interface UpdateUserPayload {
|
||||
rol: string
|
||||
activo: boolean
|
||||
}
|
||||
|
||||
// UDT-009 — Permisos overrides per-user types
|
||||
|
||||
export interface UsuarioPermisos {
|
||||
rolPermisos: string[]
|
||||
overrides: {
|
||||
grant: string[]
|
||||
deny: string[]
|
||||
}
|
||||
effective: string[]
|
||||
}
|
||||
|
||||
export interface UpdatePermisosOverridesPayload {
|
||||
grant: string[]
|
||||
deny: string[]
|
||||
}
|
||||
|
||||
export type PermisoOverrideState = 'heredado' | 'concedido' | 'denegado'
|
||||
|
||||
197
src/web/src/tests/features/users/PermisosEditor.test.tsx
Normal file
197
src/web/src/tests/features/users/PermisosEditor.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { PermisosEditor } from '../../../features/users/components/PermisosEditor'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
// Catalog of ALL known permissions (from /api/v1/permisos)
|
||||
const catalogoPermisos = [
|
||||
{ id: 1, codigo: 'ventas:contado:crear', nombre: 'Crear venta contado', descripcion: null, modulo: 'ventas' },
|
||||
{ id: 2, codigo: 'ventas:contado:cobrar', nombre: 'Cobrar venta contado', descripcion: null, modulo: 'ventas' },
|
||||
{ id: 3, codigo: 'textos:editar', nombre: 'Editar textos', descripcion: null, modulo: 'textos' },
|
||||
]
|
||||
|
||||
// User permisos — from /api/v1/users/42/permisos
|
||||
const mockUsuarioPermisos = {
|
||||
rolPermisos: ['ventas:contado:crear', 'ventas:contado:cobrar'],
|
||||
overrides: {
|
||||
grant: ['textos:editar'],
|
||||
deny: ['ventas:contado:cobrar'],
|
||||
},
|
||||
effective: ['ventas:contado:crear', 'textos:editar'],
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderEditor(userId = 42) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<PermisosEditor userId={userId} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function setupHandlers() {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||
)
|
||||
}
|
||||
|
||||
describe('PermisosEditor', () => {
|
||||
it('calls GET /api/v1/users/:id/permisos on mount', async () => {
|
||||
let getPermisosCallCount = 0
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/users/42/permisos`, () => {
|
||||
getPermisosCallCount++
|
||||
return HttpResponse.json(mockUsuarioPermisos)
|
||||
}),
|
||||
)
|
||||
|
||||
renderEditor(42)
|
||||
|
||||
await waitFor(() => expect(getPermisosCallCount).toBe(1))
|
||||
})
|
||||
|
||||
it('renders permissions grouped by module', async () => {
|
||||
setupHandlers()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ventas')).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText('textos')).toBeInTheDocument()
|
||||
expect(screen.getByText('Crear venta contado')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument()
|
||||
expect(screen.getByText('Editar textos')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Heredado state for permissions in role but not in grant or deny', async () => {
|
||||
setupHandlers()
|
||||
renderEditor()
|
||||
|
||||
// ventas:contado:crear is in rolPermisos, not in grant, not in deny
|
||||
// expect a button/element indicating "Heredado" is active for that permission
|
||||
await waitFor(() => expect(screen.getByText('Crear venta contado')).toBeInTheDocument())
|
||||
|
||||
// Should have a "Heredado" indicator active for ventas:contado:crear
|
||||
// We look for the specific row container and check the selected state
|
||||
const crearRow = screen.getByTestId('permiso-row-ventas:contado:crear')
|
||||
expect(within(crearRow).getByRole('button', { name: /heredado/i })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Concedido state for permissions in grant', async () => {
|
||||
setupHandlers()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Editar textos')).toBeInTheDocument())
|
||||
|
||||
const editarRow = screen.getByTestId('permiso-row-textos:editar')
|
||||
expect(within(editarRow).getByRole('button', { name: /concedido/i })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Denegado state for permissions in deny', async () => {
|
||||
setupHandlers()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument())
|
||||
|
||||
const cobrarRow = screen.getByTestId('permiso-row-ventas:contado:cobrar')
|
||||
expect(within(cobrarRow).getByRole('button', { name: /denegado/i })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('Guardar button calls PUT with correct { grant, deny } body', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockUsuarioPermisos)
|
||||
}),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||
|
||||
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||
|
||||
await waitFor(() => expect(capturedBody).not.toBeNull())
|
||||
// Initial state: grant=['textos:editar'], deny=['ventas:contado:cobrar']
|
||||
expect(capturedBody).toMatchObject({
|
||||
grant: expect.arrayContaining(['textos:editar']),
|
||||
deny: expect.arrayContaining(['ventas:contado:cobrar']),
|
||||
})
|
||||
})
|
||||
|
||||
it('shows alert on 400 invalid-permiso-codes error', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
|
||||
HttpResponse.json(
|
||||
{ title: 'invalid-permiso-codes', status: 400, invalidCodes: ['fake:codigo'] },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows alert on 400 grant-deny-overlap error', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
|
||||
HttpResponse.json(
|
||||
{ title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -162,4 +162,40 @@ describe('UserEditPage', () => {
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: /resetear contraseña/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FP-01: tabs Perfil and Permisos visible when editing another user
|
||||
it('shows tabs "Perfil" and "Permisos" when editing another user', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||
http.get(`${API_URL}/api/v1/users/5/permisos`, () =>
|
||||
HttpResponse.json({
|
||||
usuarioId: 5, rol: 'cajero', rolPermisos: [], grant: [], deny: [], effective: [],
|
||||
}),
|
||||
),
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json([])),
|
||||
)
|
||||
|
||||
renderEditPage(5)
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByRole('tab', { name: /perfil/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /permisos/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FP-10: self-edit — tab Permisos is disabled
|
||||
it('disables tab "Permisos" when editing own profile (self-edit)', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/1`, () =>
|
||||
HttpResponse.json({ ...mockUserDetail, id: 1, username: 'admin' }),
|
||||
),
|
||||
)
|
||||
|
||||
renderEditPage(1)
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||
|
||||
const permisosTab = screen.getByRole('tab', { name: /permisos/i })
|
||||
expect(permisosTab).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
48
src/web/src/tests/features/users/getUserPermisos.test.ts
Normal file
48
src/web/src/tests/features/users/getUserPermisos.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { getUserPermisos } from '../../../features/users/api/getUserPermisos'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockUsuarioPermisos = {
|
||||
rolPermisos: ['ventas:contado:crear', 'ventas:contado:cobrar'],
|
||||
overrides: {
|
||||
grant: ['textos:editar'],
|
||||
deny: ['ventas:contado:cobrar'],
|
||||
},
|
||||
effective: ['ventas:contado:crear', 'textos:editar'],
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('getUserPermisos api client', () => {
|
||||
it('calls GET /api/v1/users/:id/permisos and returns UsuarioPermisos shape', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/42/permisos`, () =>
|
||||
HttpResponse.json(mockUsuarioPermisos),
|
||||
),
|
||||
)
|
||||
|
||||
const result = await getUserPermisos(42)
|
||||
|
||||
expect(result.rolPermisos).toEqual(['ventas:contado:crear', 'ventas:contado:cobrar'])
|
||||
expect(result.overrides.grant).toEqual(['textos:editar'])
|
||||
expect(result.overrides.deny).toEqual(['ventas:contado:cobrar'])
|
||||
expect(result.effective).toEqual(['ventas:contado:crear', 'textos:editar'])
|
||||
})
|
||||
|
||||
it('rejects with error on 404', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/users/9999/permisos`, () =>
|
||||
HttpResponse.json({ title: 'Not Found', status: 404 }, { status: 404 }),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(getUserPermisos(9999)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { updateUserPermisosOverrides } from '../../../features/users/api/updateUserPermisosOverrides'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockResponse = {
|
||||
usuarioId: 42,
|
||||
rol: 'cajero',
|
||||
rolPermisos: ['ventas:contado:crear'],
|
||||
grant: ['textos:editar'],
|
||||
deny: [],
|
||||
effective: ['ventas:contado:crear', 'textos:editar'],
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('updateUserPermisosOverrides api client', () => {
|
||||
it('calls PUT /api/v1/users/:id/permisos/overrides with correct body and returns UsuarioPermisos', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockResponse)
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await updateUserPermisosOverrides(42, {
|
||||
grant: ['textos:editar'],
|
||||
deny: [],
|
||||
})
|
||||
|
||||
expect(result.grant).toEqual(['textos:editar'])
|
||||
expect(result.effective).toContain('textos:editar')
|
||||
expect(capturedBody).toMatchObject({ grant: ['textos:editar'], deny: [] })
|
||||
})
|
||||
|
||||
it('rejects on 400 invalid-permiso-codes', async () => {
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
|
||||
HttpResponse.json(
|
||||
{ title: 'invalid-permiso-codes', status: 400, invalidCodes: ['modulo:fake:accion'] },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
updateUserPermisosOverrides(42, { grant: ['modulo:fake:accion'], deny: [] }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects on 400 grant-deny-overlap', async () => {
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
|
||||
HttpResponse.json(
|
||||
{ title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
updateUserPermisosOverrides(42, { grant: ['textos:editar'], deny: ['textos:editar'] }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -10,27 +11,38 @@ using SIGCM2.Domain.Entities;
|
||||
namespace SIGCM2.Api.Tests.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006).
|
||||
/// Tests isolated from DB: IRolPermisoRepository is mocked via NSubstitute.
|
||||
/// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006) + SUITE-B-AUTHZ-HANDLER (UDT-009).
|
||||
/// Tests isolated from DB: IRolPermisoRepository and IUsuarioRepository mocked via NSubstitute.
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationHandlerTests
|
||||
{
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
||||
private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
|
||||
private readonly PermissionAuthorizationHandler _handler;
|
||||
|
||||
public PermissionAuthorizationHandlerTests()
|
||||
{
|
||||
// Default: usuario repo returns null (no overrides) unless overridden in individual tests
|
||||
_usuarioRepo.GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Usuario?)null);
|
||||
|
||||
_handler = new PermissionAuthorizationHandler(
|
||||
_rolPermisoRepo,
|
||||
_usuarioRepo,
|
||||
NullLogger<PermissionAuthorizationHandler>.Instance);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue)
|
||||
/// <summary>Creates an authenticated user with rol claim and sub=42 (needed by UDT-009 handler).</summary>
|
||||
private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue, int userId = 42)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim("rol", rolValue) },
|
||||
new[]
|
||||
{
|
||||
new Claim("rol", rolValue),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
},
|
||||
authenticationType: "TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
@@ -38,7 +50,19 @@ public sealed class PermissionAuthorizationHandlerTests
|
||||
private static ClaimsPrincipal AuthenticatedUserWithoutRolClaim()
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.Name, "someuser") },
|
||||
new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "someuser"),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "42"),
|
||||
},
|
||||
authenticationType: "TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal AuthenticatedUserWithoutSubClaim()
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim("rol", "cajero") },
|
||||
authenticationType: "TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
@@ -243,4 +267,149 @@ public sealed class PermissionAuthorizationHandlerTests
|
||||
Assert.False(context.HasSucceeded);
|
||||
Assert.Equal("administracion:usuarios:gestionar", httpContext.Items["RequiredPermission"]);
|
||||
}
|
||||
|
||||
// ── UDT-009: SUITE-B-AUTHZ-HANDLER (A-01 a A-07) ────────────────────────
|
||||
|
||||
// A-01: Cajero sin override, endpoint requiere permiso ajeno → HasSucceeded == false
|
||||
[Fact]
|
||||
public async Task A01_Cajero_NoOverride_LacksPermission_Fails()
|
||||
{
|
||||
var user = AuthenticatedUserWithRol("cajero", userId: 42);
|
||||
var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
|
||||
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
_usuarioRepo.GetByIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}"""));
|
||||
|
||||
var context = MakeContext(user, requirement);
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
// A-02: Cajero con grant del permiso requerido → HasSucceeded == true
|
||||
[Fact]
|
||||
public async Task A02_Cajero_WithGrant_RequiredPermiso_Succeeds()
|
||||
{
|
||||
var user = AuthenticatedUserWithRol("cajero", userId: 42);
|
||||
var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
|
||||
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
_usuarioRepo.GetByIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(MakeUsuario(42, "cajero", """{"grant":["administracion:usuarios:gestionar"],"deny":[]}"""));
|
||||
|
||||
var context = MakeContext(user, requirement);
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
// A-03: Admin (tiene el permiso) + deny del permiso requerido → HasSucceeded == false
|
||||
[Fact]
|
||||
public async Task A03_Admin_WithDeny_RequiredPermiso_Fails()
|
||||
{
|
||||
var user = AuthenticatedUserWithRol("admin", userId: 1);
|
||||
var requirement = new RequirePermissionAttribute("administracion:permisos:ver");
|
||||
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(21, "administracion:permisos:ver") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
_usuarioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(MakeUsuario(1, "admin", """{"grant":[],"deny":["administracion:permisos:ver"]}"""));
|
||||
|
||||
var context = MakeContext(user, requirement);
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
// A-04: Token sin claim 'permisos' (post-UDT-009) → handler resuelve desde DB
|
||||
[Fact]
|
||||
public async Task A04_TokenWithoutPermisosClaim_HandlerResolvesFromDB()
|
||||
{
|
||||
// Token has sub=42 but no 'permisos' claim (post-UDT-009 JWT)
|
||||
var user = AuthenticatedUserWithRol("cajero", userId: 42);
|
||||
var requirement = new RequirePermissionAttribute("ventas:contado:crear");
|
||||
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
_usuarioRepo.GetByIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}"""));
|
||||
|
||||
var context = MakeContext(user, requirement);
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
// Handler correctly resolves from DB (no 'permisos' claim needed)
|
||||
Assert.True(context.HasSucceeded);
|
||||
await _usuarioRepo.Received(1).GetByIdAsync(42, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// A-05: IUsuarioRepository.GetByIdAsync called with sub from token
|
||||
[Fact]
|
||||
public async Task A05_GetByIdAsync_CalledWithSubFromToken()
|
||||
{
|
||||
var user = AuthenticatedUserWithRol("cajero", userId: 42);
|
||||
var requirement = new RequirePermissionAttribute("ventas:contado:crear");
|
||||
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
_usuarioRepo.GetByIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}"""));
|
||||
|
||||
var context = MakeContext(user, requirement);
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
await _usuarioRepo.Received(1).GetByIdAsync(42, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// A-06: sub claim absent → Fail, repo NOT called
|
||||
[Fact]
|
||||
public async Task A06_SubClaimAbsent_Fails_RepoNotCalled()
|
||||
{
|
||||
var user = AuthenticatedUserWithoutSubClaim();
|
||||
var requirement = new RequirePermissionAttribute("ventas:contado:crear");
|
||||
|
||||
_rolPermisoRepo.GetByRolCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
var context = MakeContext(user, requirement);
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
await _usuarioRepo.DidNotReceive().GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// A-07: Usuario not found in DB (null) → Fail, no exception
|
||||
[Fact]
|
||||
public async Task A07_UsuarioNotFoundInDB_FailsSafely_NoException()
|
||||
{
|
||||
var user = AuthenticatedUserWithRol("cajero", userId: 9999);
|
||||
var requirement = new RequirePermissionAttribute("ventas:contado:crear");
|
||||
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
_usuarioRepo.GetByIdAsync(9999, Arg.Any<CancellationToken>())
|
||||
.Returns((Usuario?)null);
|
||||
|
||||
var context = MakeContext(user, requirement);
|
||||
|
||||
// Should not throw — null usuario → no overrides → resolve with Empty (rol permisos only)
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
// With no overrides, cajero with ventas:contado:crear should succeed
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static Usuario MakeUsuario(int id, string rol, string permisosJson)
|
||||
=> new(id, "user" + id, "$2a$12$hash", "Test", "User", null, rol, permisosJson, true);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,8 @@ public sealed class CreateUsuarioEndpointTests : IAsyncLifetime
|
||||
new { Username = newUsername });
|
||||
|
||||
Assert.True(row.Activo, "Activo should be true");
|
||||
Assert.Equal("[]", row.PermisosJson);
|
||||
// V009 (UDT-009): ForCreation now defaults to canonical shape {"grant":[],"deny":[]}
|
||||
Assert.Equal("""{"grant":[],"deny":[]}""", row.PermisosJson);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
435
tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
Normal file
435
tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
Normal file
@@ -0,0 +1,435 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Usuarios;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for GET /api/v1/users/{id}/permisos and PUT /api/v1/users/{id}/permisos/overrides.
|
||||
/// SUITE-B-GET-PERMISOS (GP-01..GP-06) + SUITE-B-PUT-OVERRIDES (PO-01..PO-11) — UDT-009.
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class UsuarioPermisosEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
private string? _adminToken;
|
||||
|
||||
public UsuarioPermisosEndpointTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetBearerTokenAsync(string username, string password)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password });
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? token = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
var tok = token ?? _adminToken;
|
||||
if (tok is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tok);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task<int> GetAdminIdAsync()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
return await conn.QuerySingleAsync<int>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
|
||||
}
|
||||
|
||||
private async Task SetPermisosJsonAsync(int userId, string json)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE dbo.Usuario SET PermisosJson = @Json WHERE Id = @Id",
|
||||
new { Json = json, Id = userId });
|
||||
}
|
||||
|
||||
private async Task<string> GetPermisosJsonAsync(int userId)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
return await conn.QuerySingleAsync<string>(
|
||||
"SELECT PermisosJson FROM dbo.Usuario WHERE Id = @Id",
|
||||
new { Id = userId });
|
||||
}
|
||||
|
||||
// ── SUITE-B-GET-PERMISOS ─────────────────────────────────────────────────
|
||||
|
||||
// GP-01: Admin → 200 con shape correcto {rolPermisos, overrides, effective}
|
||||
[Fact]
|
||||
public async Task GetPermisos_Admin_Returns200_WithCorrectShape()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||
|
||||
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
Assert.True(json.TryGetProperty("rolPermisos", out var rolPermisos));
|
||||
Assert.Equal(JsonValueKind.Array, rolPermisos.ValueKind);
|
||||
|
||||
Assert.True(json.TryGetProperty("overrides", out var overrides));
|
||||
Assert.True(overrides.TryGetProperty("grant", out _));
|
||||
Assert.True(overrides.TryGetProperty("deny", out _));
|
||||
|
||||
Assert.True(json.TryGetProperty("effective", out var effective));
|
||||
Assert.Equal(JsonValueKind.Array, effective.ValueKind);
|
||||
}
|
||||
|
||||
// GP-02: Usuario con overrides no vacíos → shape refleja overrides.grant, effective incluye el grant
|
||||
[Fact]
|
||||
public async Task GetPermisos_UserWithGrant_EffectiveContainsGrantedPermiso()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
// Admin ya tiene 21 permisos del rol — grant con uno que tiene para probar idempotencia
|
||||
await SetPermisosJsonAsync(adminId, """{"grant":["textos:editar"],"deny":[]}""");
|
||||
|
||||
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var overrides = json.GetProperty("overrides");
|
||||
var grantArr = overrides.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
|
||||
Assert.Contains("textos:editar", grantArr);
|
||||
|
||||
var effectiveArr = json.GetProperty("effective").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
Assert.Contains("textos:editar", effectiveArr);
|
||||
}
|
||||
|
||||
// GP-03: Usuario con overrides vacíos → effective == rolPermisos, overrides vacíos
|
||||
[Fact]
|
||||
public async Task GetPermisos_UserWithEmptyOverrides_EffectiveEqualsRolPermisos()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||
|
||||
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var rolPermisos = json.GetProperty("rolPermisos").EnumerateArray().Select(e => e.GetString()).OrderBy(x => x).ToArray();
|
||||
var effective = json.GetProperty("effective").EnumerateArray().Select(e => e.GetString()).OrderBy(x => x).ToArray();
|
||||
|
||||
Assert.Equal(rolPermisos, effective);
|
||||
|
||||
var grantArr = json.GetProperty("overrides").GetProperty("grant").EnumerateArray().ToArray();
|
||||
var denyArr = json.GetProperty("overrides").GetProperty("deny").EnumerateArray().ToArray();
|
||||
Assert.Empty(grantArr);
|
||||
Assert.Empty(denyArr);
|
||||
}
|
||||
|
||||
// GP-04: Usuario inexistente → 404
|
||||
[Fact]
|
||||
public async Task GetPermisos_NonExistentUser_Returns404()
|
||||
{
|
||||
var request = BuildRequest(HttpMethod.Get, "/api/v1/users/99999/permisos");
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
// GP-05: Sin permiso administracion:usuarios:gestionar → 403
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithoutRequiredPermission_Returns403()
|
||||
{
|
||||
// Create a cajero user without the required permission
|
||||
var cajeroToken = await CreateCajeroAndGetTokenAsync("cajero_gp05");
|
||||
try
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos", token: cajeroToken);
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioAsync("cajero_gp05");
|
||||
}
|
||||
}
|
||||
|
||||
// GP-06: Sin auth → 401
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithoutAuth_Returns401()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
// ── SUITE-B-PUT-OVERRIDES ────────────────────────────────────────────────
|
||||
|
||||
// PO-01: Grant válido → 200, DB persistido, FechaModificacion actualizado
|
||||
[Fact]
|
||||
public async Task PutOverrides_ValidGrant_Returns200_AndPersistsInDB()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||
|
||||
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = new[] { "textos:editar" }, deny = Array.Empty<string>() });
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var stored = await GetPermisosJsonAsync(adminId);
|
||||
var parsed = JsonDocument.Parse(stored).RootElement;
|
||||
var grant = parsed.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
Assert.Contains("textos:editar", grant);
|
||||
}
|
||||
|
||||
// PO-02: Deny válido → 200
|
||||
[Fact]
|
||||
public async Task PutOverrides_ValidDeny_Returns200()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||
|
||||
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = Array.Empty<string>(), deny = new[] { "ventas:contado:cobrar" } });
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var stored = await GetPermisosJsonAsync(adminId);
|
||||
var parsed = JsonDocument.Parse(stored).RootElement;
|
||||
var deny = parsed.GetProperty("deny").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
Assert.Contains("ventas:contado:cobrar", deny);
|
||||
}
|
||||
|
||||
// PO-03: Código fuera del catálogo → 400, error code "invalid-permiso-codes"
|
||||
[Fact]
|
||||
public async Task PutOverrides_InvalidPermisoCode_Returns400_InvalidCodes()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
|
||||
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = new[] { "modulo:fake:accion" }, deny = Array.Empty<string>() });
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var title = json.GetProperty("title").GetString();
|
||||
Assert.Equal("invalid-permiso-codes", title);
|
||||
|
||||
// Should contain the list of invalid codes
|
||||
Assert.True(json.TryGetProperty("invalidCodes", out var invalidCodes));
|
||||
var codes = invalidCodes.EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
Assert.Contains("modulo:fake:accion", codes);
|
||||
}
|
||||
|
||||
// PO-04: Mismo código en grant Y deny → 400, "grant-deny-overlap"
|
||||
[Fact]
|
||||
public async Task PutOverrides_GrantDenyOverlap_Returns400_Overlap()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
|
||||
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = new[] { "textos:editar" }, deny = new[] { "textos:editar" } });
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var title = json.GetProperty("title").GetString();
|
||||
Assert.Equal("grant-deny-overlap", title);
|
||||
|
||||
Assert.True(json.TryGetProperty("overlap", out var overlap));
|
||||
var codes = overlap.EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
Assert.Contains("textos:editar", codes);
|
||||
}
|
||||
|
||||
// PO-05: Usuario inexistente → 404
|
||||
[Fact]
|
||||
public async Task PutOverrides_NonExistentUser_Returns404()
|
||||
{
|
||||
var request = BuildRequest(HttpMethod.Put, "/api/v1/users/99999/permisos/overrides",
|
||||
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() });
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
// PO-06: Sin permiso → 403
|
||||
[Fact]
|
||||
public async Task PutOverrides_WithoutRequiredPermission_Returns403()
|
||||
{
|
||||
var cajeroToken = await CreateCajeroAndGetTokenAsync("cajero_po06");
|
||||
try
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() },
|
||||
token: cajeroToken);
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioAsync("cajero_po06");
|
||||
}
|
||||
}
|
||||
|
||||
// PO-07: Sin auth → 401
|
||||
[Fact]
|
||||
public async Task PutOverrides_WithoutAuth_Returns401()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides")
|
||||
{
|
||||
Content = JsonContent.Create(new { grant = Array.Empty<string>(), deny = Array.Empty<string>() })
|
||||
};
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
// PO-08: Body JSON malformado → 400
|
||||
[Fact]
|
||||
public async Task PutOverrides_MalformedBody_Returns400()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides")
|
||||
{
|
||||
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", _adminToken) },
|
||||
Content = new StringContent("{grant: not-json", System.Text.Encoding.UTF8, "application/json")
|
||||
};
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
// PO-09: PUT idempotente — dos veces el mismo body → estado igual
|
||||
[Fact]
|
||||
public async Task PutOverrides_Idempotent_SameBodyTwice_StateUnchanged()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
var body = new { grant = new[] { "textos:editar" }, deny = Array.Empty<string>() };
|
||||
|
||||
var r1 = await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", body));
|
||||
var r2 = await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", body));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, r1.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, r2.StatusCode);
|
||||
|
||||
var stored = await GetPermisosJsonAsync(adminId);
|
||||
var parsed = JsonDocument.Parse(stored).RootElement;
|
||||
var grant = parsed.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
Assert.Single(grant);
|
||||
Assert.Equal("textos:editar", grant[0]);
|
||||
}
|
||||
|
||||
// PO-10: PUT con grants vacíos (reset overrides) → effective == rolPermisos
|
||||
[Fact]
|
||||
public async Task PutOverrides_EmptyPayload_ResetsOverrides()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
|
||||
// First set some overrides
|
||||
await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = new[] { "textos:editar" }, deny = Array.Empty<string>() }));
|
||||
|
||||
// Then reset
|
||||
var resetRequest = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() });
|
||||
var response = await _client.SendAsync(resetRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var stored = await GetPermisosJsonAsync(adminId);
|
||||
Assert.Equal("""{"grant":[],"deny":[]}""", stored);
|
||||
}
|
||||
|
||||
// PO-11: Response de PUT tiene shape {rolPermisos, overrides, effective}
|
||||
[Fact]
|
||||
public async Task PutOverrides_ResponseHasCorrectShape()
|
||||
{
|
||||
var adminId = await GetAdminIdAsync();
|
||||
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||
|
||||
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() });
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(json.TryGetProperty("rolPermisos", out _));
|
||||
Assert.True(json.TryGetProperty("overrides", out var overrides));
|
||||
Assert.True(overrides.TryGetProperty("grant", out _));
|
||||
Assert.True(overrides.TryGetProperty("deny", out _));
|
||||
Assert.True(json.TryGetProperty("effective", out _));
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> CreateCajeroAndGetTokenAsync(string username)
|
||||
{
|
||||
// Seed a cajero user without administracion:usuarios:gestionar
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("""
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
|
||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||
VALUES (@Username, '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
|
||||
'Cajero', 'Test', 'cajero', '{"grant":[],"deny":[]}', 1, 0)
|
||||
""", new { Username = username });
|
||||
|
||||
return await GetBearerTokenAsync(username, "@Diego550@");
|
||||
}
|
||||
|
||||
private async Task DeleteUsuarioAsync(string username)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
// Must delete RefreshTokens first due to FK constraint
|
||||
await conn.ExecuteAsync("""
|
||||
DELETE rt FROM dbo.RefreshToken rt
|
||||
INNER JOIN dbo.Usuario u ON rt.UsuarioId = u.Id
|
||||
WHERE u.Username = @Username
|
||||
""", new { Username = username });
|
||||
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,135 @@ public class LoginCommandHandlerTests
|
||||
Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos);
|
||||
}
|
||||
|
||||
// ── UDT-009: PermisoResolver integration in LoginCommandHandler ─────────────
|
||||
|
||||
// L-01: Admin sin overrides → permisos = exactamente los del rol
|
||||
[Fact]
|
||||
public async Task Handle_AdminNoOverrides_PermisosEqualRolPermisos()
|
||||
{
|
||||
// Arrange
|
||||
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin",
|
||||
"""{"grant":[],"deny":[]}""", true);
|
||||
_repository.GetByUsernameAsync("admin").Returns(usuario);
|
||||
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
|
||||
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
|
||||
|
||||
var adminPermisos = Enumerable.Range(1, 21)
|
||||
.Select(i => MakePermiso(i, $"perm:mod{i}:accion{i}"))
|
||||
.ToList();
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
|
||||
.Returns(adminPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(new LoginCommand("admin", "pass"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(21, result.Usuario.Permisos.Length);
|
||||
}
|
||||
|
||||
// L-02: Cajero + grant nuevo permiso → result contiene permiso del grant
|
||||
[Fact]
|
||||
public async Task Handle_CajeroWithGrant_PermisosContainGrantedPermiso()
|
||||
{
|
||||
// Arrange
|
||||
var usuario = new Usuario(2, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
|
||||
"""{"grant":["textos:editar"],"deny":[]}""", true);
|
||||
_repository.GetByUsernameAsync("cajero1").Returns(usuario);
|
||||
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
|
||||
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
|
||||
|
||||
var cajeroPermisos = new List<Permiso>
|
||||
{
|
||||
MakePermiso(10, "ventas:contado:crear"),
|
||||
MakePermiso(11, "ventas:contado:modificar"),
|
||||
MakePermiso(12, "ventas:contado:cobrar"),
|
||||
MakePermiso(13, "ventas:contado:facturar"),
|
||||
};
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, result.Usuario.Permisos.Length);
|
||||
Assert.Contains("textos:editar", result.Usuario.Permisos);
|
||||
Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
|
||||
}
|
||||
|
||||
// L-03: Cajero + deny uno del rol → result NO contiene el permiso denegado
|
||||
[Fact]
|
||||
public async Task Handle_CajeroWithDeny_PermisosExcludeDeniedPermiso()
|
||||
{
|
||||
// Arrange
|
||||
var usuario = new Usuario(3, "cajero2", "$2a$12$hash", "C", "B", null, "cajero",
|
||||
"""{"grant":[],"deny":["ventas:contado:cobrar"]}""", true);
|
||||
_repository.GetByUsernameAsync("cajero2").Returns(usuario);
|
||||
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
|
||||
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
|
||||
|
||||
var cajeroPermisos = new List<Permiso>
|
||||
{
|
||||
MakePermiso(10, "ventas:contado:crear"),
|
||||
MakePermiso(11, "ventas:contado:modificar"),
|
||||
MakePermiso(12, "ventas:contado:cobrar"),
|
||||
MakePermiso(13, "ventas:contado:facturar"),
|
||||
};
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(new LoginCommand("cajero2", "pass"));
|
||||
|
||||
// Assert — ventas:contado:cobrar was denied
|
||||
Assert.Equal(3, result.Usuario.Permisos.Length);
|
||||
Assert.DoesNotContain("ventas:contado:cobrar", result.Usuario.Permisos);
|
||||
Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
|
||||
}
|
||||
|
||||
// L-04: DTO always returns Permisos as string[] — not grant/deny shape
|
||||
[Fact]
|
||||
public async Task Handle_AlwaysReturnsPermisosAsStringArray_NotGrantDenyShape()
|
||||
{
|
||||
var usuario = new Usuario(1, "admin", "$2a$12$hash", "A", "B", null, "admin",
|
||||
"""{"grant":["extra:perm"],"deny":[]}""", true);
|
||||
_repository.GetByUsernameAsync("admin").Returns(usuario);
|
||||
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
|
||||
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
var result = await _handler.Handle(new LoginCommand("admin", "pass"));
|
||||
|
||||
// Must be string[] — no grant/deny wrapping
|
||||
Assert.IsType<string[]>(result.Usuario.Permisos);
|
||||
}
|
||||
|
||||
// L-05: Legacy PermisosJson "[]" → treated as Empty → permisos = only rol
|
||||
[Fact]
|
||||
public async Task Handle_LegacyPermisosJson_EmptyArray_TreatedAsEmpty()
|
||||
{
|
||||
var usuario = new Usuario(1, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
|
||||
"[]", true);
|
||||
_repository.GetByUsernameAsync("cajero1").Returns(usuario);
|
||||
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
|
||||
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
|
||||
|
||||
var cajeroPermisos = new List<Permiso>
|
||||
{
|
||||
MakePermiso(10, "ventas:contado:crear"),
|
||||
MakePermiso(11, "ventas:contado:cobrar"),
|
||||
};
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
|
||||
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));
|
||||
|
||||
Assert.Equal(2, result.Usuario.Permisos.Length);
|
||||
Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
|
||||
Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos);
|
||||
}
|
||||
|
||||
// Helper: construir Permiso via ForRead para tests
|
||||
private static Permiso MakePermiso(int id, string codigo) =>
|
||||
Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow);
|
||||
|
||||
134
tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
Normal file
134
tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Common;
|
||||
|
||||
/// <summary>
|
||||
/// SUITE-B-RESOLVER — R-01 a R-09 (UDT-009)
|
||||
/// Unit tests for PermisoResolver.Resolve static helper.
|
||||
/// Pure unit: no DB, no mocks.
|
||||
/// </summary>
|
||||
public sealed class PermisoResolverTests
|
||||
{
|
||||
// R-01: Override vacío → effective = solo rol sin cambios
|
||||
[Fact]
|
||||
public void Resolve_EmptyOverride_ReturnsRolPermisosUnchanged()
|
||||
{
|
||||
var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-02: Grant nuevo permiso → se agrega al set
|
||||
[Fact]
|
||||
public void Resolve_GrantNewPermiso_AddsToEffective()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Contains("C", result);
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
// R-03: Deny permiso del rol → se quita del set
|
||||
[Fact]
|
||||
public void Resolve_DenyRolPermiso_RemovesFromEffective()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: [], Deny: ["A"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.DoesNotContain("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(1, result.Count);
|
||||
}
|
||||
|
||||
// R-04: Grant duplicado (ya en rol) → idempotente, no duplicados
|
||||
[Fact]
|
||||
public void Resolve_GrantDuplicated_Idempotent()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["B"], Deny: []);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count); // no duplicates
|
||||
}
|
||||
|
||||
// R-05: Deny código inexistente en rol → no-op
|
||||
[Fact]
|
||||
public void Resolve_DenyNonExistentCode_NoOp()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: [], Deny: ["X"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-06: Grant + Deny combinados
|
||||
[Fact]
|
||||
public void Resolve_GrantAndDeny_Combined()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: ["A"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.DoesNotContain("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Contains("C", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-07: PermisosOverride.Empty literal → mismo que rol
|
||||
[Fact]
|
||||
public void Resolve_EmptyLiteral_ReturnsRolPermisosOnly()
|
||||
{
|
||||
var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-08: Rol vacío + grant → effective = solo el grant
|
||||
[Fact]
|
||||
public void Resolve_EmptyRol_WithGrant_ReturnsGrant()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
|
||||
|
||||
var result = PermisoResolver.Resolve([], overrides);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Contains("C", result);
|
||||
}
|
||||
|
||||
// R-09: Rol vacío + sin overrides → effective vacío
|
||||
[Fact]
|
||||
public void Resolve_EmptyRol_EmptyOverrides_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisoResolver.Resolve([], PermisosOverride.Empty);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// Extra: Deny gana sobre grant explícito (defense en runtime — validator lo bloquea antes)
|
||||
[Fact]
|
||||
public void Resolve_DenyWinsOver_ExplicitGrant()
|
||||
{
|
||||
// Mismo código en grant y deny → deny gana (algoritmo: grant primero, deny al final)
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: ["C"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A"], overrides);
|
||||
|
||||
Assert.DoesNotContain("C", result);
|
||||
Assert.Contains("A", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Common;
|
||||
|
||||
/// <summary>
|
||||
/// SUITE-B-PERMISOS-OVERRIDE-PARSING — P-01 a P-08 (UDT-009)
|
||||
/// Unit tests for PermisosOverride.FromJson parsing logic.
|
||||
/// </summary>
|
||||
public sealed class PermisosOverrideParsingTests
|
||||
{
|
||||
// P-01: JSON válido con grant y deny → record correcto
|
||||
[Fact]
|
||||
public void FromJson_ValidGrantAndDeny_ReturnsParsedRecord()
|
||||
{
|
||||
const string json = """{"grant":["textos:editar"],"deny":["ventas:contado:cobrar"]}""";
|
||||
|
||||
var result = PermisosOverride.FromJson(json);
|
||||
|
||||
Assert.Single(result.Grant);
|
||||
Assert.Equal("textos:editar", result.Grant[0]);
|
||||
Assert.Single(result.Deny);
|
||||
Assert.Equal("ventas:contado:cobrar", result.Deny[0]);
|
||||
}
|
||||
|
||||
// P-02: JSON vacío canónico → equivalente a Empty
|
||||
[Fact]
|
||||
public void FromJson_EmptyCanonical_ReturnsEmpty()
|
||||
{
|
||||
const string json = """{"grant":[],"deny":[]}""";
|
||||
|
||||
var result = PermisosOverride.FromJson(json);
|
||||
|
||||
Assert.Empty(result.Grant);
|
||||
Assert.Empty(result.Deny);
|
||||
}
|
||||
|
||||
// P-03: Legacy "[]" → Empty (backward compat)
|
||||
[Fact]
|
||||
public void FromJson_LegacyEmptyArray_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson("[]");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-04: Legacy '["*"]' → Empty (backward compat)
|
||||
[Fact]
|
||||
public void FromJson_LegacyWildcard_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson("""["*"]""");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-05: null → Empty
|
||||
[Fact]
|
||||
public void FromJson_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson(null);
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-06a: string vacío → Empty
|
||||
[Fact]
|
||||
public void FromJson_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson(string.Empty);
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-06b: whitespace → Empty
|
||||
[Fact]
|
||||
public void FromJson_Whitespace_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson(" ");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-07: JSON malformado → Empty (tolerante en runtime)
|
||||
[Fact]
|
||||
public void FromJson_MalformedJson_ReturnsEmpty()
|
||||
{
|
||||
// Nota: FromJson es tolerante — catch(JsonException) → Empty.
|
||||
// Ver tasks note 2: "P-07/P-08 verifican que JSON malformado → Empty (no FormatException)"
|
||||
var result = PermisosOverride.FromJson("{grant:[");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-08: JSON de tipo incorrecto (número) → Empty (tolerante)
|
||||
[Fact]
|
||||
public void FromJson_WrongJsonType_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson("42");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// Extra: ToJson produce JSON re-parseable con shape correcto
|
||||
[Fact]
|
||||
public void ToJson_ProducesCanonicalJson()
|
||||
{
|
||||
var overrides = new PermisosOverride(
|
||||
Grant: new[] { "textos:editar" },
|
||||
Deny: new[] { "ventas:contado:cobrar" });
|
||||
|
||||
var json = overrides.ToJson();
|
||||
var reparsed = PermisosOverride.FromJson(json);
|
||||
|
||||
Assert.Equal(overrides.Grant, reparsed.Grant);
|
||||
Assert.Equal(overrides.Deny, reparsed.Deny);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,59 @@ public class JwtServiceTests : IDisposable
|
||||
Assert.Contains("sigcm2.web", parsed.Audiences); // aud
|
||||
Assert.Contains(parsed.Claims, c => c.Type == "name" && c.Value == "admin");
|
||||
Assert.Contains(parsed.Claims, c => c.Type == "rol" && c.Value == "admin");
|
||||
|
||||
// J-01 (UDT-009): token must NOT contain 'permisos' claim post-UDT-009
|
||||
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
||||
}
|
||||
|
||||
// J-01: token post-UDT-009 does NOT have 'permisos' claim
|
||||
[Fact]
|
||||
public void GenerateAccessToken_DoesNotContainPermisosClaim()
|
||||
{
|
||||
var usuario = MakeUsuario();
|
||||
var token = _jwtService.GenerateAccessToken(usuario);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parsed = handler.ReadJwtToken(token);
|
||||
|
||||
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
||||
}
|
||||
|
||||
// J-02: claims present are sub, jti, name, rol (+ iat/exp/nbf) — no extras
|
||||
[Fact]
|
||||
public void GenerateAccessToken_HasExactlyExpectedClaims_NoPermisos()
|
||||
{
|
||||
var usuario = MakeUsuario();
|
||||
var token = _jwtService.GenerateAccessToken(usuario);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parsed = handler.ReadJwtToken(token);
|
||||
|
||||
// Must have sub, name, rol, jti
|
||||
Assert.Contains(parsed.Claims, c => c.Type == "sub");
|
||||
Assert.Contains(parsed.Claims, c => c.Type == "name");
|
||||
Assert.Contains(parsed.Claims, c => c.Type == "rol");
|
||||
Assert.Contains(parsed.Claims, c => c.Type == "jti");
|
||||
|
||||
// Must NOT have permisos
|
||||
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
||||
}
|
||||
|
||||
// J-03: MakeUsuario with '["*"]' PermisosJson → token still has no 'permisos' claim
|
||||
[Fact]
|
||||
public void GenerateAccessToken_WithLegacyPermisosJson_NoPermisosClaim()
|
||||
{
|
||||
// MakeUsuario already uses '[\"*\"]' — this explicitly tests J-03
|
||||
var usuario = MakeUsuario();
|
||||
Assert.Equal("[\"*\"]", usuario.PermisosJson); // verify the helper
|
||||
|
||||
var token = _jwtService.GenerateAccessToken(usuario);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parsed = handler.ReadJwtToken(token);
|
||||
|
||||
// Post-UDT-009: JwtService ignores PermisosJson entirely — no claim emitted
|
||||
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
||||
}
|
||||
|
||||
// Scenario: token is verifiable with the public key
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Respawn;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for IUsuarioRepository.UpdatePermisosJsonAsync (UDT-009).
|
||||
/// Uses SIGCM2_Test database directly.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
|
||||
private SqlConnection _connection = null!;
|
||||
private Respawner _respawner = null!;
|
||||
private UsuarioRepository _repository = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_connection = new SqlConnection(ConnectionString);
|
||||
await _connection.OpenAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
TablesToIgnore =
|
||||
[
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
]
|
||||
});
|
||||
|
||||
await _respawner.ResetAsync(_connection);
|
||||
await SeedRolCanonicalAsync();
|
||||
|
||||
var factory = new SqlConnectionFactory(ConnectionString);
|
||||
_repository = new UsuarioRepository(factory);
|
||||
|
||||
// Seed a test user
|
||||
await _connection.ExecuteAsync("""
|
||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||
VALUES ('testuser', '$2a$12$hash', 'Test', 'User', 'cajero', '{"grant":[],"deny":[]}', 1, 0)
|
||||
""");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _respawner.ResetAsync(_connection);
|
||||
await _connection.CloseAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// UPJ-01: UpdatePermisosJsonAsync persists PermisosJson and FechaModificacion
|
||||
[Fact]
|
||||
public async Task UpdatePermisosJsonAsync_PersistsJsonAndFechaModificacion()
|
||||
{
|
||||
// Arrange
|
||||
var userId = await _connection.QuerySingleAsync<int>(
|
||||
"SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
|
||||
var newJson = """{"grant":["textos:editar"],"deny":[]}""";
|
||||
var fechaMod = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
await _repository.UpdatePermisosJsonAsync(userId, newJson, fechaMod);
|
||||
|
||||
// Assert
|
||||
var row = await _connection.QuerySingleAsync<(string PermisosJson, DateTime? FechaModificacion)>(
|
||||
"SELECT PermisosJson, FechaModificacion FROM dbo.Usuario WHERE Id = @Id",
|
||||
new { Id = userId });
|
||||
|
||||
Assert.Equal(newJson, row.PermisosJson);
|
||||
Assert.NotNull(row.FechaModificacion);
|
||||
// Allow 2-second tolerance for DB round-trip
|
||||
Assert.True(
|
||||
Math.Abs((row.FechaModificacion!.Value - fechaMod).TotalSeconds) < 2,
|
||||
$"FechaModificacion {row.FechaModificacion} is too far from {fechaMod}");
|
||||
}
|
||||
|
||||
// UPJ-02: UpdatePermisosJsonAsync with non-existent id → no throw (UPDATE affects 0 rows)
|
||||
[Fact]
|
||||
public async Task UpdatePermisosJsonAsync_NonExistentId_NoThrow()
|
||||
{
|
||||
// Should not throw — UPDATE with 0 rows affected is a no-op
|
||||
await _repository.UpdatePermisosJsonAsync(99999, """{"grant":[],"deny":[]}""", DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// UPJ-03: GetByIdAsync after update reflects new PermisosJson
|
||||
[Fact]
|
||||
public async Task UpdatePermisosJsonAsync_GetByIdReflectsChange()
|
||||
{
|
||||
// Arrange
|
||||
var userId = await _connection.QuerySingleAsync<int>(
|
||||
"SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
|
||||
var newJson = """{"grant":["pauta:azanu:ver"],"deny":["ventas:contado:cobrar"]}""";
|
||||
|
||||
// Act
|
||||
await _repository.UpdatePermisosJsonAsync(userId, newJson, DateTime.UtcNow);
|
||||
|
||||
// Assert — read back through the repo
|
||||
var usuario = await _repository.GetByIdAsync(userId);
|
||||
|
||||
Assert.NotNull(usuario);
|
||||
Assert.Equal(newJson, usuario!.PermisosJson);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task SeedRolCanonicalAsync()
|
||||
{
|
||||
await _connection.ExecuteAsync("""
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.Rol AS t
|
||||
USING (VALUES
|
||||
('admin', N'Administrador', N'Supervisor total'),
|
||||
('cajero', N'Cajero', N'Mostrador contado')
|
||||
) AS s (Codigo, Nombre, Descripcion)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Activo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
|
||||
""");
|
||||
}
|
||||
}
|
||||
256
tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs
Normal file
256
tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Respawn;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009)
|
||||
/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync.
|
||||
/// Uses SIGCM2_Test database directly.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public sealed class V009MigrationTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
|
||||
private SqlConnection _connection = null!;
|
||||
private Respawner _respawner = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_connection = new SqlConnection(ConnectionString);
|
||||
await _connection.OpenAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
TablesToIgnore =
|
||||
[
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
]
|
||||
});
|
||||
|
||||
await _respawner.ResetAsync(_connection);
|
||||
await SeedRolAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// M-01: migration file exists on filesystem
|
||||
[Fact]
|
||||
public void MigrationFile_Exists()
|
||||
{
|
||||
// Walk up from test assembly looking for the repo root
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
string? repoRoot = null;
|
||||
|
||||
while (dir is not null)
|
||||
{
|
||||
if (dir.GetFiles("SIGCM2.slnx").Length > 0)
|
||||
{
|
||||
repoRoot = dir.FullName;
|
||||
break;
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
// Known fallback
|
||||
if (repoRoot is null && Directory.Exists(@"E:\SIG-CM2.0"))
|
||||
repoRoot = @"E:\SIG-CM2.0";
|
||||
|
||||
Assert.NotNull(repoRoot);
|
||||
var migrationPath = Path.Combine(repoRoot!, "database", "migrations", "V009__activate_permisos_overrides.sql");
|
||||
Assert.True(File.Exists(migrationPath), $"Migration file not found at: {migrationPath}");
|
||||
}
|
||||
|
||||
// M-02: re-run idempotent (EnsureV009SchemaAsync twice → no error)
|
||||
[Fact]
|
||||
public async Task EnsureV009SchemaAsync_IsIdempotent()
|
||||
{
|
||||
await EnsureV009SchemaAsync();
|
||||
await EnsureV009SchemaAsync(); // second call must not throw
|
||||
}
|
||||
|
||||
// M-03: after migration, DEFAULT constraint is the new shape
|
||||
[Fact]
|
||||
public async Task EnsureV009SchemaAsync_DefaultConstraint_IsNewShape()
|
||||
{
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
const string sql = """
|
||||
SELECT object_definition(default_object_id) AS DefaultDef
|
||||
FROM sys.columns
|
||||
WHERE object_id = OBJECT_ID('dbo.Usuario')
|
||||
AND name = 'PermisosJson'
|
||||
""";
|
||||
|
||||
var definition = await _connection.QuerySingleOrDefaultAsync<string>(sql);
|
||||
|
||||
Assert.NotNull(definition);
|
||||
Assert.Contains(@"{""grant"":[]", definition);
|
||||
Assert.Contains(@"""deny"":[]}", definition);
|
||||
}
|
||||
|
||||
// M-04: rows with '[]' are migrated to new shape
|
||||
[Fact]
|
||||
public async Task EnsureV009SchemaAsync_MigratesLegacyEmptyArray()
|
||||
{
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
await _connection.ExecuteAsync("""
|
||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||
VALUES ('legacyempty', '$2a$12$hash', 'L', 'E', 'admin', '[]', 1, 0)
|
||||
""");
|
||||
|
||||
// Run migration again to migrate the newly inserted row
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
var permisosJson = await _connection.QuerySingleAsync<string>(
|
||||
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'");
|
||||
|
||||
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
|
||||
}
|
||||
|
||||
// M-05: rows with '["*"]' are migrated
|
||||
[Fact]
|
||||
public async Task EnsureV009SchemaAsync_MigratesLegacyWildcard()
|
||||
{
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
await _connection.ExecuteAsync("""
|
||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||
VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0)
|
||||
""");
|
||||
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
var permisosJson = await _connection.QuerySingleAsync<string>(
|
||||
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'");
|
||||
|
||||
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
|
||||
}
|
||||
|
||||
// M-06: the migration UPDATE statement includes NULL / empty-string conditions
|
||||
// The column is NOT NULL (V001 constraint), so we verify the UPDATE logic covers
|
||||
// all the WHERE conditions syntactically and that rows with '' are migrated.
|
||||
[Fact]
|
||||
public async Task EnsureV009SchemaAsync_MigratesEmptyStringRows()
|
||||
{
|
||||
// First apply V009 schema so the constraint is updated
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
// Temporarily drop and re-add without the DEFAULT so we can insert ''
|
||||
await _connection.ExecuteAsync("""
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
|
||||
""");
|
||||
|
||||
await _connection.ExecuteAsync("""
|
||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||
VALUES ('emptystruser', '$2a$12$hash', 'E', 'S', 'admin', '', 1, 0)
|
||||
""");
|
||||
|
||||
// Re-apply V009 (which restores constraint and migrates '' rows)
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
var permisosJson = await _connection.QuerySingleAsync<string>(
|
||||
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'");
|
||||
|
||||
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
|
||||
}
|
||||
|
||||
// M-07: admin seed in SqlTestFixture uses new shape
|
||||
[Fact]
|
||||
public async Task SqlTestFixture_SeedAdmin_UsesNewPermisosJsonShape()
|
||||
{
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
// Seed admin as TestFixture does post-V009
|
||||
await _connection.ExecuteAsync("""
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
|
||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||
VALUES (
|
||||
'admin',
|
||||
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
|
||||
'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
|
||||
)
|
||||
""");
|
||||
|
||||
var permisosJson = await _connection.QuerySingleAsync<string>(
|
||||
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'admin'");
|
||||
|
||||
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task SeedRolAsync()
|
||||
{
|
||||
await _connection.ExecuteAsync("""
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.Rol AS t
|
||||
USING (VALUES ('admin', N'Administrador', N'Supervisor total'))
|
||||
AS s (Codigo, Nombre, Descripcion)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Activo) VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync.
|
||||
/// </summary>
|
||||
private async Task EnsureV009SchemaAsync()
|
||||
{
|
||||
const string dropConstraint = """
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
|
||||
END
|
||||
""";
|
||||
|
||||
const string addConstraint = """
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
ADD CONSTRAINT DF_Usuario_Permisos
|
||||
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
|
||||
END
|
||||
""";
|
||||
|
||||
const string migrateRows = """
|
||||
UPDATE dbo.Usuario
|
||||
SET PermisosJson = '{"grant":[],"deny":[]}'
|
||||
WHERE PermisosJson IN ('[]', '["*"]', '')
|
||||
OR PermisosJson IS NULL
|
||||
OR LTRIM(RTRIM(PermisosJson)) = ''
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(dropConstraint);
|
||||
await _connection.ExecuteAsync(addConstraint);
|
||||
await _connection.ExecuteAsync(migrateRows);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB
|
||||
await EnsureV008SchemaAsync();
|
||||
|
||||
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
@@ -215,6 +218,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
|
||||
private async Task SeedAdminAsync()
|
||||
{
|
||||
// V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]'
|
||||
const string sql = """
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
|
||||
@@ -222,9 +226,53 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
VALUES (
|
||||
'admin',
|
||||
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
|
||||
'Administrador', 'Sistema', 'admin', '["*"]', 1, 0
|
||||
'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
|
||||
);
|
||||
""";
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies V009 schema changes idempotently to the test database.
|
||||
/// Mirrors V009__activate_permisos_overrides.sql.
|
||||
/// Drops and re-adds DF_Usuario_Permisos with the new shape, then migrates legacy rows.
|
||||
/// </summary>
|
||||
private async Task EnsureV009SchemaAsync()
|
||||
{
|
||||
const string dropConstraint = """
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
|
||||
END
|
||||
""";
|
||||
|
||||
const string addConstraint = """
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
ADD CONSTRAINT DF_Usuario_Permisos
|
||||
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
|
||||
END
|
||||
""";
|
||||
|
||||
const string migrateRows = """
|
||||
UPDATE dbo.Usuario
|
||||
SET PermisosJson = '{"grant":[],"deny":[]}'
|
||||
WHERE PermisosJson IN ('[]', '["*"]', '')
|
||||
OR PermisosJson IS NULL
|
||||
OR LTRIM(RTRIM(PermisosJson)) = ''
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(dropConstraint);
|
||||
await _connection.ExecuteAsync(addConstraint);
|
||||
await _connection.ExecuteAsync(migrateRows);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user