feat(api): BATCH 5 - PermisosController + tests HTTP [UDT-005]

This commit is contained in:
2026-04-15 15:42:03 -03:00
parent be2257a9bf
commit 4913a35d06
3 changed files with 511 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Application.Permisos.GetByRol;
using SIGCM2.Application.Permisos.List;
namespace SIGCM2.Api.Controllers;
[ApiController]
[Route("api/v1")]
[Authorize(Roles = "admin")]
public sealed class PermisosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<AssignPermisosToRolCommand> _assignValidator;
public PermisosController(
IDispatcher dispatcher,
IValidator<AssignPermisosToRolCommand> assignValidator)
{
_dispatcher = dispatcher;
_assignValidator = assignValidator;
}
/// <summary>Lists all permisos in the canonical catalog. Requires admin role.</summary>
[HttpGet("permisos")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListPermisos()
{
var result = await _dispatcher.Send<ListPermisosQuery, IReadOnlyList<PermisoDto>>(new ListPermisosQuery());
return Ok(result);
}
/// <summary>Gets all permisos assigned to a rol. Requires admin role.</summary>
[HttpGet("roles/{codigo}/permisos")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetRolPermisos(string codigo)
{
var result = await _dispatcher.Send<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>(
new GetRolPermisosQuery(codigo));
return Ok(result);
}
/// <summary>
/// Replace-set: replaces the full permiso assignment for a rol.
/// Returns the updated permiso set (200). Requires admin role.
/// </summary>
[HttpPut("roles/{codigo}/permisos")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AssignPermisos(string codigo, [FromBody] AssignPermisosRequest request)
{
var codigos = request.Codigos ?? [];
var command = new AssignPermisosToRolCommand(
RolCodigo: codigo,
Codigos: codigos);
var validation = await _assignValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>(command);
return Ok(result);
}
}
public sealed record AssignPermisosRequest(IReadOnlyList<string>? Codigos);

View File

@@ -83,6 +83,18 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
case PermisoNotFoundException permisoNotFoundEx:
context.Result = new ObjectResult(new
{
error = "permiso_not_found",
message = permisoNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case RolAlreadyExistsException rolExistsEx: case RolAlreadyExistsException rolExistsEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new
{ {

View File

@@ -0,0 +1,416 @@
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.Permisos;
/// <summary>
/// Integration tests for /api/v1/permisos and /api/v1/roles/{codigo}/permisos (UDT-005).
/// RED: written before PermisosController exists.
/// </summary>
[Collection("ApiIntegration")]
public sealed class PermisosEndpointTests : 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;
public PermisosEndpointTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
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 });
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Login failed ({(int)response.StatusCode}): {body}");
}
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
{
var request = new HttpRequestMessage(method, url);
if (bearerToken is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (body is not null)
request.Content = JsonContent.Create(body);
return request;
}
private static async Task RestoreCajeroPermisosAsync()
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
// Remove any test-added permisos from cajero
await conn.ExecuteAsync("""
DELETE rp FROM dbo.RolPermiso rp
JOIN dbo.Rol r ON r.Id = rp.RolId
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE r.Codigo = 'cajero'
AND p.Codigo NOT IN (
'ventas:contado:crear','ventas:contado:modificar',
'ventas:contado:cobrar','ventas:contado:facturar'
);
""");
// Re-add missing canonical cajero permisos
await conn.ExecuteAsync("""
SET QUOTED_IDENTIFIER ON;
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('cajero','ventas:contado:crear'),
('cajero','ventas:contado:modificar'),
('cajero','ventas:contado:cobrar'),
('cajero','ventas:contado:facturar')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
""");
}
private async Task<string> CreateNonAdminUserAndGetTokenAsync(string username, string rol = "cajero")
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
// Create non-admin user via API
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
{
username,
password = "Secure1234!",
nombre = "Non",
apellido = "Admin",
email = (string?)null,
rol
}, adminToken);
var mkUserResp = await _client.SendAsync(mkUser);
if (mkUserResp.StatusCode != HttpStatusCode.Created && mkUserResp.StatusCode != HttpStatusCode.Conflict)
Assert.Fail($"Seed non-admin user failed: {mkUserResp.StatusCode}");
return await GetBearerTokenAsync(username, "Secure1234!");
}
private static async Task DeleteUsuarioIfExistsAsync(string username)
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync("""
DELETE rt FROM dbo.RefreshToken rt
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
WHERE u.Username = @Username
""", new { Username = username });
await conn.ExecuteAsync(
"DELETE FROM dbo.Usuario WHERE Username = @Username",
new { Username = username });
}
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
[Fact]
public async Task GetPermisos_WithAdmin_Returns200With18Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(18, list.GetArrayLength());
}
[Fact]
public async Task GetPermisos_WithoutToken_Returns401()
{
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, "/api/v1/permisos"));
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task GetPermisos_WithNonAdminToken_Returns403()
{
const string username = "perm_nonadmin_list";
try
{
var token = await CreateNonAdminUserAndGetTokenAsync(username);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
}
finally
{
await DeleteUsuarioIfExistsAsync(username);
}
}
[Fact]
public async Task GetPermisos_ResponseContainsCodigoNombreFields()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
var first = list.EnumerateArray().First();
Assert.True(first.TryGetProperty("codigo", out _), "Response item missing 'codigo' field");
Assert.True(first.TryGetProperty("nombre", out _), "Response item missing 'nombre' field");
}
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact]
public async Task GetRolPermisos_AdminRol_Returns200With18Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(18, list.GetArrayLength());
}
[Fact]
public async Task GetRolPermisos_CajeroRol_Returns200With4Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/cajero/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(4, list.GetArrayLength());
}
[Fact]
public async Task GetRolPermisos_ReportesRol_Returns200WithEmptyArray()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/reportes/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(0, list.GetArrayLength());
}
[Fact]
public async Task GetRolPermisos_InexistentRol_Returns404()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/rol_inexistente_xyz/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
[Fact]
public async Task GetRolPermisos_WithoutToken_Returns401()
{
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos"));
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task GetRolPermisos_WithNonAdminToken_Returns403()
{
const string username = "perm_nonadmin_getRol";
try
{
var token = await CreateNonAdminUserAndGetTokenAsync(username);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
}
finally
{
await DeleteUsuarioIfExistsAsync(username);
}
}
// ── PUT /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact]
public async Task PutRolPermisos_ValidAssignment_Returns200WithUpdatedSet()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
try
{
using var req = BuildRequest(
HttpMethod.Put,
"/api/v1/roles/cajero/permisos",
new { codigos = new[] { "ventas:contado:crear" } },
token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(1, list.GetArrayLength());
Assert.Equal("ventas:contado:crear", list[0].GetProperty("codigo").GetString());
}
finally
{
await RestoreCajeroPermisosAsync();
}
}
[Fact]
public async Task PutRolPermisos_ThenGet_ReturnsUpdatedSet()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
try
{
// Assign 1 permiso to cajero
using var putReq = BuildRequest(
HttpMethod.Put,
"/api/v1/roles/cajero/permisos",
new { codigos = new[] { "textos:editar" } },
token);
var putResp = await _client.SendAsync(putReq);
Assert.Equal(HttpStatusCode.OK, putResp.StatusCode);
// GET should now return 1 item
using var getReq = BuildRequest(HttpMethod.Get, "/api/v1/roles/cajero/permisos", bearerToken: token);
var getResp = await _client.SendAsync(getReq);
Assert.Equal(HttpStatusCode.OK, getResp.StatusCode);
var list = await getResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(1, list.GetArrayLength());
Assert.Equal("textos:editar", list[0].GetProperty("codigo").GetString());
}
finally
{
await RestoreCajeroPermisosAsync();
}
}
[Fact]
public async Task PutRolPermisos_Idempotent_TwoCallsSameResult()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
try
{
var body = new { codigos = new[] { "ventas:contado:crear", "textos:editar" } };
using var req1 = BuildRequest(HttpMethod.Put, "/api/v1/roles/cajero/permisos", body, token);
var resp1 = await _client.SendAsync(req1);
Assert.Equal(HttpStatusCode.OK, resp1.StatusCode);
using var req2 = BuildRequest(HttpMethod.Put, "/api/v1/roles/cajero/permisos", body, token);
var resp2 = await _client.SendAsync(req2);
Assert.Equal(HttpStatusCode.OK, resp2.StatusCode);
var list2 = await resp2.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(2, list2.GetArrayLength());
}
finally
{
await RestoreCajeroPermisosAsync();
}
}
[Fact]
public async Task PutRolPermisos_AdminWithEmptyList_Returns400()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(
HttpMethod.Put,
"/api/v1/roles/admin/permisos",
new { codigos = Array.Empty<string>() },
token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task PutRolPermisos_NonExistentPermiso_Returns404()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(
HttpMethod.Put,
"/api/v1/roles/cajero/permisos",
new { codigos = new[] { "permiso:no:existe" } },
token);
var resp = await _client.SendAsync(req);
// Validator rejects unknown codes with 400 (not in catalog) before handler can 404
// The validator checks Permiso.Todos — if code not in static catalog → 400
Assert.True(
resp.StatusCode == HttpStatusCode.BadRequest || resp.StatusCode == HttpStatusCode.NotFound,
$"Expected 400 or 404 but got {resp.StatusCode}");
}
[Fact]
public async Task PutRolPermisos_InexistentRol_Returns404()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(
HttpMethod.Put,
"/api/v1/roles/rol_inexistente_xyz/permisos",
new { codigos = new[] { "ventas:contado:crear" } },
token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
[Fact]
public async Task PutRolPermisos_WithoutToken_Returns401()
{
var resp = await _client.SendAsync(BuildRequest(
HttpMethod.Put,
"/api/v1/roles/cajero/permisos",
new { codigos = new[] { "ventas:contado:crear" } }));
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task PutRolPermisos_WithNonAdminToken_Returns403()
{
const string username = "perm_nonadmin_put";
try
{
var token = await CreateNonAdminUserAndGetTokenAsync(username);
using var req = BuildRequest(
HttpMethod.Put,
"/api/v1/roles/cajero/permisos",
new { codigos = new[] { "ventas:contado:crear" } },
token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
}
finally
{
await DeleteUsuarioIfExistsAsync(username);
}
}
}