feat(domain): V008 migration + Usuario with-methods + DomainException hierarchy [UDT-008]

This commit is contained in:
2026-04-15 17:36:46 -03:00
parent 5ddc5ddf02
commit d1f7b3805b
8 changed files with 383 additions and 5 deletions

View File

@@ -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

View File

@@ -12,6 +12,11 @@ public sealed class Usuario
public string PermisosJson { get; } public string PermisosJson { get; }
public bool Activo { get; } public bool Activo { get; }
// UDT-008: new properties
public DateTime? FechaModificacion { get; }
public DateTime? UltimoLogin { get; }
public bool MustChangePassword { get; }
public Usuario( public Usuario(
int id, int id,
string username, string username,
@@ -21,7 +26,10 @@ public sealed class Usuario
string? email, string? email,
string rol, string rol,
string permisosJson, string permisosJson,
bool activo) bool activo,
DateTime? fechaModificacion = null,
DateTime? ultimoLogin = null,
bool mustChangePassword = false)
{ {
Id = id; Id = id;
Username = username; Username = username;
@@ -32,11 +40,14 @@ public sealed class Usuario
Rol = rol; Rol = rol;
PermisosJson = permisosJson; PermisosJson = permisosJson;
Activo = activo; Activo = activo;
FechaModificacion = fechaModificacion;
UltimoLogin = ultimoLogin;
MustChangePassword = mustChangePassword;
} }
/// <summary> /// <summary>
/// Factory for creating a new user (no Id — DB assigns via IDENTITY). /// Factory for creating a new user (no Id — DB assigns via IDENTITY).
/// Defaults: Activo=true, PermisosJson="[]". /// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false.
/// </summary> /// </summary>
public static Usuario ForCreation( public static Usuario ForCreation(
string username, string username,
@@ -55,6 +66,87 @@ public sealed class Usuario
email: email, email: email,
rol: rol, rol: rol,
permisosJson: "[]", permisosJson: "[]",
activo: true); activo: true,
fechaModificacion: null,
ultimoLogin: null,
mustChangePassword: false);
} }
// ── UDT-008: copy-with factory methods ────────────────────────────────────
/// <summary>
/// Returns a new instance with updated profile fields.
/// Sets FechaModificacion = UtcNow. Username and PasswordHash are immutable.
/// </summary>
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);
/// <summary>
/// Returns a new instance with a new password hash and mustChangePassword flag.
/// Sets FechaModificacion = UtcNow.
/// </summary>
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);
/// <summary>
/// Returns a new instance with only the MustChangePassword flag changed.
/// Sets FechaModificacion = UtcNow.
/// </summary>
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);
/// <summary>
/// Returns a new instance with only UltimoLogin updated.
/// Does NOT touch FechaModificacion.
/// </summary>
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);
} }

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// 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.
/// </summary>
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.") { }
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Base class for all domain-level exceptions in SIGCM2.
/// </summary>
public abstract class DomainException : Exception
{
protected DomainException(string message) : base(message) { }
protected DomainException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when an operation would remove the last active admin from the system,
/// causing a lockout condition.
/// </summary>
public sealed class LastAdminLockoutException : DomainException
{
public LastAdminLockoutException()
: base("No se puede desactivar o cambiar el rol del último administrador activo.") { }
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested user does not exist in the system.
/// </summary>
public sealed class UsuarioNotFoundException : DomainException
{
public int Id { get; }
public UsuarioNotFoundException(int id)
: base($"El usuario con id '{id}' no existe.")
{
Id = id;
}
}

View File

@@ -2,6 +2,8 @@ using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain; namespace SIGCM2.Application.Tests.Domain;
// ── UDT-008 tests ────────────────────────────────────────────────────────────
public class UsuarioTests public class UsuarioTests
{ {
// Happy path: constructor sets all properties correctly // 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); var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false);
Assert.False(usuario.Activo); 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
);
} }

View File

@@ -26,6 +26,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
_connection = new SqlConnection(_connectionString); _connection = new SqlConnection(_connectionString);
await _connection.OpenAsync(); 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 _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{ {
DbAdapter = DbAdapter.SqlServer, DbAdapter = DbAdapter.SqlServer,
@@ -83,6 +86,38 @@ public sealed class SqlTestFixture : IAsyncLifetime
} }
} }
/// <summary>
/// Applies V008 schema changes idempotently to the test database.
/// Mirrors V008__add_mustchangepassword_and_indexes.sql.
/// </summary>
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() private async Task SeedPermisosCanonicalAsync()
{ {
const string sql = """ const string sql = """
@@ -183,11 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
const string sql = """ const string sql = """
SET QUOTED_IDENTIFIER ON; SET QUOTED_IDENTIFIER ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') 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 ( VALUES (
'admin', 'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
'Administrador', 'Sistema', 'admin', '["*"]', 1 'Administrador', 'Sistema', 'admin', '["*"]', 1, 0
); );
"""; """;
await _connection.ExecuteAsync(sql); await _connection.ExecuteAsync(sql);