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