Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs
dmolinari e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00

492 lines
19 KiB
C#

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 = TestConnectionStrings.ApiTestDb;
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_Returns200With24Items()
{
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>();
// 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 total
Assert.Equal(24, 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_Returns200With24Items()
{
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>();
// 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 total
Assert.Equal(24, 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);
}
}
[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<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);
}
}
// ── 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<JsonElement>();
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<JsonElement>();
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);
}
}
}