From 4913a35d069a7c9ce18efbd0a9e23147ed7a3d96 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:42:03 -0300 Subject: [PATCH] feat(api): BATCH 5 - PermisosController + tests HTTP [UDT-005] --- .../Controllers/PermisosController.cs | 83 ++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 12 + .../Permisos/PermisosEndpointTests.cs | 416 ++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/PermisosController.cs create mode 100644 tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/PermisosController.cs b/src/api/SIGCM2.Api/Controllers/PermisosController.cs new file mode 100644 index 0000000..77ed8de --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/PermisosController.cs @@ -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 _assignValidator; + + public PermisosController( + IDispatcher dispatcher, + IValidator assignValidator) + { + _dispatcher = dispatcher; + _assignValidator = assignValidator; + } + + /// Lists all permisos in the canonical catalog. Requires admin role. + [HttpGet("permisos")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListPermisos() + { + var result = await _dispatcher.Send>(new ListPermisosQuery()); + return Ok(result); + } + + /// Gets all permisos assigned to a rol. Requires admin role. + [HttpGet("roles/{codigo}/permisos")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetRolPermisos(string codigo) + { + var result = await _dispatcher.Send>( + new GetRolPermisosQuery(codigo)); + return Ok(result); + } + + /// + /// Replace-set: replaces the full permiso assignment for a rol. + /// Returns the updated permiso set (200). Requires admin role. + /// + [HttpPut("roles/{codigo}/permisos")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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>(command); + return Ok(result); + } +} + +public sealed record AssignPermisosRequest(IReadOnlyList? Codigos); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 13b4b5a..e1911b8 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -83,6 +83,18 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; 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: context.Result = new ObjectResult(new { diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs new file mode 100644 index 0000000..098a6f5 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -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; + +/// +/// Integration tests for /api/v1/permisos and /api/v1/roles/{codigo}/permisos (UDT-005). +/// RED: written before PermisosController exists. +/// +[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 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(); + 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 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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() }, + 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); + } + } +}