From 54955231bf847a4a4c40486219a9cba74e0ff9eb Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 21:27:29 -0300 Subject: [PATCH] feat(infra): V009 migration + Usuario.WithPermisosJson + SqlTestFixture V009 schema [UDT-009] --- .../V009__activate_permisos_overrides.sql | 43 +++ src/api/SIGCM2.Domain/Entities/Usuario.cs | 24 +- .../Integration/V009MigrationTests.cs | 256 ++++++++++++++++++ tests/SIGCM2.TestSupport/SqlTestFixture.cs | 50 +++- 4 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 database/migrations/V009__activate_permisos_overrides.sql create mode 100644 tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs diff --git a/database/migrations/V009__activate_permisos_overrides.sql b/database/migrations/V009__activate_permisos_overrides.sql new file mode 100644 index 0000000..b95c910 --- /dev/null +++ b/database/migrations/V009__activate_permisos_overrides.sql @@ -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 diff --git a/src/api/SIGCM2.Domain/Entities/Usuario.cs b/src/api/SIGCM2.Domain/Entities/Usuario.cs index aab7f18..deefe66 100644 --- a/src/api/SIGCM2.Domain/Entities/Usuario.cs +++ b/src/api/SIGCM2.Domain/Entities/Usuario.cs @@ -47,7 +47,7 @@ public sealed class Usuario /// /// 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. /// public static Usuario ForCreation( string username, @@ -65,7 +65,7 @@ public sealed class Usuario apellido: apellido, email: email, rol: rol, - permisosJson: "[]", + permisosJson: """{"grant":[],"deny":[]}""", activo: true, fechaModificacion: null, ultimoLogin: null, @@ -131,6 +131,26 @@ public sealed class Usuario ultimoLogin: UltimoLogin, mustChangePassword: value); + /// + /// UDT-009: Returns a new instance with PermisosJson replaced. + /// Sets FechaModificacion = UtcNow. + /// Accepts raw JSON string so Domain stays free of Application dependencies. + /// + 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); + /// /// Returns a new instance with only UltimoLogin updated. /// Does NOT touch FechaModificacion. diff --git a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs new file mode 100644 index 0000000..8566027 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs @@ -0,0 +1,256 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009) +/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync. +/// Uses SIGCM2_Test database directly. +/// +[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(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( + "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( + "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( + "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( + "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); + """); + } + + /// + /// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync. + /// + 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); + } +} diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 783a672..2bf7c92 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -29,6 +29,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB await EnsureV008SchemaAsync(); + // V009: update PermisosJson DEFAULT constraint and migrate legacy rows + await EnsureV009SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -215,6 +218,7 @@ public sealed class SqlTestFixture : IAsyncLifetime private async Task SeedAdminAsync() { + // V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]' const string sql = """ SET QUOTED_IDENTIFIER ON; IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') @@ -222,9 +226,53 @@ public sealed class SqlTestFixture : IAsyncLifetime VALUES ( 'admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', - 'Administrador', 'Sistema', 'admin', '["*"]', 1, 0 + 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0 ); """; await _connection.ExecuteAsync(sql); } + + /// + /// 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. + /// + 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); + } }