UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth #12
29
src/api/SIGCM2.Application/Common/PermisoResolver.cs
Normal file
29
src/api/SIGCM2.Application/Common/PermisoResolver.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Resolves effective permissions as (rolPermisos ∪ grant) \ deny.
|
||||
/// Static helper — no dependencies, pure algorithm, freely testable.
|
||||
/// </summary>
|
||||
public static class PermisoResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the effective permission set for a user.
|
||||
/// Algorithm: start with role permissions, add grant, remove deny.
|
||||
/// Deny always wins over grant (last operation). Idempotent on duplicates.
|
||||
/// Never throws.
|
||||
/// </summary>
|
||||
public static IReadOnlySet<string> Resolve(
|
||||
IEnumerable<string> rolPermisos,
|
||||
PermisosOverride overrides)
|
||||
{
|
||||
var set = new HashSet<string>(rolPermisos, StringComparer.Ordinal);
|
||||
|
||||
foreach (var g in overrides.Grant)
|
||||
set.Add(g);
|
||||
|
||||
foreach (var d in overrides.Deny)
|
||||
set.Remove(d);
|
||||
|
||||
return set;
|
||||
}
|
||||
}
|
||||
60
src/api/SIGCM2.Application/Common/PermisosOverride.cs
Normal file
60
src/api/SIGCM2.Application/Common/PermisosOverride.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-009: Overrides explícitos sobre permisos heredados del rol.
|
||||
/// Shape: { "grant": [...], "deny": [...] }
|
||||
/// </summary>
|
||||
public sealed record PermisosOverride(
|
||||
[property: JsonPropertyName("grant")] IReadOnlyList<string> Grant,
|
||||
[property: JsonPropertyName("deny")] IReadOnlyList<string> Deny)
|
||||
{
|
||||
/// <summary>No overrides — empty grant and deny.</summary>
|
||||
public static readonly PermisosOverride Empty =
|
||||
new(Array.Empty<string>(), Array.Empty<string>());
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses <paramref name="json"/> tolerantly:
|
||||
/// - null / "" / whitespace → Empty
|
||||
/// - starts with '[' (legacy '[]' or '["*"]') → Empty (backward compat)
|
||||
/// - valid JSON object with grant/deny → parsed record
|
||||
/// - malformed or wrong-shape JSON → Empty (tolerant in runtime)
|
||||
/// </summary>
|
||||
public static PermisosOverride FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Empty;
|
||||
|
||||
var trimmed = json.Trim();
|
||||
|
||||
// Legacy: '[]' or '["*"]' — array shape, treat as no overrides
|
||||
if (trimmed.StartsWith('['))
|
||||
return Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<PermisosOverride>(trimmed, Options);
|
||||
if (parsed is null)
|
||||
return Empty;
|
||||
|
||||
return new PermisosOverride(
|
||||
parsed.Grant ?? Array.Empty<string>(),
|
||||
parsed.Deny ?? Array.Empty<string>());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Tolerant: malformed JSON → Empty (protects authorization handler)
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes to canonical JSON shape.</summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
}
|
||||
134
tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
Normal file
134
tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Common;
|
||||
|
||||
/// <summary>
|
||||
/// SUITE-B-RESOLVER — R-01 a R-09 (UDT-009)
|
||||
/// Unit tests for PermisoResolver.Resolve static helper.
|
||||
/// Pure unit: no DB, no mocks.
|
||||
/// </summary>
|
||||
public sealed class PermisoResolverTests
|
||||
{
|
||||
// R-01: Override vacío → effective = solo rol sin cambios
|
||||
[Fact]
|
||||
public void Resolve_EmptyOverride_ReturnsRolPermisosUnchanged()
|
||||
{
|
||||
var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-02: Grant nuevo permiso → se agrega al set
|
||||
[Fact]
|
||||
public void Resolve_GrantNewPermiso_AddsToEffective()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Contains("C", result);
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
// R-03: Deny permiso del rol → se quita del set
|
||||
[Fact]
|
||||
public void Resolve_DenyRolPermiso_RemovesFromEffective()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: [], Deny: ["A"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.DoesNotContain("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(1, result.Count);
|
||||
}
|
||||
|
||||
// R-04: Grant duplicado (ya en rol) → idempotente, no duplicados
|
||||
[Fact]
|
||||
public void Resolve_GrantDuplicated_Idempotent()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["B"], Deny: []);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count); // no duplicates
|
||||
}
|
||||
|
||||
// R-05: Deny código inexistente en rol → no-op
|
||||
[Fact]
|
||||
public void Resolve_DenyNonExistentCode_NoOp()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: [], Deny: ["X"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-06: Grant + Deny combinados
|
||||
[Fact]
|
||||
public void Resolve_GrantAndDeny_Combined()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: ["A"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A", "B"], overrides);
|
||||
|
||||
Assert.DoesNotContain("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Contains("C", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-07: PermisosOverride.Empty literal → mismo que rol
|
||||
[Fact]
|
||||
public void Resolve_EmptyLiteral_ReturnsRolPermisosOnly()
|
||||
{
|
||||
var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
|
||||
|
||||
Assert.Contains("A", result);
|
||||
Assert.Contains("B", result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// R-08: Rol vacío + grant → effective = solo el grant
|
||||
[Fact]
|
||||
public void Resolve_EmptyRol_WithGrant_ReturnsGrant()
|
||||
{
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
|
||||
|
||||
var result = PermisoResolver.Resolve([], overrides);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Contains("C", result);
|
||||
}
|
||||
|
||||
// R-09: Rol vacío + sin overrides → effective vacío
|
||||
[Fact]
|
||||
public void Resolve_EmptyRol_EmptyOverrides_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisoResolver.Resolve([], PermisosOverride.Empty);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// Extra: Deny gana sobre grant explícito (defense en runtime — validator lo bloquea antes)
|
||||
[Fact]
|
||||
public void Resolve_DenyWinsOver_ExplicitGrant()
|
||||
{
|
||||
// Mismo código en grant y deny → deny gana (algoritmo: grant primero, deny al final)
|
||||
var overrides = new PermisosOverride(Grant: ["C"], Deny: ["C"]);
|
||||
|
||||
var result = PermisoResolver.Resolve(["A"], overrides);
|
||||
|
||||
Assert.DoesNotContain("C", result);
|
||||
Assert.Contains("A", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Common;
|
||||
|
||||
/// <summary>
|
||||
/// SUITE-B-PERMISOS-OVERRIDE-PARSING — P-01 a P-08 (UDT-009)
|
||||
/// Unit tests for PermisosOverride.FromJson parsing logic.
|
||||
/// </summary>
|
||||
public sealed class PermisosOverrideParsingTests
|
||||
{
|
||||
// P-01: JSON válido con grant y deny → record correcto
|
||||
[Fact]
|
||||
public void FromJson_ValidGrantAndDeny_ReturnsParsedRecord()
|
||||
{
|
||||
const string json = """{"grant":["textos:editar"],"deny":["ventas:contado:cobrar"]}""";
|
||||
|
||||
var result = PermisosOverride.FromJson(json);
|
||||
|
||||
Assert.Single(result.Grant);
|
||||
Assert.Equal("textos:editar", result.Grant[0]);
|
||||
Assert.Single(result.Deny);
|
||||
Assert.Equal("ventas:contado:cobrar", result.Deny[0]);
|
||||
}
|
||||
|
||||
// P-02: JSON vacío canónico → equivalente a Empty
|
||||
[Fact]
|
||||
public void FromJson_EmptyCanonical_ReturnsEmpty()
|
||||
{
|
||||
const string json = """{"grant":[],"deny":[]}""";
|
||||
|
||||
var result = PermisosOverride.FromJson(json);
|
||||
|
||||
Assert.Empty(result.Grant);
|
||||
Assert.Empty(result.Deny);
|
||||
}
|
||||
|
||||
// P-03: Legacy "[]" → Empty (backward compat)
|
||||
[Fact]
|
||||
public void FromJson_LegacyEmptyArray_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson("[]");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-04: Legacy '["*"]' → Empty (backward compat)
|
||||
[Fact]
|
||||
public void FromJson_LegacyWildcard_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson("""["*"]""");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-05: null → Empty
|
||||
[Fact]
|
||||
public void FromJson_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson(null);
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-06a: string vacío → Empty
|
||||
[Fact]
|
||||
public void FromJson_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson(string.Empty);
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-06b: whitespace → Empty
|
||||
[Fact]
|
||||
public void FromJson_Whitespace_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson(" ");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-07: JSON malformado → Empty (tolerante en runtime)
|
||||
[Fact]
|
||||
public void FromJson_MalformedJson_ReturnsEmpty()
|
||||
{
|
||||
// Nota: FromJson es tolerante — catch(JsonException) → Empty.
|
||||
// Ver tasks note 2: "P-07/P-08 verifican que JSON malformado → Empty (no FormatException)"
|
||||
var result = PermisosOverride.FromJson("{grant:[");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// P-08: JSON de tipo incorrecto (número) → Empty (tolerante)
|
||||
[Fact]
|
||||
public void FromJson_WrongJsonType_ReturnsEmpty()
|
||||
{
|
||||
var result = PermisosOverride.FromJson("42");
|
||||
|
||||
Assert.Same(PermisosOverride.Empty, result);
|
||||
}
|
||||
|
||||
// Extra: ToJson produce JSON re-parseable con shape correcto
|
||||
[Fact]
|
||||
public void ToJson_ProducesCanonicalJson()
|
||||
{
|
||||
var overrides = new PermisosOverride(
|
||||
Grant: new[] { "textos:editar" },
|
||||
Deny: new[] { "ventas:contado:cobrar" });
|
||||
|
||||
var json = overrides.ToJson();
|
||||
var reparsed = PermisosOverride.FromJson(json);
|
||||
|
||||
Assert.Equal(overrides.Grant, reparsed.Grant);
|
||||
Assert.Equal(overrides.Deny, reparsed.Deny);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user