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).
193 lines
7.2 KiB
C#
193 lines
7.2 KiB
C#
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>";
|
|
}
|
|
}
|
|
}
|