UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth #12

Merged
dmolinari merged 14 commits from feature/UDT-009 into main 2026-04-16 13:12:23 +00:00
4 changed files with 370 additions and 3 deletions
Showing only changes of commit 54955231bf - Show all commits

View File

@@ -0,0 +1,43 @@
-- V009__activate_permisos_overrides.sql
-- Activates Usuario.PermisosJson as explicit overrides {grant, deny} on top of role permissions.
-- Idempotent: safe to run multiple times.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
-- 1. Drop old default constraint if it exists (handles any previous shape)
IF EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
PRINT 'Dropped DF_Usuario_Permisos.';
END
GO
-- 2. Re-add default constraint with canonical shape
IF NOT EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario
ADD CONSTRAINT DF_Usuario_Permisos
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
PRINT 'Added DF_Usuario_Permisos with new shape {"grant":[],"deny":[]}.';
END
GO
-- 3. Migrate legacy values to new canonical shape
UPDATE dbo.Usuario
SET PermisosJson = '{"grant":[],"deny":[]}'
WHERE PermisosJson IN ('[]', '["*"]', '')
OR PermisosJson IS NULL
OR LTRIM(RTRIM(PermisosJson)) = '';
PRINT 'Migrated legacy PermisosJson rows to canonical shape.';
GO

View File

@@ -47,7 +47,7 @@ public sealed class Usuario
/// <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="[]", MustChangePassword=false. /// Defaults: Activo=true, PermisosJson={"grant":[],"deny":[]}, MustChangePassword=false.
/// </summary> /// </summary>
public static Usuario ForCreation( public static Usuario ForCreation(
string username, string username,
@@ -65,7 +65,7 @@ public sealed class Usuario
apellido: apellido, apellido: apellido,
email: email, email: email,
rol: rol, rol: rol,
permisosJson: "[]", permisosJson: """{"grant":[],"deny":[]}""",
activo: true, activo: true,
fechaModificacion: null, fechaModificacion: null,
ultimoLogin: null, ultimoLogin: null,
@@ -131,6 +131,26 @@ public sealed class Usuario
ultimoLogin: UltimoLogin, ultimoLogin: UltimoLogin,
mustChangePassword: value); mustChangePassword: value);
/// <summary>
/// UDT-009: Returns a new instance with PermisosJson replaced.
/// Sets FechaModificacion = UtcNow.
/// Accepts raw JSON string so Domain stays free of Application dependencies.
/// </summary>
public Usuario WithPermisosJson(string permisosJson)
=> 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> /// <summary>
/// Returns a new instance with only UltimoLogin updated. /// Returns a new instance with only UltimoLogin updated.
/// Does NOT touch FechaModificacion. /// Does NOT touch FechaModificacion.

View File

