test(udt-001): backend unit and integration tests (30 tests)
This commit is contained in:
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