feat(api): GET /api/v1/users/{id}/permisos con CQRS handler [UDT-009]
This commit is contained in:
435
tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
Normal file
435
tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
Normal file
@@ -0,0 +1,435 @@
|
||||
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 =
|
||||
"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;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user