diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs new file mode 100644 index 0000000..e48f550 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Auth; + +[Collection("ApiIntegration")] +public class AuthControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public AuthControllerTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + // Scenario: happy path — valid admin credentials return 200 with token shape + usuario + [Fact] + public async Task Login_ValidCredentials_Returns200WithTokenShape() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = "admin", + password = "@Diego550@" + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("accessToken", out var token), "Response missing 'accessToken'"); + Assert.True(json.TryGetProperty("refreshToken", out var refresh), "Response missing 'refreshToken'"); + Assert.True(json.TryGetProperty("expiresIn", out var expires), "Response missing 'expiresIn'"); + + Assert.False(string.IsNullOrWhiteSpace(token.GetString()), "'accessToken' must not be empty"); + Assert.False(string.IsNullOrWhiteSpace(refresh.GetString()), "'refreshToken' must not be empty"); + Assert.Equal(3600, expires.GetInt32()); + + // Contract: response must include usuario object + Assert.True(json.TryGetProperty("usuario", out var usuario), "Response missing 'usuario'"); + Assert.True(usuario.TryGetProperty("id", out var id), "usuario missing 'id'"); + Assert.True(usuario.TryGetProperty("nombre", out var nombre), "usuario missing 'nombre'"); + Assert.True(usuario.TryGetProperty("rol", out var rol), "usuario missing 'rol'"); + Assert.True(usuario.TryGetProperty("permisos", out var permisos), "usuario missing 'permisos'"); + + Assert.True(id.GetInt32() > 0, "'usuario.id' must be positive"); + Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty"); + Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty"); + Assert.Equal(JsonValueKind.Array, permisos.ValueKind); + } + + // Scenario: invalid credentials return 401 with opaque error + [Fact] + public async Task Login_InvalidCredentials_Returns401() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = "admin", + password = "WrongPassword1!" + }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("error", out var error)); + Assert.Equal("Credenciales inválidas", error.GetString()); + } + + // Scenario: malformed body (missing password) returns 400 + [Fact] + public async Task Login_MissingPassword_Returns400() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = "admin" + // password intentionally missing — JSON serializes as no field + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("errors", out var errors), "Response missing 'errors'"); + } + + // Triangulation: empty username returns 400 + [Fact] + public async Task Login_EmptyUsername_Returns400() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = "", + password = "@Diego550@" + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Api.Tests/SIGCM2.Api.Tests.csproj b/tests/SIGCM2.Api.Tests/SIGCM2.Api.Tests.csproj new file mode 100644 index 0000000..b7f3581 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/SIGCM2.Api.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + SIGCM2.Api.Tests + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs new file mode 100644 index 0000000..3458221 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs @@ -0,0 +1,103 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Auth.Login; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Auth.Login; + +public class LoginCommandHandlerTests +{ + private readonly IUsuarioRepository _repository = Substitute.For(); + private readonly IPasswordHasher _hasher = Substitute.For(); + private readonly IJwtService _jwtService = Substitute.For(); + private readonly LoginCommandHandler _handler; + + public LoginCommandHandlerTests() + { + _handler = new LoginCommandHandler(_repository, _hasher, _jwtService); + } + + // Scenario: valid credentials → returns token response with usuario populated + [Fact] + public async Task Handle_ValidCredentials_ReturnsTokenResponse() + { + var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); + _repository.GetByUsernameAsync("admin").Returns(usuario); + _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true); + _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here"); + + var command = new LoginCommand("admin", "@Diego550@"); + var result = await _handler.Handle(command); + + Assert.Equal("jwt.token.here", result.AccessToken); + Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken)); + Assert.Equal(3600, result.ExpiresIn); + + // Contract: Usuario must be populated + Assert.NotNull(result.Usuario); + Assert.Equal(1, result.Usuario.Id); + Assert.Equal("Admin Sys", result.Usuario.Nombre); + Assert.Equal("admin", result.Usuario.Rol); + Assert.NotNull(result.Usuario.Permisos); + Assert.Contains("*", result.Usuario.Permisos); + } + + // Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user + [Fact] + public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser() + { + var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "Cajero", + "[\"ventas:contado:create\",\"ventas:contado:read\"]", true); + _repository.GetByUsernameAsync("cajero1").Returns(usuario); + _hasher.Verify("pass123", "$2a$12$hash3").Returns(true); + _jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token"); + + var command = new LoginCommand("cajero1", "pass123"); + var result = await _handler.Handle(command); + + Assert.Equal(42, result.Usuario.Id); + Assert.Equal("María González", result.Usuario.Nombre); + Assert.Equal("Cajero", result.Usuario.Rol); + Assert.Equal(2, result.Usuario.Permisos.Length); + Assert.Contains("ventas:contado:create", result.Usuario.Permisos); + Assert.Contains("ventas:contado:read", result.Usuario.Permisos); + } + + // Scenario: user does not exist → throws InvalidCredentialsException + [Fact] + public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException() + { + _repository.GetByUsernameAsync("noexiste").Returns((Usuario?)null); + + var command = new LoginCommand("noexiste", "anything"); + + await Assert.ThrowsAsync(() => _handler.Handle(command)); + } + + // Scenario: user is inactive → throws InvalidCredentialsException + [Fact] + public async Task Handle_InactiveUser_ThrowsInvalidCredentialsException() + { + var inactive = new Usuario(2, "operador", "$2a$12$hash2", "Juan", "Pérez", null, "vendedor", "[]", false); + _repository.GetByUsernameAsync("operador").Returns(inactive); + + var command = new LoginCommand("operador", "correctpassword"); + + await Assert.ThrowsAsync(() => _handler.Handle(command)); + } + + // Scenario: wrong password → throws InvalidCredentialsException + [Fact] + public async Task Handle_WrongPassword_ThrowsInvalidCredentialsException() + { + var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); + _repository.GetByUsernameAsync("admin").Returns(usuario); + _hasher.Verify("WrongPass1", "$2a$12$hash").Returns(false); + + var command = new LoginCommand("admin", "WrongPass1"); + + await Assert.ThrowsAsync(() => _handler.Handle(command)); + } +} diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandValidatorTests.cs new file mode 100644 index 0000000..9d2e9c9 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandValidatorTests.cs @@ -0,0 +1,55 @@ +using FluentValidation.TestHelper; +using SIGCM2.Application.Auth.Login; + +namespace SIGCM2.Application.Tests.Auth.Login; + +public class LoginCommandValidatorTests +{ + private readonly LoginCommandValidator _validator = new(); + + // Happy path: valid command passes validation + [Fact] + public void Validate_ValidCommand_ShouldHaveNoErrors() + { + var command = new LoginCommand("admin", "@Diego550@"); + var result = _validator.TestValidate(command); + result.ShouldNotHaveAnyValidationErrors(); + } + + // Scenario: empty username → validation error referencing Username + [Fact] + public void Validate_EmptyUsername_ShouldHaveErrorForUsername() + { + var command = new LoginCommand("", "@Diego550@"); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(c => c.Username); + } + + // Triangulation: whitespace-only username + [Fact] + public void Validate_WhitespaceUsername_ShouldHaveErrorForUsername() + { + var command = new LoginCommand(" ", "@Diego550@"); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(c => c.Username); + } + + // Scenario: missing password → validation error referencing Password + [Fact] + public void Validate_EmptyPassword_ShouldHaveErrorForPassword() + { + var command = new LoginCommand("admin", ""); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(c => c.Password); + } + + // Triangulation: null-equivalent (empty string is how records serialize missing fields) + [Fact] + public void Validate_BothEmpty_ShouldHaveErrorsForBothFields() + { + var command = new LoginCommand("", ""); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(c => c.Username); + result.ShouldHaveValidationErrorFor(c => c.Password); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs new file mode 100644 index 0000000..576c238 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs @@ -0,0 +1,72 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Domain; + +public class UsuarioTests +{ + // Happy path: constructor sets all properties correctly + [Fact] + public void Constructor_SetsAllProperties() + { + var usuario = new Usuario( + id: 1, + username: "admin", + passwordHash: "$2a$12$hash", + nombre: "Administrador", + apellido: "Sistema", + email: null, + rol: "admin", + permisosJson: "[\"*\"]", + activo: true + ); + + Assert.Equal(1, usuario.Id); + Assert.Equal("admin", usuario.Username); + Assert.Equal("$2a$12$hash", usuario.PasswordHash); + Assert.Equal("Administrador", usuario.Nombre); + Assert.Equal("Sistema", usuario.Apellido); + Assert.Null(usuario.Email); + Assert.Equal("admin", usuario.Rol); + Assert.Equal("[\"*\"]", usuario.PermisosJson); + Assert.True(usuario.Activo); + } + + // Triangulation: inactive user + [Fact] + public void Constructor_WithActivo_False_SetsActivo_False() + { + var usuario = new Usuario( + id: 2, + username: "vendedor", + passwordHash: "$2a$12$hash2", + nombre: "Juan", + apellido: "Pérez", + email: "juan@example.com", + rol: "vendedor", + permisosJson: "[]", + activo: false + ); + + Assert.Equal(2, usuario.Id); + Assert.Equal("vendedor", usuario.Username); + Assert.Equal("juan@example.com", usuario.Email); + Assert.Equal("vendedor", usuario.Rol); + Assert.Equal("[]", usuario.PermisosJson); + Assert.False(usuario.Activo); + } + + // Activo property reflects the actual state + [Fact] + public void Activo_IsTrue_WhenConstructedActive() + { + var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); + Assert.True(usuario.Activo); + } + + [Fact] + public void Activo_IsFalse_WhenConstructedInactive() + { + var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false); + Assert.False(usuario.Activo); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/BcryptPasswordHasherTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/BcryptPasswordHasherTests.cs new file mode 100644 index 0000000..d965e64 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/BcryptPasswordHasherTests.cs @@ -0,0 +1,46 @@ +using SIGCM2.Infrastructure.Security; + +namespace SIGCM2.Application.Tests.Infrastructure; + +public class BcryptPasswordHasherTests +{ + private readonly BcryptPasswordHasher _hasher = new(); + + // The seed hash for '@Diego550@' generated at cost 12 + private const string SeedHash = "$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW"; + + // Scenario: correct password verifies against seed hash + [Fact] + public void Verify_CorrectPassword_ReturnsTrue() + { + var result = _hasher.Verify("@Diego550@", SeedHash); + Assert.True(result); + } + + // Triangulation: wrong password does not verify + [Fact] + public void Verify_WrongPassword_ReturnsFalse() + { + var result = _hasher.Verify("WrongPass1", SeedHash); + Assert.False(result); + } + + // Hash + Verify round-trip: hash a new password and verify it + [Fact] + public void Hash_ThenVerify_ReturnsTrue() + { + var plain = "TestPassword123!"; + var hash = _hasher.Hash(plain); + + Assert.StartsWith("$2a$", hash); // BCrypt format + Assert.True(_hasher.Verify(plain, hash)); + } + + // Triangulation: verification of different password against generated hash fails + [Fact] + public void Hash_ThenVerifyWrong_ReturnsFalse() + { + var hash = _hasher.Hash("OriginalPassword1!"); + Assert.False(_hasher.Verify("DifferentPassword1!", hash)); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs new file mode 100644 index 0000000..3a1fc34 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs @@ -0,0 +1,127 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Security; + +namespace SIGCM2.Application.Tests.Infrastructure; + +public class JwtServiceTests : IDisposable +{ + private readonly RSA _rsa; + private readonly JwtOptions _options; + private readonly JwtService _jwtService; + + public JwtServiceTests() + { + // Generate a test RSA key pair inline (no files needed for unit tests) + _rsa = RSA.Create(2048); + _options = new JwtOptions + { + Issuer = "sigcm2.api", + Audience = "sigcm2.web", + AccessTokenMinutes = 60 + }; + _jwtService = new JwtService(_rsa, _options); + } + + public void Dispose() => _rsa.Dispose(); + + // Scenario: generated token uses RS256 algorithm + [Fact] + public void GenerateAccessToken_UsesRS256Algorithm() + { + var usuario = MakeUsuario(); + var token = _jwtService.GenerateAccessToken(usuario); + + var handler = new JwtSecurityTokenHandler(); + var parsed = handler.ReadJwtToken(token); + + Assert.Equal("RS256", parsed.Header.Alg); + } + + // Scenario: claims contain expected values + [Fact] + public void GenerateAccessToken_ContainsExpectedClaims() + { + var usuario = MakeUsuario(); + var token = _jwtService.GenerateAccessToken(usuario); + + var handler = new JwtSecurityTokenHandler(); + var parsed = handler.ReadJwtToken(token); + + Assert.Equal("1", parsed.Subject); // sub = user ID + Assert.Equal("sigcm2.api", parsed.Issuer); // iss + Assert.Contains("sigcm2.web", parsed.Audiences); // aud + Assert.Contains(parsed.Claims, c => c.Type == "name" && c.Value == "admin"); + Assert.Contains(parsed.Claims, c => c.Type == "rol" && c.Value == "admin"); + } + + // Scenario: token is verifiable with the public key + [Fact] + public void GenerateAccessToken_IsVerifiableWithPublicKey() + { + var usuario = MakeUsuario(); + var token = _jwtService.GenerateAccessToken(usuario); + + var publicKey = RSA.Create(); + publicKey.ImportRSAPublicKey(_rsa.ExportRSAPublicKey(), out _); + + var validationParams = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new RsaSecurityKey(publicKey), + ValidIssuer = "sigcm2.api", + ValidAudience = "sigcm2.web", + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(token, validationParams, out var validatedToken); + + Assert.NotNull(principal); + Assert.IsType(validatedToken); + } + + // Scenario: expiry is 60 minutes from now + [Fact] + public void GenerateAccessToken_ExpiryIs60MinutesFromNow() + { + var usuario = MakeUsuario(); + var before = DateTime.UtcNow; + var token = _jwtService.GenerateAccessToken(usuario); + var after = DateTime.UtcNow; + + var handler = new JwtSecurityTokenHandler(); + var parsed = handler.ReadJwtToken(token); + + var expectedMinExpiry = before.AddMinutes(59).AddSeconds(55); + var expectedMaxExpiry = after.AddMinutes(60).AddSeconds(5); + + Assert.True(parsed.ValidTo >= expectedMinExpiry, $"exp {parsed.ValidTo} < expected min {expectedMinExpiry}"); + Assert.True(parsed.ValidTo <= expectedMaxExpiry, $"exp {parsed.ValidTo} > expected max {expectedMaxExpiry}"); + } + + // Triangulation: different user produces token with different sub claim + [Fact] + public void GenerateAccessToken_DifferentUser_DifferentSubClaim() + { + var user1 = MakeUsuario(id: 1, username: "admin"); + var user2 = MakeUsuario(id: 2, username: "vendedor"); + + var token1 = _jwtService.GenerateAccessToken(user1); + var token2 = _jwtService.GenerateAccessToken(user2); + + var handler = new JwtSecurityTokenHandler(); + var parsed1 = handler.ReadJwtToken(token1); + var parsed2 = handler.ReadJwtToken(token2); + + Assert.NotEqual(parsed1.Subject, parsed2.Subject); + Assert.Equal("1", parsed1.Subject); + Assert.Equal("2", parsed2.Subject); + } + + private static Usuario MakeUsuario(int id = 1, string username = "admin") + => new(id, username, "$2a$12$hash", "Administrador", "Sistema", null, "admin", "[\"*\"]", true); +} diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs new file mode 100644 index 0000000..6e1e66d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -0,0 +1,102 @@ +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +[Collection("Database")] +public class UsuarioRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private Respawner _respawner = null!; + private UsuarioRepository _repository = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer + }); + + // Reset DB and seed admin user for each test class run + await _respawner.ResetAsync(_connection); + await SeedAdminAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new UsuarioRepository(factory); + } + + public async Task DisposeAsync() + { + await _respawner.ResetAsync(_connection); + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // Scenario: GetByUsername returns correct entity when user exists + [Fact] + public async Task GetByUsernameAsync_ExistingUser_ReturnsUsuario() + { + var usuario = await _repository.GetByUsernameAsync("admin"); + + Assert.NotNull(usuario); + Assert.Equal("admin", usuario.Username); + Assert.Equal("admin", usuario.Rol); + Assert.True(usuario.Activo); + Assert.False(string.IsNullOrWhiteSpace(usuario.PasswordHash)); + } + + // Triangulation: GetByUsername returns null when user does not exist + [Fact] + public async Task GetByUsernameAsync_NonExistentUser_ReturnsNull() + { + var usuario = await _repository.GetByUsernameAsync("noexiste"); + Assert.Null(usuario); + } + + // Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default) + [Fact] + public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser() + { + // Insert a second user + await _connection.ExecuteAsync( + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " + + "VALUES ('vendedor1', '$2a$12$hash2', 'Juan', 'Pérez', 'vendedor', '[]')"); + + var admin = await _repository.GetByUsernameAsync("admin"); + var vendedor = await _repository.GetByUsernameAsync("vendedor1"); + + Assert.NotNull(admin); + Assert.NotNull(vendedor); + Assert.NotEqual(admin.Id, vendedor.Id); + Assert.Equal("admin", admin.Rol); + Assert.Equal("vendedor", vendedor.Rol); + } + + private async Task SeedAdminAsync() + { + await _connection.ExecuteAsync( + "SET QUOTED_IDENTIFIER ON; " + + "IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') " + + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + + "VALUES ('admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', " + + "'Administrador', 'Sistema', 'admin', '[\"*\"]', 1)"); + } +} + +// Dapper extension helper for IDbConnection +file static class DapperHelper +{ + public static async Task ExecuteAsync(this SqlConnection conn, string sql) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj b/tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj new file mode 100644 index 0000000..ceb4501 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + SIGCM2.Application.Tests + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SIGCM2.TestSupport/SIGCM2.TestSupport.csproj b/tests/SIGCM2.TestSupport/SIGCM2.TestSupport.csproj new file mode 100644 index 0000000..dc19f70 --- /dev/null +++ b/tests/SIGCM2.TestSupport/SIGCM2.TestSupport.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + SIGCM2.TestSupport + + + + + + + + + + + + + + + + + diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs new file mode 100644 index 0000000..d4b1c63 --- /dev/null +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -0,0 +1,66 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using Xunit; + +namespace SIGCM2.TestSupport; + +/// +/// Manages a real SQL Server test database. +/// Resets state between test runs using Respawn. +/// Seeds the admin user after each reset. +/// +public sealed class SqlTestFixture : IAsyncLifetime +{ + private readonly string _connectionString; + private SqlConnection _connection = null!; + private Respawner _respawner = null!; + + public SqlTestFixture(string connectionString) + { + _connectionString = connectionString; + } + + public async Task InitializeAsync() + { + _connection = new SqlConnection(_connectionString); + await _connection.OpenAsync(); + + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer + }); + + await ResetAndSeedAsync(); + } + + public async Task ResetAndSeedAsync() + { + await _respawner.ResetAsync(_connection); + await SeedAdminAsync(); + } + + public async Task DisposeAsync() + { + if (_connection is not null) + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + } + + private async Task SeedAdminAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) + VALUES ( + 'admin', + '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', + 'Administrador', 'Sistema', 'admin', '["*"]', 1 + ); + """; + await _connection.ExecuteAsync(sql); + } +} diff --git a/tests/SIGCM2.TestSupport/TestWebAppFactory.cs b/tests/SIGCM2.TestSupport/TestWebAppFactory.cs new file mode 100644 index 0000000..6482080 --- /dev/null +++ b/tests/SIGCM2.TestSupport/TestWebAppFactory.cs @@ -0,0 +1,94 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Infrastructure.Security; +using Xunit; + +namespace SIGCM2.TestSupport; + +/// +/// WebApplicationFactory for integration tests against SIGCM2.Api. +/// Uses SIGCM2_Test database (separate from production SIGCM2). +/// +public sealed class TestWebAppFactory : WebApplicationFactory, IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + // Resolved once — absolute paths independent of working directory + private static readonly string RepoRoot = ResolveRepoRoot(); + private static readonly string PrivateKeyPath = Path.Combine(RepoRoot, "src", "api", "SIGCM2.Api", "keys", "private.pem"); + private static readonly string PublicKeyPath = Path.Combine(RepoRoot, "src", "api", "SIGCM2.Api", "keys", "public.pem"); + + private readonly SqlTestFixture _dbFixture = new(TestConnectionString); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Step 1: Override configuration BEFORE services are built + builder.ConfigureAppConfiguration((ctx, config) => + { + // Clear all existing sources and rebuild with test values + // This ensures our paths win over appsettings.json + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:SqlServer"] = TestConnectionString, + ["Jwt:Issuer"] = "sigcm2.api", + ["Jwt:Audience"] = "sigcm2.web", + ["Jwt:AccessTokenMinutes"] = "60", + ["Jwt:PrivateKeyPath"] = PrivateKeyPath, + ["Jwt:PublicKeyPath"] = PublicKeyPath, + ["Jwt:PrivateKey"] = null, + ["Jwt:PublicKey"] = null, + ["Cors:AllowedOrigins:0"] = "http://localhost:5173", + ["Serilog:MinimumLevel:Default"] = "Warning", + }); + }); + + builder.UseEnvironment("Testing"); + } + + public async Task InitializeAsync() + { + await _dbFixture.InitializeAsync(); + } + + public new async Task DisposeAsync() + { + await _dbFixture.DisposeAsync(); + await base.DisposeAsync(); + } + + private static string ResolveRepoRoot() + { + // Walk up from AppContext.BaseDirectory looking for SIGCM2.slnx + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (dir.GetFiles("SIGCM2.slnx").Length > 0) + return dir.FullName; + dir = dir.Parent; + } + + // Walk up from assembly location + var assemblyLocation = typeof(TestWebAppFactory).Assembly.Location; + dir = new DirectoryInfo(Path.GetDirectoryName(assemblyLocation)!); + while (dir is not null) + { + if (dir.GetFiles("SIGCM2.slnx").Length > 0) + return dir.FullName; + dir = dir.Parent; + } + + // Known absolute path (last resort for this machine) + const string knownPath = @"E:\SIG-CM2.0"; + if (Directory.Exists(knownPath) && File.Exists(Path.Combine(knownPath, "SIGCM2.slnx"))) + return knownPath; + + throw new InvalidOperationException( + $"Could not find repo root containing SIGCM2.slnx. " + + $"AppContext.BaseDirectory: {AppContext.BaseDirectory}"); + } +}