@@ -0,0 +1,256 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
namespace SIGCM2.Application.Tests.Integration;
/// <summary>
/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009)
/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync.
/// Uses SIGCM2_Test database directly.
/// </summary>
[Collection("Database")]
public sealed class V009MigrationTests : 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!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolAsync();
}
public async Task DisposeAsync()
{
if (_connection is not null)
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
}
// M-01: migration file exists on filesystem
[Fact]
public void MigrationFile_Exists()
{
// Walk up from test assembly looking for the repo root
var dir = new DirectoryInfo(AppContext.BaseDirectory);
string? repoRoot = null;
while (dir is not null)
{
if (dir.GetFiles("SIGCM2.slnx").Length > 0)
{
repoRoot = dir.FullName;
break;
}
dir = dir.Parent;
}
// Known fallback
if (repoRoot is null && Directory.Exists(@"E:\SIG-CM2.0"))
repoRoot = @"E:\SIG-CM2.0";
Assert.NotNull(repoRoot);
var migrationPath = Path.Combine(repoRoot!, "database", "migrations", "V009__activate_permisos_overrides.sql");
Assert.True(File.Exists(migrationPath), $"Migration file not found at: {migrationPath}");
}
// M-02: re-run idempotent (EnsureV009SchemaAsync twice → no error)
[Fact]
public async Task EnsureV009SchemaAsync_IsIdempotent()
{
await EnsureV009SchemaAsync();
await EnsureV009SchemaAsync(); // second call must not throw
}
// M-03: after migration, DEFAULT constraint is the new shape
[Fact]
public async Task EnsureV009SchemaAsync_DefaultConstraint_IsNewShape()
{
await EnsureV009SchemaAsync();
const string sql = """
SELECT object_definition(default_object_id) AS DefaultDef
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.Usuario')
AND name = 'PermisosJson'
""";
var definition = await _connection.QuerySingleOrDefaultAsync<string>(sql);
Assert.NotNull(definition);
Assert.Contains(@"{""grant"":[]", definition);
Assert.Contains(@"""deny"":[]}", definition);
}
// M-04: rows with '[]' are migrated to new shape
[Fact]
public async Task EnsureV009SchemaAsync_MigratesLegacyEmptyArray()
{
await EnsureV009SchemaAsync();
await _connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('legacyempty', '$2a$12$hash', 'L', 'E', 'admin', '[]', 1, 0)
""");
// Run migration again to migrate the newly inserted row
await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
}
// M-05: rows with '["*"]' are migrated
[Fact]
public async Task EnsureV009SchemaAsync_MigratesLegacyWildcard()
{
await EnsureV009SchemaAsync();
await _connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0)
""");
await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
}
// M-06: the migration UPDATE statement includes NULL / empty-string conditions
// The column is NOT NULL (V001 constraint), so we verify the UPDATE logic covers
// all the WHERE conditions syntactically and that rows with '' are migrated.
[Fact]
public async Task EnsureV009SchemaAsync_MigratesEmptyStringRows()
{
// First apply V009 schema so the constraint is updated
await EnsureV009SchemaAsync();
// Temporarily drop and re-add without the DEFAULT so we can insert ''
await _connection.ExecuteAsync("""
IF EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
""");
await _connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('emptystruser', '$2a$12$hash', 'E', 'S', 'admin', '', 1, 0)
""");
// Re-apply V009 (which restores constraint and migrates '' rows)
await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
}
// M-07: admin seed in SqlTestFixture uses new shape
[Fact]
public async Task SqlTestFixture_SeedAdmin_UsesNewPermisosJsonShape()
{
await EnsureV009SchemaAsync();
// Seed admin as TestFixture does post-V009
await _connection.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES (
'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
)
""");
var permisosJson = await _connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'admin'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
}
// ── helpers ───────────────────────────────────────────────────────────────
private async Task SeedRolAsync()
{
await _connection.ExecuteAsync("""
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES ('admin', N'Administrador', N'Supervisor total'))
AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo) VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""");
}
/// <summary>
/// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync.
/// </summary>
private async Task EnsureV009SchemaAsync()
{
const string dropConstraint = """
IF EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
END
""";
const string addConstraint = """
IF NOT EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario
ADD CONSTRAINT DF_Usuario_Permisos
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
END
""";
const string migrateRows = """
UPDATE dbo.Usuario
SET PermisosJson = '{"grant":[],"deny":[]}'
WHERE PermisosJson IN ('[]', '["*"]', '')
OR PermisosJson IS NULL
OR LTRIM(RTRIM(PermisosJson)) = ''
""";
await _connection.ExecuteAsync(dropConstraint);
await _connection.ExecuteAsync(addConstraint);
await _connection.ExecuteAsync(migrateRows);
}
}

View File

@@ -29,6 +29,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB // V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB
await EnsureV008SchemaAsync(); await EnsureV008SchemaAsync();
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows
await EnsureV009SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{ {
DbAdapter = DbAdapter.SqlServer, DbAdapter = DbAdapter.SqlServer,
@@ -215,6 +218,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
private async Task SeedAdminAsync() private async Task SeedAdminAsync()
{ {
// V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]'
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')
@@ -222,9 +226,53 @@ public sealed class SqlTestFixture : IAsyncLifetime
VALUES ( VALUES (
'admin', 'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
'Administrador', 'Sistema', 'admin', '["*"]', 1, 0 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
); );
"""; """;
await _connection.ExecuteAsync(sql); await _connection.ExecuteAsync(sql);
} }
/// <summary>
/// Applies V009 schema changes idempotently to the test database.
/// Mirrors V009__activate_permisos_overrides.sql.
/// Drops and re-adds DF_Usuario_Permisos with the new shape, then migrates legacy rows.
/// </summary>
private async Task EnsureV009SchemaAsync()
{
const string dropConstraint = """
IF EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
END
""";
const string addConstraint = """
IF NOT EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario
ADD CONSTRAINT DF_Usuario_Permisos
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
END
""";
const string migrateRows = """
UPDATE dbo.Usuario
SET PermisosJson = '{"grant":[],"deny":[]}'
WHERE PermisosJson IN ('[]', '["*"]', '')
OR PermisosJson IS NULL
OR LTRIM(RTRIM(PermisosJson)) = ''
""";
await _connection.ExecuteAsync(dropConstraint);
await _connection.ExecuteAsync(addConstraint);
await _connection.ExecuteAsync(migrateRows);
}
} }