UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth #12
@@ -10,6 +10,7 @@ using SIGCM2.Application.Usuarios.Deactivate;
|
|||||||
using SIGCM2.Application.Usuarios.GetById;
|
using SIGCM2.Application.Usuarios.GetById;
|
||||||
using SIGCM2.Application.Usuarios.List;
|
using SIGCM2.Application.Usuarios.List;
|
||||||
using SIGCM2.Application.Usuarios.Reactivate;
|
using SIGCM2.Application.Usuarios.Reactivate;
|
||||||
|
using SIGCM2.Application.Usuarios.Permisos;
|
||||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||||
using SIGCM2.Application.Usuarios.Update;
|
using SIGCM2.Application.Usuarios.Update;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
@@ -225,10 +226,46 @@ public sealed class UsuariosController : ControllerBase
|
|||||||
var result = await _dispatcher.Send<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
|
var result = await _dispatcher.Send<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UDT-009: Permisos endpoints ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a usuario's role permissions, explicit grant/deny overrides, and computed effective set.
|
||||||
|
/// Requires administracion:usuarios:gestionar.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:int}/permisos")]
|
||||||
|
[RequirePermission("administracion:usuarios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetPermisos([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Send<GetUsuarioPermisosQuery, UsuarioPermisosDto>(
|
||||||
|
new GetUsuarioPermisosQuery(id));
|
||||||
|
return Ok(MapToPermisosResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto)
|
||||||
|
=> new(
|
||||||
|
RolPermisos: dto.RolPermisos,
|
||||||
|
Overrides: new PermisosOverridesShape(dto.Grant, dto.Deny),
|
||||||
|
Effective: dto.Effective);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── request body records ──────────────────────────────────────────────────────
|
// ── request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>UDT-009: Response shape for permisos endpoints.</summary>
|
||||||
|
public sealed record UsuarioPermisosResponse(
|
||||||
|
IReadOnlyList<string> RolPermisos,
|
||||||
|
PermisosOverridesShape Overrides,
|
||||||
|
IReadOnlyList<string> Effective);
|
||||||
|
|
||||||
|
/// <summary>UDT-009: The grant/deny override shape nested in UsuarioPermisosResponse.</summary>
|
||||||
|
public sealed record PermisosOverridesShape(
|
||||||
|
IReadOnlyList<string> Grant,
|
||||||
|
IReadOnlyList<string> Deny);
|
||||||
|
|
||||||
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
|
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
|
||||||
public sealed record CreateUsuarioRequest(
|
public sealed record CreateUsuarioRequest(
|
||||||
string? Username,
|
string? Username,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ using SIGCM2.Application.Usuarios.GetById;
|
|||||||
using SIGCM2.Application.Usuarios.List;
|
using SIGCM2.Application.Usuarios.List;
|
||||||
using SIGCM2.Application.Usuarios.Reactivate;
|
using SIGCM2.Application.Usuarios.Reactivate;
|
||||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||||
|
using SIGCM2.Application.Usuarios.Permisos;
|
||||||
using SIGCM2.Application.Usuarios.Update;
|
using SIGCM2.Application.Usuarios.Update;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
@@ -57,6 +58,9 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ChangeMyPasswordCommand, Unit>, ChangeMyPasswordCommandHandler>();
|
services.AddScoped<ICommandHandler<ChangeMyPasswordCommand, Unit>, ChangeMyPasswordCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>, ResetUsuarioPasswordCommandHandler>();
|
services.AddScoped<ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>, ResetUsuarioPasswordCommandHandler>();
|
||||||
|
|
||||||
|
// Usuarios/Permisos (UDT-009)
|
||||||
|
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||||
|
|
||||||
|
/// <summary>UDT-009: Query to get a user's role permissions, overrides, and effective set.</summary>
|
||||||
|
public sealed record GetUsuarioPermisosQuery(int Id);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UDT-009: Handles GET /api/v1/users/{id}/permisos.
|
||||||
|
/// Resolves role permissions + overrides + effective set.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetUsuarioPermisosQueryHandler
|
||||||
|
: ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>
|
||||||
|
{
|
||||||
|
private readonly IUsuarioRepository _usuarioRepo;
|
||||||
|
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||||
|
|
||||||
|
public GetUsuarioPermisosQueryHandler(
|
||||||
|
IUsuarioRepository usuarioRepo,
|
||||||
|
IRolPermisoRepository rolPermisoRepo)
|
||||||
|
{
|
||||||
|
_usuarioRepo = usuarioRepo;
|
||||||
|
_rolPermisoRepo = rolPermisoRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UsuarioPermisosDto> Handle(GetUsuarioPermisosQuery query)
|
||||||
|
{
|
||||||
|
var usuario = await _usuarioRepo.GetByIdAsync(query.Id)
|
||||||
|
?? throw new UsuarioNotFoundException(query.Id);
|
||||||
|
|
||||||
|
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(usuario.Rol);
|
||||||
|
var rolPermisos = rolPermisoEntities
|
||||||
|
.Select(p => p.Codigo)
|
||||||
|
.OrderBy(c => c, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var overrides = PermisosOverride.FromJson(usuario.PermisosJson);
|
||||||
|
|
||||||
|
var effective = PermisoResolver.Resolve(rolPermisos, overrides)
|
||||||
|
.OrderBy(c => c, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new UsuarioPermisosDto(
|
||||||
|
UsuarioId: usuario.Id,
|
||||||
|
Rol: usuario.Rol,
|
||||||
|
RolPermisos: rolPermisos,
|
||||||
|
Grant: overrides.Grant,
|
||||||
|
Deny: overrides.Deny,
|
||||||
|
Effective: effective);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SIGCM2.Application.Usuarios.Permisos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UDT-009: Response DTO for user permissions.
|
||||||
|
/// Contains role permissions, explicit overrides, and computed effective permissions.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UsuarioPermisosDto(
|
||||||
|
int UsuarioId,
|
||||||
|
string Rol,
|
||||||
|
IReadOnlyList<string> RolPermisos,
|
||||||
|
IReadOnlyList<string> Grant,
|
||||||
|
IReadOnlyList<string> Deny,
|
||||||
|
IReadOnlyList<string> Effective);
|
||||||
435
tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
Normal file
435
tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Usuarios;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for GET /api/v1/users/{id}/permisos and PUT /api/v1/users/{id}/permisos/overrides.
|
||||||
|
/// SUITE-B-GET-PERMISOS (GP-01..GP-06) + SUITE-B-PUT-OVERRIDES (PO-01..PO-11) — UDT-009.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class UsuarioPermisosEndpointTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string TestConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private const string AdminUsername = "admin";
|
||||||
|
private const string AdminPassword = "@Diego550@";
|
||||||
|
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private string? _adminToken;
|
||||||
|
|
||||||
|
public UsuarioPermisosEndpointTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string> GetBearerTokenAsync(string username, string password)
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password });
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("accessToken").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? token = null)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, url);
|
||||||
|
var tok = token ?? _adminToken;
|
||||||
|
if (tok is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tok);
|
||||||
|
if (body is not null)
|
||||||
|
request.Content = JsonContent.Create(body);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> GetAdminIdAsync()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
return await conn.QuerySingleAsync<int>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetPermisosJsonAsync(int userId, string json)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE dbo.Usuario SET PermisosJson = @Json WHERE Id = @Id",
|
||||||
|
new { Json = json, Id = userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetPermisosJsonAsync(int userId)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
return await conn.QuerySingleAsync<string>(
|
||||||
|
"SELECT PermisosJson FROM dbo.Usuario WHERE Id = @Id",
|
||||||
|
new { Id = userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SUITE-B-GET-PERMISOS ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GP-01: Admin → 200 con shape correcto {rolPermisos, overrides, effective}
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPermisos_Admin_Returns200_WithCorrectShape()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
|
||||||
|
Assert.True(json.TryGetProperty("rolPermisos", out var rolPermisos));
|
||||||
|
Assert.Equal(JsonValueKind.Array, rolPermisos.ValueKind);
|
||||||
|
|
||||||
|
Assert.True(json.TryGetProperty("overrides", out var overrides));
|
||||||
|
Assert.True(overrides.TryGetProperty("grant", out _));
|
||||||
|
Assert.True(overrides.TryGetProperty("deny", out _));
|
||||||
|
|
||||||
|
Assert.True(json.TryGetProperty("effective", out var effective));
|
||||||
|
Assert.Equal(JsonValueKind.Array, effective.ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GP-02: Usuario con overrides no vacíos → shape refleja overrides.grant, effective incluye el grant
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPermisos_UserWithGrant_EffectiveContainsGrantedPermiso()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
// Admin ya tiene 21 permisos del rol — grant con uno que tiene para probar idempotencia
|
||||||
|
await SetPermisosJsonAsync(adminId, """{"grant":["textos:editar"],"deny":[]}""");
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var overrides = json.GetProperty("overrides");
|
||||||
|
var grantArr = overrides.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||||
|
|
||||||
|
Assert.Contains("textos:editar", grantArr);
|
||||||
|
|
||||||
|
var effectiveArr = json.GetProperty("effective").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||||
|
Assert.Contains("textos:editar", effectiveArr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GP-03: Usuario con overrides vacíos → effective == rolPermisos, overrides vacíos
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPermisos_UserWithEmptyOverrides_EffectiveEqualsRolPermisos()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var rolPermisos = json.GetProperty("rolPermisos").EnumerateArray().Select(e => e.GetString()).OrderBy(x => x).ToArray();
|
||||||
|
var effective = json.GetProperty("effective").EnumerateArray().Select(e => e.GetString()).OrderBy(x => x).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(rolPermisos, effective);
|
||||||
|
|
||||||
|
var grantArr = json.GetProperty("overrides").GetProperty("grant").EnumerateArray().ToArray();
|
||||||
|
var denyArr = json.GetProperty("overrides").GetProperty("deny").EnumerateArray().ToArray();
|
||||||
|
Assert.Empty(grantArr);
|
||||||
|
Assert.Empty(denyArr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GP-04: Usuario inexistente → 404
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPermisos_NonExistentUser_Returns404()
|
||||||
|
{
|
||||||
|
var request = BuildRequest(HttpMethod.Get, "/api/v1/users/99999/permisos");
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GP-05: Sin permiso administracion:usuarios:gestionar → 403
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPermisos_WithoutRequiredPermission_Returns403()
|
||||||
|
{
|
||||||
|
// Create a cajero user without the required permission
|
||||||
|
var cajeroToken = await CreateCajeroAndGetTokenAsync("cajero_gp05");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos", token: cajeroToken);
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteUsuarioAsync("cajero_gp05");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GP-06: Sin auth → 401
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPermisos_WithoutAuth_Returns401()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SUITE-B-PUT-OVERRIDES ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// PO-01: Grant válido → 200, DB persistido, FechaModificacion actualizado
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_ValidGrant_Returns200_AndPersistsInDB()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = new[] { "textos:editar" }, deny = Array.Empty<string>() });
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var stored = await GetPermisosJsonAsync(adminId);
|
||||||
|
var parsed = JsonDocument.Parse(stored).RootElement;
|
||||||
|
var grant = parsed.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||||
|
Assert.Contains("textos:editar", grant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-02: Deny válido → 200
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_ValidDeny_Returns200()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = Array.Empty<string>(), deny = new[] { "ventas:contado:cobrar" } });
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var stored = await GetPermisosJsonAsync(adminId);
|
||||||
|
var parsed = JsonDocument.Parse(stored).RootElement;
|
||||||
|
var deny = parsed.GetProperty("deny").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||||
|
Assert.Contains("ventas:contado:cobrar", deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-03: Código fuera del catálogo → 400, error code "invalid-permiso-codes"
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_InvalidPermisoCode_Returns400_InvalidCodes()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = new[] { "modulo:fake:accion" }, deny = Array.Empty<string>() });
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var title = json.GetProperty("title").GetString();
|
||||||
|
Assert.Equal("invalid-permiso-codes", title);
|
||||||
|
|
||||||
|
// Should contain the list of invalid codes
|
||||||
|
Assert.True(json.TryGetProperty("invalidCodes", out var invalidCodes));
|
||||||
|
var codes = invalidCodes.EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||||
|
Assert.Contains("modulo:fake:accion", codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-04: Mismo código en grant Y deny → 400, "grant-deny-overlap"
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_GrantDenyOverlap_Returns400_Overlap()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = new[] { "textos:editar" }, deny = new[] { "textos:editar" } });
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var title = json.GetProperty("title").GetString();
|
||||||
|
Assert.Equal("grant-deny-overlap", title);
|
||||||
|
|
||||||
|
Assert.True(json.TryGetProperty("overlap", out var overlap));
|
||||||
|
var codes = overlap.EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||||
|
Assert.Contains("textos:editar", codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-05: Usuario inexistente → 404
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_NonExistentUser_Returns404()
|
||||||
|
{
|
||||||
|
var request = BuildRequest(HttpMethod.Put, "/api/v1/users/99999/permisos/overrides",
|
||||||
|
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() });
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-06: Sin permiso → 403
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_WithoutRequiredPermission_Returns403()
|
||||||
|
{
|
||||||
|
var cajeroToken = await CreateCajeroAndGetTokenAsync("cajero_po06");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() },
|
||||||
|
token: cajeroToken);
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteUsuarioAsync("cajero_po06");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-07: Sin auth → 401
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_WithoutAuth_Returns401()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { grant = Array.Empty<string>(), deny = Array.Empty<string>() })
|
||||||
|
};
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-08: Body JSON malformado → 400
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_MalformedBody_Returns400()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides")
|
||||||
|
{
|
||||||
|
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", _adminToken) },
|
||||||
|
Content = new StringContent("{grant: not-json", System.Text.Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-09: PUT idempotente — dos veces el mismo body → estado igual
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_Idempotent_SameBodyTwice_StateUnchanged()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
var body = new { grant = new[] { "textos:editar" }, deny = Array.Empty<string>() };
|
||||||
|
|
||||||
|
var r1 = await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", body));
|
||||||
|
var r2 = await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", body));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, r1.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, r2.StatusCode);
|
||||||
|
|
||||||
|
var stored = await GetPermisosJsonAsync(adminId);
|
||||||
|
var parsed = JsonDocument.Parse(stored).RootElement;
|
||||||
|
var grant = parsed.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||||
|
Assert.Single(grant);
|
||||||
|
Assert.Equal("textos:editar", grant[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-10: PUT con grants vacíos (reset overrides) → effective == rolPermisos
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_EmptyPayload_ResetsOverrides()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
|
||||||
|
// First set some overrides
|
||||||
|
await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = new[] { "textos:editar" }, deny = Array.Empty<string>() }));
|
||||||
|
|
||||||
|
// Then reset
|
||||||
|
var resetRequest = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() });
|
||||||
|
var response = await _client.SendAsync(resetRequest);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var stored = await GetPermisosJsonAsync(adminId);
|
||||||
|
Assert.Equal("""{"grant":[],"deny":[]}""", stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PO-11: Response de PUT tiene shape {rolPermisos, overrides, effective}
|
||||||
|
[Fact]
|
||||||
|
public async Task PutOverrides_ResponseHasCorrectShape()
|
||||||
|
{
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
|
||||||
|
|
||||||
|
var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
|
||||||
|
body: new { grant = Array.Empty<string>(), deny = Array.Empty<string>() });
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.TryGetProperty("rolPermisos", out _));
|
||||||
|
Assert.True(json.TryGetProperty("overrides", out var overrides));
|
||||||
|
Assert.True(overrides.TryGetProperty("grant", out _));
|
||||||
|
Assert.True(overrides.TryGetProperty("deny", out _));
|
||||||
|
Assert.True(json.TryGetProperty("effective", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string> CreateCajeroAndGetTokenAsync(string username)
|
||||||
|
{
|
||||||
|
// Seed a cajero user without administracion:usuarios:gestionar
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
|
||||||
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||||
|
VALUES (@Username, '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
|
||||||
|
'Cajero', 'Test', 'cajero', '{"grant":[],"deny":[]}', 1, 0)
|
||||||
|
""", new { Username = username });
|
||||||
|
|
||||||
|
return await GetBearerTokenAsync(username, "@Diego550@");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteUsuarioAsync(string username)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
// Must delete RefreshTokens first due to FK constraint
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
DELETE rt FROM dbo.RefreshToken rt
|
||||||
|
INNER JOIN dbo.Usuario u ON rt.UsuarioId = u.Id
|
||||||
|
WHERE u.Username = @Username
|
||||||
|
""", new { Username = username });
|
||||||
|
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user