diff --git a/database/migrations/V009__activate_permisos_overrides.sql b/database/migrations/V009__activate_permisos_overrides.sql new file mode 100644 index 0000000..b95c910 --- /dev/null +++ b/database/migrations/V009__activate_permisos_overrides.sql @@ -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 diff --git a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs index 0efb172..43551a9 100644 --- a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs +++ b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs @@ -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; /// /// Authorization handler for . -/// Reads the "rol" claim from the authenticated user, queries -/// 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). /// public sealed class PermissionAuthorizationHandler : AuthorizationHandler { private readonly IRolPermisoRepository _rolPermisoRepo; + private readonly IUsuarioRepository _usuarioRepo; private readonly ILogger _logger; public PermissionAuthorizationHandler( IRolPermisoRepository rolPermisoRepo, + IUsuarioRepository usuarioRepo, ILogger 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)}")); diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index cfa7e61..15d27fd 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -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(command); return Ok(result); } + + // ── UDT-009: Permisos endpoints ─────────────────────────────────────────── + + /// + /// Gets a usuario's role permissions, explicit grant/deny overrides, and computed effective set. + /// Requires administracion:usuarios:gestionar. + /// + [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 GetPermisos([FromRoute] int id) + { + var result = await _dispatcher.Send( + new GetUsuarioPermisosQuery(id)); + return Ok(MapToPermisosResponse(result)); + } + + /// + /// Replaces the grant/deny override sets for a usuario. + /// Requires administracion:usuarios:gestionar. + /// + [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 UpdatePermisosOverrides( + [FromRoute] int id, + [FromBody] UpdatePermisosOverridesRequest request) + { + var command = new UpdateUsuarioPermisosOverridesCommand( + Id: id, + Grant: request.Grant ?? [], + Deny: request.Deny ?? []); + + var result = await _dispatcher.Send(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 ────────────────────────────────────────────────────── +/// UDT-009: Response shape for permisos endpoints. +public sealed record UsuarioPermisosResponse( + IReadOnlyList RolPermisos, + PermisosOverridesShape Overrides, + IReadOnlyList Effective); + +/// UDT-009: The grant/deny override shape nested in UsuarioPermisosResponse. +public sealed record PermisosOverridesShape( + IReadOnlyList Grant, + IReadOnlyList Deny); + +/// UDT-009: PUT permisos/overrides request body. +public sealed record UpdatePermisosOverridesRequest( + IReadOnlyList? Grant, + IReadOnlyList? Deny); + /// Create user request body — nullable to catch missing field scenarios. public sealed record CreateUsuarioRequest( string? Username, diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f583aed..f566bf3 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -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) diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs index bd1d554..31d10ae 100644 --- a/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs @@ -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 CountActiveAdminsAsync(CancellationToken ct = default); + + // UDT-009 + Task UpdatePermisosJsonAsync(int id, string permisosJson, DateTime fechaModificacion, CancellationToken ct = default); } diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs index 796a2aa..fc9a06e 100644 --- a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs @@ -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 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, diff --git a/src/api/SIGCM2.Application/Common/PermisoResolver.cs b/src/api/SIGCM2.Application/Common/PermisoResolver.cs new file mode 100644 index 0000000..b5fa640 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/PermisoResolver.cs @@ -0,0 +1,29 @@ +namespace SIGCM2.Application.Common; + +/// +/// UDT-009: Resolves effective permissions as (rolPermisos ∪ grant) \ deny. +/// Static helper — no dependencies, pure algorithm, freely testable. +/// +public static class PermisoResolver +{ + /// + /// 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. + /// + public static IReadOnlySet Resolve( + IEnumerable rolPermisos, + PermisosOverride overrides) + { + var set = new HashSet(rolPermisos, StringComparer.Ordinal); + + foreach (var g in overrides.Grant) + set.Add(g); + + foreach (var d in overrides.Deny) + set.Remove(d); + + return set; + } +} diff --git a/src/api/SIGCM2.Application/Common/PermisosOverride.cs b/src/api/SIGCM2.Application/Common/PermisosOverride.cs new file mode 100644 index 0000000..00b022c --- /dev/null +++ b/src/api/SIGCM2.Application/Common/PermisosOverride.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SIGCM2.Application.Common; + +/// +/// UDT-009: Overrides explícitos sobre permisos heredados del rol. +/// Shape: { "grant": [...], "deny": [...] } +/// +public sealed record PermisosOverride( + [property: JsonPropertyName("grant")] IReadOnlyList Grant, + [property: JsonPropertyName("deny")] IReadOnlyList Deny) +{ + /// No overrides — empty grant and deny. + public static readonly PermisosOverride Empty = + new(Array.Empty(), Array.Empty()); + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// + /// Parses 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) + /// + 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(trimmed, Options); + if (parsed is null) + return Empty; + + return new PermisosOverride( + parsed.Grant ?? Array.Empty(), + parsed.Deny ?? Array.Empty()); + } + catch (JsonException) + { + // Tolerant: malformed JSON → Empty (protects authorization handler) + return Empty; + } + } + + /// Serializes to canonical JSON shape. + public string ToJson() => JsonSerializer.Serialize(this, Options); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 1476873..d71d9c2 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -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, ChangeMyPasswordCommandHandler>(); services.AddScoped, ResetUsuarioPasswordCommandHandler>(); + // Usuarios/Permisos (UDT-009) + services.AddScoped, GetUsuarioPermisosQueryHandler>(); + services.AddScoped, UpdateUsuarioPermisosOverridesCommandHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQuery.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQuery.cs new file mode 100644 index 0000000..835668a --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQuery.cs @@ -0,0 +1,4 @@ +namespace SIGCM2.Application.Usuarios.Permisos; + +/// UDT-009: Query to get a user's role permissions, overrides, and effective set. +public sealed record GetUsuarioPermisosQuery(int Id); diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQueryHandler.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQueryHandler.cs new file mode 100644 index 0000000..bb5e8d8 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQueryHandler.cs @@ -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; + +/// +/// UDT-009: Handles GET /api/v1/users/{id}/permisos. +/// Resolves role permissions + overrides + effective set. +/// +public sealed class GetUsuarioPermisosQueryHandler + : ICommandHandler +{ + private readonly IUsuarioRepository _usuarioRepo; + private readonly IRolPermisoRepository _rolPermisoRepo; + + public GetUsuarioPermisosQueryHandler( + IUsuarioRepository usuarioRepo, + IRolPermisoRepository rolPermisoRepo) + { + _usuarioRepo = usuarioRepo; + _rolPermisoRepo = rolPermisoRepo; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommand.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommand.cs new file mode 100644 index 0000000..bd4953f --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Usuarios.Permisos; + +/// UDT-009: Command to replace the grant/deny override sets for a usuario. +public sealed record UpdateUsuarioPermisosOverridesCommand( + int Id, + IReadOnlyList Grant, + IReadOnlyList Deny); diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommandHandler.cs new file mode 100644 index 0000000..29a90e3 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommandHandler.cs @@ -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; + +/// +/// UDT-009: Handles PUT /api/v1/users/{id}/permisos/overrides. +/// Validates overlap and catalog existence, persists new overrides, returns updated effective set. +/// +public sealed class UpdateUsuarioPermisosOverridesCommandHandler + : ICommandHandler +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/UsuarioPermisosDto.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/UsuarioPermisosDto.cs new file mode 100644 index 0000000..fd0233f --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/UsuarioPermisosDto.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Usuarios.Permisos; + +/// +/// UDT-009: Response DTO for user permissions. +/// Contains role permissions, explicit overrides, and computed effective permissions. +/// +public sealed record UsuarioPermisosDto( + int UsuarioId, + string Rol, + IReadOnlyList RolPermisos, + IReadOnlyList Grant, + IReadOnlyList Deny, + IReadOnlyList Effective); diff --git a/src/api/SIGCM2.Domain/Entities/Usuario.cs b/src/api/SIGCM2.Domain/Entities/Usuario.cs index aab7f18..deefe66 100644 --- a/src/api/SIGCM2.Domain/Entities/Usuario.cs +++ b/src/api/SIGCM2.Domain/Entities/Usuario.cs @@ -47,7 +47,7 @@ public sealed class Usuario /// /// 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. /// 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); + /// + /// UDT-009: Returns a new instance with PermisosJson replaced. + /// Sets FechaModificacion = UtcNow. + /// Accepts raw JSON string so Domain stays free of Application dependencies. + /// + 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); + /// /// Returns a new instance with only UltimoLogin updated. /// Does NOT touch FechaModificacion. diff --git a/src/api/SIGCM2.Domain/Exceptions/GrantDenyOverlapException.cs b/src/api/SIGCM2.Domain/Exceptions/GrantDenyOverlapException.cs new file mode 100644 index 0000000..5e2ac12 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/GrantDenyOverlapException.cs @@ -0,0 +1,16 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// UDT-009: Thrown when the same code appears in both grant and deny arrays. +/// Maps to 400 { title: "grant-deny-overlap", overlap: [...] }. +/// +public sealed class GrantDenyOverlapException : Exception +{ + public IReadOnlyList Overlap { get; } + + public GrantDenyOverlapException(IReadOnlyList overlap) + : base($"Los siguientes códigos aparecen en grant y deny simultáneamente: {string.Join(", ", overlap)}") + { + Overlap = overlap; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/InvalidPermisoCodesException.cs b/src/api/SIGCM2.Domain/Exceptions/InvalidPermisoCodesException.cs new file mode 100644 index 0000000..048569d --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/InvalidPermisoCodesException.cs @@ -0,0 +1,16 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// UDT-009: Thrown when grant or deny arrays contain codes not in the Permiso catalog. +/// Maps to 400 { title: "invalid-permiso-codes", invalidCodes: [...] }. +/// +public sealed class InvalidPermisoCodesException : Exception +{ + public IReadOnlyList InvalidCodes { get; } + + public InvalidPermisoCodesException(IReadOnlyList codes) + : base($"Códigos de permiso inexistentes en el catálogo: {string.Join(", ", codes)}") + { + InvalidCodes = codes; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs index 263aa20..503b2e6 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs @@ -226,6 +226,27 @@ public sealed class UsuarioRepository : IUsuarioRepository return await connection.ExecuteScalarAsync(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) diff --git a/src/api/SIGCM2.Infrastructure/Security/JwtService.cs b/src/api/SIGCM2.Infrastructure/Security/JwtService.cs index 02ac8c7..85ab73e 100644 --- a/src/api/SIGCM2.Infrastructure/Security/JwtService.cs +++ b/src/api/SIGCM2.Infrastructure/Security/JwtService.cs @@ -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; } + /// + /// 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). + /// 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 { 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(permisosJson) ?? []; - } - catch - { - return []; - } - } } diff --git a/src/web/package-lock.json b/src/web/package-lock.json index 3b2ca0e..db4dd82 100644 --- a/src/web/package-lock.json +++ b/src/web/package-lock.json @@ -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", diff --git a/src/web/package.json b/src/web/package.json index 0d8e9df..2f0bde3 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -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", diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index b4926b2..8286e98 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -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() { + ) } diff --git a/src/web/src/components/ui/tabs.tsx b/src/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..9f2c71a --- /dev/null +++ b/src/web/src/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/web/src/features/users/api/getUserPermisos.ts b/src/web/src/features/users/api/getUserPermisos.ts new file mode 100644 index 0000000..5862100 --- /dev/null +++ b/src/web/src/features/users/api/getUserPermisos.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UsuarioPermisos } from '../types' + +export async function getUserPermisos(id: number): Promise { + const response = await axiosClient.get(`/api/v1/users/${id}/permisos`) + return response.data +} diff --git a/src/web/src/features/users/api/updateUserPermisosOverrides.ts b/src/web/src/features/users/api/updateUserPermisosOverrides.ts new file mode 100644 index 0000000..3eaa7b8 --- /dev/null +++ b/src/web/src/features/users/api/updateUserPermisosOverrides.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UsuarioPermisos, UpdatePermisosOverridesPayload } from '../types' + +export async function updateUserPermisosOverrides( + id: number, + payload: UpdatePermisosOverridesPayload, +): Promise { + const response = await axiosClient.put( + `/api/v1/users/${id}/permisos/overrides`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/users/components/PermisosEditor.tsx b/src/web/src/features/users/components/PermisosEditor.tsx new file mode 100644 index 0000000..5c52747 --- /dev/null +++ b/src/web/src/features/users/components/PermisosEditor.tsx @@ -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 { + const map = new Map() + 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 + const [states, setStates] = useState>(new Map()) + const [saveError, setSaveError] = useState(null) + + // Initialize state from loaded data + useEffect(() => { + if (!permisoData) return + const map = new Map() + // 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

