feat(configdb): add AuditLog migration with monthly partitioning and DB roles (#23)

Bundle C of the #23 M1 foundation. Creates the centralized AuditLog table
with the partition function, partition scheme, partition-aligned
non-clustered indexes, and the two access-control roles documented in
alog.md §4.

Schema:
- pf_AuditLog_Month: RANGE RIGHT, 24 monthly boundaries (Jan 2026 – Dec 2027).
- ps_AuditLog_Month: ALL TO ([PRIMARY]) — dev/test parity.
- dbo.AuditLog: created via raw SQL ON ps_AuditLog_Month(OccurredAtUtc).
  Composite clustered PK {EventId, OccurredAtUtc} (partition column must be
  part of the clustered key). 22 columns matching the EF AuditEvent model.
- 5 reconciliation/query non-clustered indexes from alog.md §4
  (Channel_Status_Occurred, CorrelationId filtered, OccurredAtUtc,
  Site_Occurred, Target_Occurred filtered) — all partition-aligned.
- UX_AuditLog_EventId: non-aligned UNIQUE on EventId alone (preserves
  InsertIfNotExistsAsync idempotency from M1-T8). Non-aligned because
  partition-aligned unique indexes require the partition column in the key,
  which would weaken to composite uniqueness; the purge story (M2/M3)
  rebuilds this index around partition switches.

Access control:
- scadalink_audit_writer: GRANT INSERT + GRANT SELECT, DENY UPDATE + DENY DELETE
  on AuditLog. The explicit DENY guarantees later db_datawriter membership
  cannot quietly re-enable mutation.
- scadalink_audit_purger: GRANT SELECT on AuditLog, GRANT ALTER on SCHEMA::dbo
  (enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION).

Both role definitions are idempotent (IF DATABASE_PRINCIPAL_ID IS NULL).
Down() drops in reverse dependency order with IF EXISTS guards.

Integration tests (tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/):
- MsSqlMigrationFixture: connects to the running infra/mssql container (or
  the SCADALINK_MSSQL_TEST_CONN override), creates a unique per-fixture
  database, applies the migrations, drops the DB on dispose. Marks itself
  Available=false when MSSQL is unreachable so tests early-return cleanly
  on CI without the dev container.
- AddAuditLogTableMigrationTests: 8 tests covering table existence,
  partition function/scheme, partition-aligned PK, the 5 named indexes,
  both roles' grants, and a smoke test that a writer-role user receives
  SqlException with "permission" on UPDATE AuditLog.

ConfigurationDatabase tests: 142 passing -> 150 passing (8 new integration
tests). Full solution builds clean.

Package: tests project locally overrides Microsoft.Data.SqlClient to 6.1.1
(EF SqlServer 10.0.7 needs >= 6.1.1; central package version is pinned at
6.0.2 for the production ExternalSystemGateway).
This commit is contained in:
Joseph Doherty
2026-05-20 10:25:25 -04:00
parent 7d9550f779
commit d9c99242a3
6 changed files with 2357 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
using Microsoft.Data.SqlClient;
using Xunit.Abstractions;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Tests early-return with a clear test-output message when the
/// <see cref="MsSqlMigrationFixture"/> reports unavailable, so CI without
/// the dev MSSQL container still runs the suite green.
/// </remarks>
public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
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<int>(
"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<int>(
"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<int>(
"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<int>(
"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<string?>(
"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<int>(
"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<int>(
"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<int>(
"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<int>(
"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<int>(
"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<int>(
"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<int>(
"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<int>(
"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<int>(
"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<SqlException>(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 ------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
private async Task<bool> 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<T> ScalarAsync<T>(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))!;
}
}

View File

