diff --git a/tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs new file mode 100644 index 0000000..5c3641b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Usuarios; + +/// +/// Integration tests for PUT /api/v1/users/me/password (UDT-008 B6). +/// +[Collection("ApiIntegration")] +public sealed class ChangeMyPasswordEndpointTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + // This hash corresponds to "@Diego550@" + private const string DefaultHash = "$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW"; + + private readonly HttpClient _client; + private readonly SqlTestFixture _db; + + public ChangeMyPasswordEndpointTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + _db = new SqlTestFixture(TestConnectionString); + } + + public async Task InitializeAsync() => await _db.InitializeAsync(); + public async Task DisposeAsync() => await _db.DisposeAsync(); + + private async Task SeedUserAsync(string username) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.ExecuteScalarAsync($""" + IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = '{username}') + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) + VALUES ('{username}', '{DefaultHash}', 'Test', 'User', 'cajero', '[]', 1, 0); + SELECT Id FROM dbo.Usuario WHERE Username = '{username}' + """); + } + + private async Task GetTokenAsync(string username) + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username, password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + [Fact] + public async Task PUT_Me_Password_204_No_Content() + { + await SeedUserAsync("user_chpwd_happy"); + var token = await GetTokenAsync("user_chpwd_happy"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task PUT_Me_Password_400_Wrong_Old_With_Error_Key() + { + await SeedUserAsync("user_chpwd_wrongold"); + var token = await GetTokenAsync("user_chpwd_wrongold"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "WrongPassword!", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("invalid-old-password", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task PUT_Me_Password_400_Weak_New_Password() + { + await SeedUserAsync("user_chpwd_weak"); + var token = await GetTokenAsync("user_chpwd_weak"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "abc" }); // too short + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PUT_Me_Password_401_No_Auth() + { + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PUT_Me_Password_Does_NOT_Require_Users_Manage_Permission() + { + // Cajero user (no users:gestionar permission) should be able to change own password + await SeedUserAsync("cajero_chpwd"); + var token = await GetTokenAsync("cajero_chpwd"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + // Should succeed with 204, NOT 403 + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ChangeMyPasswordCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ChangeMyPasswordCommandHandlerTests.cs new file mode 100644 index 0000000..4c686cf --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/ChangeMyPasswordCommandHandlerTests.cs @@ -0,0 +1,72 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Usuarios.ChangeMyPassword; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class ChangeMyPasswordCommandHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly IPasswordHasher _hasher = Substitute.For(); + private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly ChangeMyPasswordCommandHandler _handler; + + public ChangeMyPasswordCommandHandlerTests() + { + _handler = new ChangeMyPasswordCommandHandler(_repo, _hasher); + } + + private static Usuario MakeUser(int id = 1, bool mustChangePassword = false) + => new(id, "user" + id, "$2a$12$oldhash", "Test", "User", null, "cajero", "[]", true, + mustChangePassword: mustChangePassword); + + [Fact] + public async Task Handle_Happy_Path_Hashes_New_Password_Clears_MustChange() + { + var user = MakeUser(1, mustChangePassword: true); + _repo.GetByIdAsync(1, Arg.Any()).Returns(user); + _hasher.Verify("oldPass1!", "$2a$12$oldhash").Returns(true); + _hasher.Hash("newPass2!").Returns("$2a$12$newhash"); + + await _handler.Handle(new ChangeMyPasswordCommand(1, "oldPass1!", "newPass2!")); + + await _repo.Received(1).UpdatePasswordAsync(1, "$2a$12$newhash", false, Arg.Any()); + } + + [Fact] + public async Task Handle_Throws_InvalidOldPasswordException_When_Wrong_Old() + { + var user = MakeUser(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(user); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(false); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ChangeMyPasswordCommand(1, "wrongPass!", "newPass2!"))); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found() + { + _repo.GetByIdAsync(9999, Arg.Any()).Returns((Usuario?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ChangeMyPasswordCommand(9999, "old", "new1234"))); + } + + [Fact] + public async Task Handle_Does_NOT_Revoke_Own_Refresh_Tokens() + { + var user = MakeUser(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(user); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); + _hasher.Hash(Arg.Any()).Returns("$2a$12$newhash"); + + await _handler.Handle(new ChangeMyPasswordCommand(1, "oldPass1!", "newPass2!")); + + // spec REQ-BCP-05: change password does NOT revoke own tokens + await _refreshRepo.DidNotReceive().RevokeAllActiveForUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +}