using Microsoft.Data.SqlClient; using Xunit.Abstractions; namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; /// /// Bundle C (#23 M1) integration tests: applies the EF migrations to a /// freshly-created MSSQL test database on the running infra/mssql container /// and asserts that the AddAuditLogTable migration produced the expected /// partition function, partition scheme, partition-aligned table, named /// indexes, and DB roles. /// /// /// Tests early-return with a clear test-output message when the /// reports unavailable, so CI without /// the dev MSSQL container still runs the suite green. /// public class AddAuditLogTableMigrationTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; private readonly ITestOutputHelper _output; public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture, ITestOutputHelper output) { _fixture = fixture; _output = output; } [Fact] public async Task AppliesMigration_CreatesAuditLogTable() { if (!await EnsureMigrationApplied()) { return; } var exists = await ScalarAsync( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " + "WHERE TABLE_NAME = 'AuditLog' AND TABLE_SCHEMA = 'dbo';"); Assert.Equal(1, exists); } [Fact] public async Task AppliesMigration_CreatesPartitionFunction_pf_AuditLog_Month() { if (!await EnsureMigrationApplied()) { return; } var functionExists = await ScalarAsync( "SELECT COUNT(*) FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month';"); Assert.Equal(1, functionExists); // Specification (alog.md §4 / Bundle C plan): 24 monthly boundaries // covering 2026-01-01 through 2027-12-01 UTC. var boundaryCount = await ScalarAsync( "SELECT COUNT(*) FROM sys.partition_range_values rv " + "INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id " + "WHERE pf.name = 'pf_AuditLog_Month';"); Assert.True(boundaryCount >= 24, $"Expected at least 24 monthly boundaries on pf_AuditLog_Month; got {boundaryCount}."); } [Fact] public async Task AppliesMigration_CreatesPartitionScheme_ps_AuditLog_Month() { if (!await EnsureMigrationApplied()) { return; } var schemeExists = await ScalarAsync( "SELECT COUNT(*) FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month';"); Assert.Equal(1, schemeExists); } [Fact] public async Task AppliesMigration_TableIsPartitionAligned() { if (!await EnsureMigrationApplied()) { return; } // The clustered (PK) index on AuditLog must live on the ps_AuditLog_Month // partition scheme; sys.indexes.data_space_id points at the scheme. var schemeName = await ScalarAsync( "SELECT ps.name FROM sys.indexes i " + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + "INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " + "WHERE o.name = 'AuditLog' AND i.index_id = 1;"); Assert.Equal("ps_AuditLog_Month", schemeName); } [Fact] public async Task AppliesMigration_CreatesFiveNamedIndexes() { if (!await EnsureMigrationApplied()) { return; } var expected = new[] { "IX_AuditLog_OccurredAtUtc", "IX_AuditLog_Site_Occurred", "IX_AuditLog_CorrelationId", "IX_AuditLog_Channel_Status_Occurred", "IX_AuditLog_Target_Occurred", }; foreach (var indexName in expected) { var count = await ScalarAsync( "SELECT COUNT(*) FROM sys.indexes i " + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + $"WHERE o.name = 'AuditLog' AND i.name = '{indexName}';"); Assert.True(count == 1, $"Expected index '{indexName}' to exist on AuditLog; found {count}."); } } [Fact] public async Task AppliesMigration_CreatesAuditWriterRole_WithExpectedGrants() { if (!await EnsureMigrationApplied()) { return; } var roleExists = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_principals " + "WHERE name = 'scadalink_audit_writer' AND type = 'R';"); Assert.Equal(1, roleExists); // GRANT INSERT + GRANT SELECT must be present (G state = grant). var insertGranted = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_permissions p " + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + " AND p.permission_name = 'INSERT' AND p.state IN ('G','W');"); Assert.Equal(1, insertGranted); var selectGranted = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_permissions p " + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + " AND p.permission_name = 'SELECT' AND p.state IN ('G','W');"); Assert.Equal(1, selectGranted); // UPDATE / DELETE must NOT be granted — and DENY (state = 'D') is even // stronger. Treat presence of GRANT (state 'G' or 'W') as the failure. var updateGranted = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_permissions p " + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + " AND p.permission_name = 'UPDATE' AND p.state IN ('G','W');"); Assert.Equal(0, updateGranted); var deleteGranted = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_permissions p " + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + " AND p.permission_name = 'DELETE' AND p.state IN ('G','W');"); Assert.Equal(0, deleteGranted); } [Fact] public async Task AppliesMigration_CreatesAuditPurgerRole_WithExpectedGrants() { if (!await EnsureMigrationApplied()) { return; } var roleExists = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_principals " + "WHERE name = 'scadalink_audit_purger' AND type = 'R';"); Assert.Equal(1, roleExists); // SELECT on AuditLog. var selectGranted = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_permissions p " + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + "WHERE pr.name = 'scadalink_audit_purger' AND o.name = 'AuditLog' " + " AND p.permission_name = 'SELECT' AND p.state IN ('G','W');"); Assert.Equal(1, selectGranted); // ALTER on SCHEMA::dbo (class 3 = SCHEMA). var alterSchema = await ScalarAsync( "SELECT COUNT(*) FROM sys.database_permissions p " + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + "INNER JOIN sys.schemas s ON p.major_id = s.schema_id " + "WHERE pr.name = 'scadalink_audit_purger' AND s.name = 'dbo' " + " AND p.class = 3 AND p.permission_name = 'ALTER' AND p.state IN ('G','W');"); Assert.Equal(1, alterSchema); } [Fact] public async Task AuditWriterRole_CannotUpdateAuditLog() { if (!await EnsureMigrationApplied()) { return; } // Set up a dedicated user mapped to scadalink_audit_writer, then EXECUTE AS // and attempt UPDATE — DENY UPDATE on the role must reject the statement. // Use a guid-suffixed user name so reruns in the same fixture don't collide. var testUser = $"audit_writer_smoke_{Guid.NewGuid():N}".Substring(0, 32); await using (var setup = new SqlConnection(_fixture.ConnectionString)) { await setup.OpenAsync(); await using var setupCmd = setup.CreateCommand(); setupCmd.CommandText = $"CREATE USER [{testUser}] WITHOUT LOGIN; " + $"ALTER ROLE scadalink_audit_writer ADD MEMBER [{testUser}];"; await setupCmd.ExecuteNonQueryAsync(); } var ex = await Assert.ThrowsAsync(async () => { await using var conn = new SqlConnection(_fixture.ConnectionString); await conn.OpenAsync(); await using var cmd = conn.CreateCommand(); // WHERE 1=0 guarantees no rows are touched even if the permission check // somehow passes — the test asserts the engine rejects the statement // at permission-check time, not via a side effect on data. cmd.CommandText = $"EXECUTE AS USER = '{testUser}'; " + $"UPDATE dbo.AuditLog SET Status = 'X' WHERE 1 = 0; " + $"REVERT;"; await cmd.ExecuteNonQueryAsync(); }); // SQL Server permission-denied errors carry number 229 (e.g. "The UPDATE // permission was denied"). Assert the message mentions permission rather // than pinning to the exact code, in case the engine version drifts. Assert.Contains("permission", ex.Message, StringComparison.OrdinalIgnoreCase); } // --- helpers ------------------------------------------------------------ /// /// Applies the migration to the per-fixture test database. Returns false /// when the fixture is unavailable (no MSSQL container) — callers should /// log + early-return so the test is reported green on a CI box without /// the dev container. /// private async Task EnsureMigrationApplied() { if (!_fixture.Available) { _output.WriteLine($"[SKIP] {_fixture.SkipReason}"); return false; } // ApplyAuditMigrationAsync is idempotent — repeat calls within a fixture // are a no-op after the first migration. Cheaper than re-creating the // database per test for M1. await _fixture.ApplyAuditMigrationAsync(); return true; } private async Task ScalarAsync(string sql) { await using var conn = _fixture.OpenConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; var result = await cmd.ExecuteScalarAsync(); if (result is null || result is DBNull) { return default!; } return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; } }