diff --git a/database/migrations/V008__add_mustchangepassword_and_indexes.sql b/database/migrations/V008__add_mustchangepassword_and_indexes.sql new file mode 100644 index 0000000..3391bfd --- /dev/null +++ b/database/migrations/V008__add_mustchangepassword_and_indexes.sql @@ -0,0 +1,34 @@ +-- V008: Add MustChangePassword column + IX_Usuario_Activo_Rol index +-- Idempotent: re-runnable without errors. +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- Add MustChangePassword column (idempotent via COL_LENGTH check) +IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL +BEGIN + ALTER TABLE dbo.Usuario + ADD MustChangePassword BIT NOT NULL + CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0); + PRINT 'Column MustChangePassword added to dbo.Usuario.'; +END +ELSE + PRINT 'Column MustChangePassword already exists — skipping.'; +GO + +-- Compound index for listado filtrado (Activo + Rol) and anti-lockout guard +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_Usuario_Activo_Rol' + AND object_id = OBJECT_ID('dbo.Usuario') +) +BEGIN + CREATE INDEX IX_Usuario_Activo_Rol + ON dbo.Usuario(Activo, Rol) + INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion); + PRINT 'Index IX_Usuario_Activo_Rol created.'; +END +ELSE + PRINT 'Index IX_Usuario_Activo_Rol already exists — skipping.'; +GO diff --git a/src/api/SIGCM2.Domain/Entities/Usuario.cs b/src/api/SIGCM2.Domain/Entities/Usuario.cs index 8cef69d..aab7f18 100644 --- a/src/api/SIGCM2.Domain/Entities/Usuario.cs +++ b/src/api/SIGCM2.Domain/Entities/Usuario.cs @@ -12,6 +12,11 @@ public sealed class Usuario public string PermisosJson { get; } public bool Activo { get; } + // UDT-008: new properties + public DateTime? FechaModificacion { get; } + public DateTime? UltimoLogin { get; } + public bool MustChangePassword { get; } + public Usuario( int id, string username, @@ -21,7 +26,10 @@ public sealed class Usuario string? email, string rol, string permisosJson, - bool activo) + bool activo, + DateTime? fechaModificacion = null, + DateTime? ultimoLogin = null, + bool mustChangePassword = false) { Id = id; Username = username; @@ -32,11 +40,14 @@ public sealed class Usuario Rol = rol; PermisosJson = permisosJson; Activo = activo; + FechaModificacion = fechaModificacion; + UltimoLogin = ultimoLogin; + MustChangePassword = mustChangePassword; } /// /// Factory for creating a new user (no Id — DB assigns via IDENTITY). - /// Defaults: Activo=true, PermisosJson="[]". + /// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false. /// public static Usuario ForCreation( string username, @@ -55,6 +66,87 @@ public sealed class Usuario email: email, rol: rol, permisosJson: "[]", - activo: true); + activo: true, + fechaModificacion: null, + ultimoLogin: null, + mustChangePassword: false); } + + // ── UDT-008: copy-with factory methods ──────────────────────────────────── + + /// + /// Returns a new instance with updated profile fields. + /// Sets FechaModificacion = UtcNow. Username and PasswordHash are immutable. + /// + public Usuario WithUpdatedProfile(string nombre, string apellido, string? email, string rol, bool activo) + => new( + id: Id, + username: Username, + passwordHash: PasswordHash, + nombre: nombre, + apellido: apellido, + email: email, + rol: rol, + permisosJson: PermisosJson, + activo: activo, + fechaModificacion: DateTime.UtcNow, + ultimoLogin: UltimoLogin, + mustChangePassword: MustChangePassword); + + /// + /// Returns a new instance with a new password hash and mustChangePassword flag. + /// Sets FechaModificacion = UtcNow. + /// + public Usuario WithNewPasswordHash(string hash, bool mustChangePassword) + => new( + id: Id, + username: Username, + passwordHash: hash, + nombre: Nombre, + apellido: Apellido, + email: Email, + rol: Rol, + permisosJson: PermisosJson, + activo: Activo, + fechaModificacion: DateTime.UtcNow, + ultimoLogin: UltimoLogin, + mustChangePassword: mustChangePassword); + + /// + /// Returns a new instance with only the MustChangePassword flag changed. + /// Sets FechaModificacion = UtcNow. + /// + public Usuario WithMustChangePassword(bool value) + => new( + id: Id, + username: Username, + passwordHash: PasswordHash, + nombre: Nombre, + apellido: Apellido, + email: Email, + rol: Rol, + permisosJson: PermisosJson, + activo: Activo, + fechaModificacion: DateTime.UtcNow, + ultimoLogin: UltimoLogin, + mustChangePassword: value); + + /// + /// Returns a new instance with only UltimoLogin updated. + /// Does NOT touch FechaModificacion. + /// + public Usuario WithUltimoLogin(DateTime utcNow) + => new( + id: Id, + username: Username, + passwordHash: PasswordHash, + nombre: Nombre, + apellido: Apellido, + email: Email, + rol: Rol, + permisosJson: PermisosJson, + activo: Activo, + fechaModificacion: FechaModificacion, + ultimoLogin: utcNow, + mustChangePassword: MustChangePassword); } diff --git a/src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs b/src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs new file mode 100644 index 0000000..2199169 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when an admin attempts to reset their own password via the admin reset endpoint. +/// Admin must use the self-service change password endpoint instead. +/// +public sealed class CannotSelfResetException : DomainException +{ + public CannotSelfResetException() + : base("Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio.") { } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/DomainException.cs b/src/api/SIGCM2.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..1e75b5c --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/DomainException.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Base class for all domain-level exceptions in SIGCM2. +/// +public abstract class DomainException : Exception +{ + protected DomainException(string message) : base(message) { } + protected DomainException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/LastAdminLockoutException.cs b/src/api/SIGCM2.Domain/Exceptions/LastAdminLockoutException.cs new file mode 100644 index 0000000..36f1dc9 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/LastAdminLockoutException.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when an operation would remove the last active admin from the system, +/// causing a lockout condition. +/// +public sealed class LastAdminLockoutException : DomainException +{ + public LastAdminLockoutException() + : base("No se puede desactivar o cambiar el rol del último administrador activo.") { } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs new file mode 100644 index 0000000..fe9c15f --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested user does not exist in the system. +/// +public sealed class UsuarioNotFoundException : DomainException +{ + public int Id { get; } + + public UsuarioNotFoundException(int id) + : base($"El usuario con id '{id}' no existe.") + { + Id = id; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs index 576c238..ee28ce7 100644 --- a/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs +++ b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs @@ -2,6 +2,8 @@ using SIGCM2.Domain.Entities; namespace SIGCM2.Application.Tests.Domain; +// ── UDT-008 tests ──────────────────────────────────────────────────────────── + public class UsuarioTests { // Happy path: constructor sets all properties correctly @@ -69,4 +71,172 @@ public class UsuarioTests var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false); Assert.False(usuario.Activo); } + + // ── UDT-008: new properties ─────────────────────────────────────────────── + + [Fact] + public void ForCreation_Defaults_MustChangePassword_False() + { + var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero"); + Assert.False(u.MustChangePassword); + } + + [Fact] + public void ForCreation_Defaults_FechaModificacion_Null() + { + var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero"); + Assert.Null(u.FechaModificacion); + } + + [Fact] + public void ForCreation_Defaults_UltimoLogin_Null() + { + var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero"); + Assert.Null(u.UltimoLogin); + } + + // ── UDT-008: WithUpdatedProfile ────────────────────────────────────────── + + [Fact] + public void WithUpdatedProfile_Returns_NewInstance() + { + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("NuevoNombre", "NuevoApellido", "new@x.com", "cajero", true); + Assert.NotSame(u, updated); + } + + [Fact] + public void WithUpdatedProfile_Sets_Fields_Correctly() + { + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("Pedro", "Gómez", "p@g.com", "cajero", false); + Assert.Equal("Pedro", updated.Nombre); + Assert.Equal("Gómez", updated.Apellido); + Assert.Equal("p@g.com", updated.Email); + Assert.Equal("cajero", updated.Rol); + Assert.False(updated.Activo); + } + + [Fact] + public void WithUpdatedProfile_Sets_FechaModificacion_To_UtcNow() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("A", "B", null, "admin", true); + Assert.NotNull(updated.FechaModificacion); + Assert.True(updated.FechaModificacion >= before); + } + + [Fact] + public void WithUpdatedProfile_Preserves_Immutable_Fields() + { + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("X", "Y", null, "cajero", true); + Assert.Equal(u.Id, updated.Id); + Assert.Equal(u.Username, updated.Username); + Assert.Equal(u.PasswordHash, updated.PasswordHash); + } + + // ── UDT-008: WithNewPasswordHash ───────────────────────────────────────── + + [Fact] + public void WithNewPasswordHash_Returns_NewInstance() + { + var u = MakeUsuario(); + var updated = u.WithNewPasswordHash("newhash", mustChangePassword: false); + Assert.NotSame(u, updated); + } + + [Fact] + public void WithNewPasswordHash_Sets_Hash_And_MustChange() + { + var u = MakeUsuario(); + var updated = u.WithNewPasswordHash("newhash", mustChangePassword: true); + Assert.Equal("newhash", updated.PasswordHash); + Assert.True(updated.MustChangePassword); + } + + [Fact] + public void WithNewPasswordHash_Clears_MustChange_When_False() + { + var u = MakeUsuario(mustChangePassword: true); + var updated = u.WithNewPasswordHash("hash2", mustChangePassword: false); + Assert.False(updated.MustChangePassword); + } + + [Fact] + public void WithNewPasswordHash_Sets_FechaModificacion() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var u = MakeUsuario(); + var updated = u.WithNewPasswordHash("hash2", false); + Assert.NotNull(updated.FechaModificacion); + Assert.True(updated.FechaModificacion >= before); + } + + // ── UDT-008: WithUltimoLogin ────────────────────────────────────────────── + + [Fact] + public void WithUltimoLogin_Returns_NewInstance() + { + var u = MakeUsuario(); + var updated = u.WithUltimoLogin(DateTime.UtcNow); + Assert.NotSame(u, updated); + } + + [Fact] + public void WithUltimoLogin_Sets_UltimoLogin() + { + var ts = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc); + var u = MakeUsuario(); + var updated = u.WithUltimoLogin(ts); + Assert.Equal(ts, updated.UltimoLogin); + } + + [Fact] + public void WithUltimoLogin_Does_NOT_Touch_FechaModificacion() + { + var u = MakeUsuario(); + var originalFecha = u.FechaModificacion; + var updated = u.WithUltimoLogin(DateTime.UtcNow); + Assert.Equal(originalFecha, updated.FechaModificacion); + } + + // ── UDT-008: WithMustChangePassword ────────────────────────────────────── + + [Fact] + public void WithMustChangePassword_Sets_Value_True() + { + var u = MakeUsuario(mustChangePassword: false); + var updated = u.WithMustChangePassword(true); + Assert.True(updated.MustChangePassword); + } + + [Fact] + public void WithMustChangePassword_Sets_FechaModificacion() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var u = MakeUsuario(); + var updated = u.WithMustChangePassword(true); + Assert.NotNull(updated.FechaModificacion); + Assert.True(updated.FechaModificacion >= before); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private static Usuario MakeUsuario(bool mustChangePassword = false) + => new( + id: 1, + username: "testuser", + passwordHash: "$2a$12$hash", + nombre: "Test", + apellido: "User", + email: "test@x.com", + rol: "admin", + permisosJson: "[]", + activo: true, + fechaModificacion: null, + ultimoLogin: null, + mustChangePassword: mustChangePassword + ); } diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index b2cbd33..783a672 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -26,6 +26,9 @@ public sealed class SqlTestFixture : IAsyncLifetime _connection = new SqlConnection(_connectionString); await _connection.OpenAsync(); + // V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB + await EnsureV008SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -83,6 +86,38 @@ public sealed class SqlTestFixture : IAsyncLifetime } } + /// + /// Applies V008 schema changes idempotently to the test database. + /// Mirrors V008__add_mustchangepassword_and_indexes.sql. + /// + private async Task EnsureV008SchemaAsync() + { + const string addColumn = """ + IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL + BEGIN + ALTER TABLE dbo.Usuario + ADD MustChangePassword BIT NOT NULL + CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0); + END + """; + + const string addIndex = """ + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_Usuario_Activo_Rol' + AND object_id = OBJECT_ID('dbo.Usuario') + ) + BEGIN + CREATE INDEX IX_Usuario_Activo_Rol + ON dbo.Usuario(Activo, Rol) + INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion); + END + """; + + await _connection.ExecuteAsync(addColumn); + await _connection.ExecuteAsync(addIndex); + } + private async Task SeedPermisosCanonicalAsync() { const string sql = """ @@ -183,11 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime 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) + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) VALUES ( 'admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', - 'Administrador', 'Sistema', 'admin', '["*"]', 1 + 'Administrador', 'Sistema', 'admin', '["*"]', 1, 0 ); """; await _connection.ExecuteAsync(sql);