Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.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

435 lines
18 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.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 = TestConnectionStrings.ApiTestDb;
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 });
}
}