Cargando permisos...

+ } + + if (!permisoData || !catalogo) { + return ( + + + No se pudieron cargar los permisos. + + ) + } + + // 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 ( +
+ {saveError && ( + + + {saveError} + + )} + + {Array.from(grupos.entries()).map(([modulo, permisos]) => ( +
+

+ {modulo} +

+
+ {permisos.map((p) => { + const currentState = getState(p.codigo) + return ( +
+
+ {p.nombre} + {p.codigo} +
+
+ {(['heredado', 'concedido', 'denegado'] as PermisoOverrideState[]).map( + (state) => ( + + ), + )} +
+
+ ) + })} +
+
+ ))} + +
+ +
+
+ ) +} diff --git a/src/web/src/features/users/hooks/useUpdateUserPermisosOverrides.ts b/src/web/src/features/users/hooks/useUpdateUserPermisosOverrides.ts new file mode 100644 index 0000000..654ebed --- /dev/null +++ b/src/web/src/features/users/hooks/useUpdateUserPermisosOverrides.ts @@ -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({ + 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'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useUserPermisos.ts b/src/web/src/features/users/hooks/useUserPermisos.ts new file mode 100644 index 0000000..67decbc --- /dev/null +++ b/src/web/src/features/users/hooks/useUserPermisos.ts @@ -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 { + return useQuery({ + queryKey: userPermisosQueryKey(id), + queryFn: () => getUserPermisos(id), + staleTime: 15_000, + enabled: id > 0, + }) +} diff --git a/src/web/src/features/users/pages/UserEditPage.tsx b/src/web/src/features/users/pages/UserEditPage.tsx index 8ac581d..78605e7 100644 --- a/src/web/src/features/users/pages/UserEditPage.tsx +++ b/src/web/src/features/users/pages/UserEditPage.tsx @@ -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 (

Editar Usuario

- {loggedUserId !== userId && } + {!isSelf && } @@ -129,105 +133,120 @@ export function UserEditPage() {

{user.username}

-
- - {backendError && ( - - - {backendError} - - )} + + + Perfil + + Permisos + + - ( - - Nombre - - - - - - )} - /> + + + + {backendError && ( + + + {backendError} + + )} - ( - - Apellido - - - - - - )} - /> + ( + + Nombre + + + + + + )} + /> - ( - - Email (opcional) - - - - - - )} - /> + ( + + Apellido + + + + + + )} + /> - ( - - Rol - - - - - - )} - /> + ( + + Email (opcional) + + + + + + )} + /> - ( - - - - - Activo - - )} - /> + ( + + Rol + + + + + + )} + /> - - - + ( + + + + + Activo + + )} + /> + + + + + + + + + +
) } diff --git a/src/web/src/features/users/types.ts b/src/web/src/features/users/types.ts index b2a1732..80c44b2 100644 --- a/src/web/src/features/users/types.ts +++ b/src/web/src/features/users/types.ts @@ -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' diff --git a/src/web/src/tests/features/users/PermisosEditor.test.tsx b/src/web/src/tests/features/users/PermisosEditor.test.tsx new file mode 100644 index 0000000..067e4e6 --- /dev/null +++ b/src/web/src/tests/features/users/PermisosEditor.test.tsx @@ -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( + + + + + , + ) +} + +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(), + ) + }) +}) diff --git a/src/web/src/tests/features/users/UserEditPage.test.tsx b/src/web/src/tests/features/users/UserEditPage.test.tsx index 5b8882c..c9284c4 100644 --- a/src/web/src/tests/features/users/UserEditPage.test.tsx +++ b/src/web/src/tests/features/users/UserEditPage.test.tsx @@ -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() + }) }) diff --git a/src/web/src/tests/features/users/getUserPermisos.test.ts b/src/web/src/tests/features/users/getUserPermisos.test.ts new file mode 100644 index 0000000..2533c6e --- /dev/null +++ b/src/web/src/tests/features/users/getUserPermisos.test.ts @@ -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() + }) +}) diff --git a/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts b/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts new file mode 100644 index 0000000..b46707f --- /dev/null +++ b/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts @@ -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() + }) +}) diff --git a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs index 408bcb7..53606e2 100644 --- a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs +++ b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs @@ -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; /// -/// 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. /// public sealed class PermissionAuthorizationHandlerTests { private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For(); + private readonly IUsuarioRepository _usuarioRepo = Substitute.For(); private readonly PermissionAuthorizationHandler _handler; public PermissionAuthorizationHandlerTests() { + // Default: usuario repo returns null (no overrides) unless overridden in individual tests + _usuarioRepo.GetByIdAsync(Arg.Any(), Arg.Any()) + .Returns((Usuario?)null); + _handler = new PermissionAuthorizationHandler( _rolPermisoRepo, + _usuarioRepo, NullLogger.Instance); } // ── Helpers ────────────────────────────────────────────────────────────── - private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue) + /// Creates an authenticated user with rol claim and sub=42 (needed by UDT-009 handler). + 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()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + _usuarioRepo.GetByIdAsync(42, Arg.Any()) + .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()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + _usuarioRepo.GetByIdAsync(42, Arg.Any()) + .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()) + .Returns(new List { MakePermiso(21, "administracion:permisos:ver") } + .AsReadOnly() as IReadOnlyList); + _usuarioRepo.GetByIdAsync(1, Arg.Any()) + .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()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + _usuarioRepo.GetByIdAsync(42, Arg.Any()) + .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()); + } + + // 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()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + _usuarioRepo.GetByIdAsync(42, Arg.Any()) + .Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}""")); + + var context = MakeContext(user, requirement); + await _handler.HandleAsync(context); + + await _usuarioRepo.Received(1).GetByIdAsync(42, Arg.Any()); + } + + // 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(), Arg.Any()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + + var context = MakeContext(user, requirement); + await _handler.HandleAsync(context); + + Assert.False(context.HasSucceeded); + await _usuarioRepo.DidNotReceive().GetByIdAsync(Arg.Any(), Arg.Any()); + } + + // 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()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + _usuarioRepo.GetByIdAsync(9999, Arg.Any()) + .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); } diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index 9d89b3a..abaa702 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -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 { diff --git a/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs new file mode 100644 index 0000000..7571acb --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs @@ -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; + +/// +/// 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. +/// +[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 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(); + 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 GetAdminIdAsync() + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync("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 GetPermisosJsonAsync(int userId) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync( + "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(); + + 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(); + 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(); + 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() }); + 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(), 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() }); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var json = await response.Content.ReadFromJsonAsync(); + 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(); + 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(), deny = Array.Empty() }); + 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(), deny = Array.Empty() }, + 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(), deny = Array.Empty() }) + }; + 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() }; + + 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() })); + + // Then reset + var resetRequest = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", + body: new { grant = Array.Empty(), deny = Array.Empty() }); + 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(), deny = Array.Empty() }); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadFromJsonAsync(); + 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 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 }); + } +} diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs index f96306c..e009f00 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs @@ -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()) + .Returns(adminPermisos.AsReadOnly() as IReadOnlyList); + + // 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 + { + 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()) + .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); + + // 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 + { + 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()) + .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); + + // 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()) + .Returns(new List().AsReadOnly() as IReadOnlyList); + + var result = await _handler.Handle(new LoginCommand("admin", "pass")); + + // Must be string[] — no grant/deny wrapping + Assert.IsType(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 + { + MakePermiso(10, "ventas:contado:crear"), + MakePermiso(11, "ventas:contado:cobrar"), + }; + _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) + .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); + + 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); diff --git a/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs b/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs new file mode 100644 index 0000000..bb78d3f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs @@ -0,0 +1,134 @@ +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Tests.Common; + +/// +/// SUITE-B-RESOLVER — R-01 a R-09 (UDT-009) +/// Unit tests for PermisoResolver.Resolve static helper. +/// Pure unit: no DB, no mocks. +/// +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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs b/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs new file mode 100644 index 0000000..fa95d82 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs @@ -0,0 +1,116 @@ +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Tests.Common; + +/// +/// SUITE-B-PERMISOS-OVERRIDE-PARSING — P-01 a P-08 (UDT-009) +/// Unit tests for PermisosOverride.FromJson parsing logic. +/// +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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs index cea3266..c080c8f 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs @@ -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 diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs new file mode 100644 index 0000000..6ffe69a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs @@ -0,0 +1,131 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// Integration tests for IUsuarioRepository.UpdatePermisosJsonAsync (UDT-009). +/// Uses SIGCM2_Test database directly. +/// +[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( + "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( + "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); + """); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs new file mode 100644 index 0000000..8566027 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs @@ -0,0 +1,256 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009) +/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync. +/// Uses SIGCM2_Test database directly. +/// +[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(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( + "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( + "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( + "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( + "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); + """); + } + + /// + /// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync. + /// + 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); + } +} diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 783a672..2bf7c92 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -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); } + + /// + /// 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. + /// + 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); + } }