Merge pull request 'UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth' (#12) from feature/UDT-009 into main

This commit was merged in pull request #12.
This commit is contained in:
2026-04-16 13:12:23 +00:00
44 changed files with 2880 additions and 143 deletions

View 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

View File

@@ -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)}"));

View File

@@ -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,

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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,

View 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;
}
}

View 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);
}

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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 [];
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
)
}

View 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 }

View 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
}

View File

@@ -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
}

View 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>
)
}

View File

@@ -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'] })
},
})
}

View 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,
})
}

View File

@@ -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,105 +133,120 @@ export function UserEditPage() {
<p className="text-sm font-mono bg-muted rounded px-3 py-2">{user.username}</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<Tabs defaultValue="perfil">
<TabsList>
<TabsTrigger value="perfil">Perfil</TabsTrigger>
<TabsTrigger value="permisos" disabled={isSelf}>
Permisos
</TabsTrigger>
</TabsList>
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TabsContent value="perfil">
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="apellido"
render={({ field }) => (
<FormItem>
<FormLabel>Apellido</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email (opcional)</FormLabel>
<FormControl>
<Input {...field} type="email" disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apellido"
render={({ field }) => (
<FormItem>
<FormLabel>Apellido</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="rol"
render={({ field }) => (
<FormItem>
<FormLabel>Rol</FormLabel>
<FormControl>
<select
{...field}
disabled={isPending}
aria-label="Rol"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="admin">Admin</option>
<option value="cajero">Cajero</option>
<option value="reportes">Reportes</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email (opcional)</FormLabel>
<FormControl>
<Input {...field} type="email" disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="activo"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
disabled={isPending}
aria-label="Activo"
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="!mt-0">Activo</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name="rol"
render={({ field }) => (
<FormItem>
<FormLabel>Rol</FormLabel>
<FormControl>
<select
{...field}
disabled={isPending}
aria-label="Rol"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="admin">Admin</option>
<option value="cajero">Cajero</option>
<option value="reportes">Reportes</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</form>
</Form>
<FormField
control={form.control}
name="activo"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
disabled={isPending}
aria-label="Activo"
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="!mt-0">Activo</FormLabel>
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="permisos">
<PermisosEditor userId={userId} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -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'

View 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(),
)
})
})

View File

@@ -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()
})
})

View 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()
})
})

View File

@@ -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()
})
})

View File

@@ -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);
}

View File

@@ -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
{

View 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 });
}
}

View File

@@ -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);

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);
""");
}
}

View 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);
}
}

View File

@@ -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);
}
}