@@ -0,0 +1,192 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Per-test-class MSSQL fixture for the Bundle C integration tests (#23 M1).
///
/// Creates a fresh, uniquely-named test database on the running infra/mssql
/// container, applies the EF migrations against it, and drops it on dispose.
/// When MSSQL is not reachable (CI without the container), <see cref="Available"/>
/// is set to false so each test can early-return cleanly — keeping the test
/// suite green wherever it runs.
/// </summary>
/// <remarks>
/// xUnit 2.9.x has no dynamic Skip; the early-return-after-output pattern is
/// the project convention. Tests calling <see cref="EnsureAvailableOrSkip"/>
/// receive a clear log line in the output explaining why they did not run.
/// </remarks>
public sealed class MsSqlMigrationFixture : IDisposable
{
// Same credentials infra/mssql/setup.sql + docker-compose use. Not a committed
// production secret — this is a local dev container connection string.
private const string DefaultAdminConnectionString =
"Server=localhost,1433;User Id=sa;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false";
private const string AdminEnvVar = "SCADALINK_MSSQL_TEST_CONN";
public string DatabaseName { get; }
public string ConnectionString { get; }
/// <summary>
/// True when the MSSQL container was reachable at fixture construction
/// time AND the per-fixture test database was successfully created. When
/// false, the integration tests using this fixture must early-return.
/// </summary>
public bool Available { get; }
/// <summary>
/// Populated when <see cref="Available"/> is false; describes why the
/// fixture chose to skip (env var unset, connect failed, etc.).
/// </summary>
public string SkipReason { get; }
private readonly string _adminConnectionString;
public MsSqlMigrationFixture()
{
// Short, lowercase guid suffix keeps the database identifier under SQL Server's
// 128-char limit and safe for raw concatenation (no quoting required).
DatabaseName = $"ScadaLinkAuditMigTest_{Guid.NewGuid():N}".Substring(0, 38);
// Env var lets CI / power users override the admin endpoint; absent
// defaults to the local docker dev container's sa connection.
var fromEnv = Environment.GetEnvironmentVariable(AdminEnvVar);
_adminConnectionString = string.IsNullOrWhiteSpace(fromEnv)
? DefaultAdminConnectionString
: fromEnv;
try
{
using var connection = new SqlConnection(_adminConnectionString);
// Short timeout so the suite skips quickly in a no-container environment
// rather than hanging on SqlClient's default 30s connect timeout.
connection.Open();
using var createCmd = connection.CreateCommand();
createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];";
createCmd.ExecuteNonQuery();
ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName);
Available = true;
SkipReason = string.Empty;
}
catch (Exception ex)
{
// Don't fail fixture construction — the surrounding test classes
// must remain runnable on a CI box without MSSQL. Each [Fact] gates
// on Available and skips with a clear reason via test output.
ConnectionString = string.Empty;
Available = false;
SkipReason = $"MSSQL not reachable at '{RedactPassword(_adminConnectionString)}': {ex.GetType().Name}: {ex.Message}";
}
}
/// <summary>
/// Applies the EF migrations to the per-fixture test database via a freshly
/// constructed <see cref="ScadaLinkDbContext"/> pointed at it. Uses the
/// schema-only single-argument constructor — the AuditLog migration does
/// not write secret-bearing columns at apply time.
/// </summary>
public async Task ApplyAuditMigrationAsync(CancellationToken cancellationToken = default)
{
ThrowIfUnavailable();
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(ConnectionString)
.Options;
await using var context = new ScadaLinkDbContext(options);
await context.Database.MigrateAsync(cancellationToken);
}
/// <summary>
/// Convenience for opening a fresh <see cref="SqlConnection"/> to the test
/// database. Caller is responsible for disposal.
/// </summary>
public SqlConnection OpenConnection()
{
ThrowIfUnavailable();
var connection = new SqlConnection(ConnectionString);
connection.Open();
return connection;
}
public void Dispose()
{
if (!Available)
{
return;
}
// Best-effort drop — never let a teardown failure pollute later runs.
// SINGLE_USER WITH ROLLBACK IMMEDIATE detaches lingering pooled connections
// so the DROP DATABASE doesn't fail with "database is in use".
try
{
// Connection-pool cleanup is necessary because EF's MigrateAsync leaves
// pooled connections behind; SqlConnection.ClearAllPools() forces them
// closed so the SINGLE_USER + DROP sequence below can complete.
SqlConnection.ClearAllPools();
using var connection = new SqlConnection(_adminConnectionString);
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText =
$"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
$"BEGIN " +
$" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
$" DROP DATABASE [{DatabaseName}]; " +
$"END";
cmd.ExecuteNonQuery();
}
catch
{
// Swallow — the database name carries a random guid suffix so a
// stranded test database does not collide with future runs.
}
}
/// <summary>
/// Throws an <see cref="InvalidOperationException"/> when invoked on an
/// unavailable fixture; tests should branch on <see cref="Available"/>
/// before reaching this code path.
/// </summary>
private void ThrowIfUnavailable()
{
if (!Available)
{
throw new InvalidOperationException(
$"MsSqlMigrationFixture is not Available: {SkipReason}");
}
}
private static string BuildPerDbConnectionString(string adminConnectionString, string databaseName)
{
var builder = new SqlConnectionStringBuilder(adminConnectionString)
{
InitialCatalog = databaseName,
};
return builder.ConnectionString;
}
private static string RedactPassword(string connectionString)
{
try
{
var builder = new SqlConnectionStringBuilder(connectionString)
{
Password = "***",
};
return builder.ConnectionString;
}
catch
{
return "<unparseable>";
}
}
}

View File

@@ -10,7 +10,16 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<!--
Bundle C migration integration tests need Microsoft.Data.SqlClient. EF
SqlServer 10.0.7 pulls in SqlClient >= 6.1.1, but the central package
version is pinned at 6.0.2 (the version the production
ExternalSystemGateway uses). Override the version locally for the test
project only; production assemblies are unaffected.
-->
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />