Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs

435 lines
18 KiB
C#
Raw Permalink Normal View History

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 });
}
}