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_Returns200With25Items() { 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(); // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total Assert.Equal(25, 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_Returns200With25Items() { 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(); // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total Assert.Equal(25, 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); } } [Fact] public async Task GetRolPermisos_InvalidCodigoFormat_Returns400() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); // "ROL-INVALIDO" no matchea ^[a-z][a-z0-9_]*$ (tiene guion y mayúsculas) using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/ROL-INVALIDO/permisos", bearerToken: token); var resp = await _client.SendAsync(req); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } // ── 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); } } // ── UDT-006: 403 ProblemDetails shape ───────────────────────────────────── [Fact] public async Task GetPermisos_WithCajeroToken_Returns403WithProblemDetailsShape() { const string username = "udt006_permisos_403_cajero"; 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); Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? ""); var json = await resp.Content.ReadFromJsonAsync(); Assert.Equal(403, json.GetProperty("status").GetInt32()); Assert.Equal("Acceso denegado", json.GetProperty("title").GetString()); Assert.True(json.TryGetProperty("permisoRequerido", out var perm), "Response must contain 'permisoRequerido'"); // GET /permisos migra a administracion:permisos:ver Assert.Equal("administracion:permisos:ver", perm.GetString()); } finally { await DeleteUsuarioIfExistsAsync(username); } } [Fact] public async Task PutRolPermisos_WithCajeroToken_Returns403WithProblemDetailsShape() { const string username = "udt006_put_permisos_403"; 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); Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? ""); var json = await resp.Content.ReadFromJsonAsync(); Assert.Equal(403, json.GetProperty("status").GetInt32()); Assert.True(json.TryGetProperty("permisoRequerido", out var perm), "Response must contain 'permisoRequerido'"); // PUT /roles/{c}/permisos migra a administracion:roles_permisos:gestionar Assert.Equal("administracion:roles_permisos:gestionar", perm.GetString()); } finally { await DeleteUsuarioIfExistsAsync(username); } } }