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/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); + } +}