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; /// /// 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. /// [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 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(); 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 GetAdminIdAsync() { await using var conn = new SqlConnection(TestConnectionString); await conn.OpenAsync(); return await conn.QuerySingleAsync("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 GetPermisosJsonAsync(int userId) { await using var conn = new SqlConnection(TestConnectionString); await conn.OpenAsync(); return await conn.QuerySingleAsync( "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(); 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(); 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(); 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() }); 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(), 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() }); var response = await _client.SendAsync(request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await response.Content.ReadFromJsonAsync(); 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(); 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(), deny = Array.Empty() }); 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(), deny = Array.Empty() }, 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(), deny = Array.Empty() }) }; 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() }; 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() })); // Then reset var resetRequest = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", body: new { grant = Array.Empty(), deny = Array.Empty() }); 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(), deny = Array.Empty() }); var response = await _client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await response.Content.ReadFromJsonAsync(); 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 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 }); } }