UDT-001: Login (scaffolding + JWT RS256 end-to-end) #1

Merged
dmolinari merged 14 commits from feature/UDT-001 into main 2026-04-14 14:44:28 +00:00
12 changed files with 851 additions and 0 deletions
Showing only changes of commit b657dc0d2a - Show all commits

View File

@@ -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<TestWebAppFactory>
{
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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);
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>SIGCM2.Api.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Respawn" />
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\SIGCM2.Api\SIGCM2.Api.csproj" />
<ProjectReference Include="..\SIGCM2.TestSupport\SIGCM2.TestSupport.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -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<IUsuarioRepository>();
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
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<InvalidCredentialsException>(() => _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<InvalidCredentialsException>(() => _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<InvalidCredentialsException>(() => _handler.Handle(command));
}
}

View File

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

View File

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

View File

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

View File

@@ -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<JwtSecurityToken>(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);
}

View File

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

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>SIGCM2.Application.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Respawn" />
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>SIGCM2.TestSupport</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Respawn" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="xunit" />
<PackageReference Include="Dapper" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\SIGCM2.Api\SIGCM2.Api.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,66 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using Xunit;
namespace SIGCM2.TestSupport;
/// <summary>
/// Manages a real SQL Server test database.
/// Resets state between test runs using Respawn.
/// Seeds the admin user after each reset.
/// </summary>
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);
}
}

View File

@@ -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;
/// <summary>
/// WebApplicationFactory for integration tests against SIGCM2.Api.
/// Uses SIGCM2_Test database (separate from production SIGCM2).
/// </summary>
public sealed class TestWebAppFactory : WebApplicationFactory<Program>, 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<string, string?>
{
["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}");
}
}