UDT-001: Login (scaffolding + JWT RS256 end-to-end) #1
97
tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs
Normal file
97
tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
tests/SIGCM2.Api.Tests/SIGCM2.Api.Tests.csproj
Normal file
32
tests/SIGCM2.Api.Tests/SIGCM2.Api.Tests.csproj
Normal 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>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs
Normal file
72
tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
127
tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
Normal file
127
tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
25
tests/SIGCM2.TestSupport/SIGCM2.TestSupport.csproj
Normal file
25
tests/SIGCM2.TestSupport/SIGCM2.TestSupport.csproj
Normal 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>
|
||||||
66
tests/SIGCM2.TestSupport/SqlTestFixture.cs
Normal file
66
tests/SIGCM2.TestSupport/SqlTestFixture.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
tests/SIGCM2.TestSupport/TestWebAppFactory.cs
Normal file
94
tests/SIGCM2.TestSupport/TestWebAppFactory.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user