diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index cfa7e61..d86e562 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -10,6 +10,7 @@ using SIGCM2.Application.Usuarios.Deactivate; using SIGCM2.Application.Usuarios.GetById; using SIGCM2.Application.Usuarios.List; using SIGCM2.Application.Usuarios.Reactivate; +using SIGCM2.Application.Usuarios.Permisos; using SIGCM2.Application.Usuarios.ResetPassword; using SIGCM2.Application.Usuarios.Update; using System.IdentityModel.Tokens.Jwt; @@ -225,10 +226,46 @@ public sealed class UsuariosController : ControllerBase var result = await _dispatcher.Send(command); return Ok(result); } + + // ── UDT-009: Permisos endpoints ─────────────────────────────────────────── + + /// + /// Gets a usuario's role permissions, explicit grant/deny overrides, and computed effective set. + /// Requires administracion:usuarios:gestionar. + /// + [HttpGet("{id:int}/permisos")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetPermisos([FromRoute] int id) + { + var result = await _dispatcher.Send( + new GetUsuarioPermisosQuery(id)); + return Ok(MapToPermisosResponse(result)); + } + + private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto) + => new( + RolPermisos: dto.RolPermisos, + Overrides: new PermisosOverridesShape(dto.Grant, dto.Deny), + Effective: dto.Effective); } // ── request body records ────────────────────────────────────────────────────── +/// UDT-009: Response shape for permisos endpoints. +public sealed record UsuarioPermisosResponse( + IReadOnlyList RolPermisos, + PermisosOverridesShape Overrides, + IReadOnlyList Effective); + +/// UDT-009: The grant/deny override shape nested in UsuarioPermisosResponse. +public sealed record PermisosOverridesShape( + IReadOnlyList Grant, + IReadOnlyList Deny); + /// Create user request body — nullable to catch missing field scenarios. public sealed record CreateUsuarioRequest( string? Username, diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 1476873..6c5db6a 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -22,6 +22,7 @@ using SIGCM2.Application.Usuarios.GetById; using SIGCM2.Application.Usuarios.List; using SIGCM2.Application.Usuarios.Reactivate; using SIGCM2.Application.Usuarios.ResetPassword; +using SIGCM2.Application.Usuarios.Permisos; using SIGCM2.Application.Usuarios.Update; namespace SIGCM2.Application; @@ -57,6 +58,9 @@ public static class DependencyInjection services.AddScoped, ChangeMyPasswordCommandHandler>(); services.AddScoped, ResetUsuarioPasswordCommandHandler>(); + // Usuarios/Permisos (UDT-009) + services.AddScoped, GetUsuarioPermisosQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQuery.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQuery.cs new file mode 100644 index 0000000..835668a --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQuery.cs @@ -0,0 +1,4 @@ +namespace SIGCM2.Application.Usuarios.Permisos; + +/// UDT-009: Query to get a user's role permissions, overrides, and effective set. +public sealed record GetUsuarioPermisosQuery(int Id); diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQueryHandler.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQueryHandler.cs new file mode 100644 index 0000000..bb5e8d8 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/GetUsuarioPermisosQueryHandler.cs @@ -0,0 +1,51 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Permisos; + +/// +/// UDT-009: Handles GET /api/v1/users/{id}/permisos. +/// Resolves role permissions + overrides + effective set. +/// +public sealed class GetUsuarioPermisosQueryHandler + : ICommandHandler +{ + private readonly IUsuarioRepository _usuarioRepo; + private readonly IRolPermisoRepository _rolPermisoRepo; + + public GetUsuarioPermisosQueryHandler( + IUsuarioRepository usuarioRepo, + IRolPermisoRepository rolPermisoRepo) + { + _usuarioRepo = usuarioRepo; + _rolPermisoRepo = rolPermisoRepo; + } + + public async Task Handle(GetUsuarioPermisosQuery query) + { + var usuario = await _usuarioRepo.GetByIdAsync(query.Id) + ?? throw new UsuarioNotFoundException(query.Id); + + var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(usuario.Rol); + var rolPermisos = rolPermisoEntities + .Select(p => p.Codigo) + .OrderBy(c => c, StringComparer.Ordinal) + .ToArray(); + + var overrides = PermisosOverride.FromJson(usuario.PermisosJson); + + var effective = PermisoResolver.Resolve(rolPermisos, overrides) + .OrderBy(c => c, StringComparer.Ordinal) + .ToArray(); + + return new UsuarioPermisosDto( + UsuarioId: usuario.Id, + Rol: usuario.Rol, + RolPermisos: rolPermisos, + Grant: overrides.Grant, + Deny: overrides.Deny, + Effective: effective); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/UsuarioPermisosDto.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/UsuarioPermisosDto.cs new file mode 100644 index 0000000..fd0233f --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/UsuarioPermisosDto.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Usuarios.Permisos; + +/// +/// UDT-009: Response DTO for user permissions. +/// Contains role permissions, explicit overrides, and computed effective permissions. +/// +public sealed record UsuarioPermisosDto( + int UsuarioId, + string Rol, + IReadOnlyList RolPermisos, + IReadOnlyList Grant, + IReadOnlyList Deny, + IReadOnlyList Effective); diff --git a/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs new file mode 100644 index 0000000..7571acb --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs @@ -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; + +/// +/// 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 }); + } +}