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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Bundle C (#23 M1): creates the centralized AuditLog table with monthly
/// partitioning and the two access-control roles documented in alog.md §4.
///
/// Structure:
/// 1. Partition function <c>pf_AuditLog_Month</c> (RANGE RIGHT) with 24
/// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC.
/// 2. Partition scheme <c>ps_AuditLog_Month</c> mapping every partition to
/// [PRIMARY] (dev/test parity; production may relocate via filegroups).
/// 3. <c>AuditLog</c> table created via raw SQL so it is created directly
/// on the partition scheme. The clustered PK is composite
/// {EventId, OccurredAtUtc} — required because partition-aligned PKs
/// must include the partition column.
/// 4. Five reconciliation/query indexes from alog.md §4, plus the
/// UX_AuditLog_EventId unique index that preserves single-column
/// EventId uniqueness for InsertIfNotExistsAsync (M1-T8). All
/// non-clustered indexes are partition-aligned on
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c>.
/// 5. Two database roles:
/// - <c>scadalink_audit_writer</c>: INSERT + SELECT on AuditLog, with
/// explicit DENY on UPDATE and DELETE so additive role membership
/// (e.g. later db_datawriter) cannot accidentally re-enable mutation.
/// - <c>scadalink_audit_purger</c>: SELECT on AuditLog and ALTER on
/// SCHEMA::dbo so the purger can run ALTER PARTITION FUNCTION SWITCH
/// and SWITCH PARTITION when sliding the retention window.
/// </summary>
public partial class AddAuditLogTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Partition function (monthly boundaries Jan 2026 Dec 2027 UTC).
// RANGE RIGHT — the boundary value belongs to the right-hand partition,
// matching the convention used by SQL Server partition-switch tooling.
migrationBuilder.Sql(@"
CREATE PARTITION FUNCTION pf_AuditLog_Month (datetime2(7))
AS RANGE RIGHT FOR VALUES (
'2026-01-01T00:00:00', '2026-02-01T00:00:00', '2026-03-01T00:00:00', '2026-04-01T00:00:00',
'2026-05-01T00:00:00', '2026-06-01T00:00:00', '2026-07-01T00:00:00', '2026-08-01T00:00:00',
'2026-09-01T00:00:00', '2026-10-01T00:00:00', '2026-11-01T00:00:00', '2026-12-01T00:00:00',
'2027-01-01T00:00:00', '2027-02-01T00:00:00', '2027-03-01T00:00:00', '2027-04-01T00:00:00',
'2027-05-01T00:00:00', '2027-06-01T00:00:00', '2027-07-01T00:00:00', '2027-08-01T00:00:00',
'2027-09-01T00:00:00', '2027-10-01T00:00:00', '2027-11-01T00:00:00', '2027-12-01T00:00:00'
);");
// 2) Partition scheme mapping every partition to [PRIMARY].
migrationBuilder.Sql(@"
CREATE PARTITION SCHEME ps_AuditLog_Month
AS PARTITION pf_AuditLog_Month ALL TO ([PRIMARY]);");
// 3) Create the table directly on the partition scheme. Column shapes
// are copied from AuditLogEntityTypeConfiguration so the live schema
// matches the EF model exactly. The clustered PK is composite to
// satisfy SQL Server's rule that partition-aligned clustered indexes
// must include the partition column.
migrationBuilder.Sql(@"
CREATE TABLE dbo.AuditLog (
EventId uniqueidentifier NOT NULL,
OccurredAtUtc datetime2(7) NOT NULL,
IngestedAtUtc datetime2(7) NULL,
Channel varchar(32) NOT NULL,
Kind varchar(32) NOT NULL,
CorrelationId uniqueidentifier NULL,
SourceSiteId varchar(64) NULL,
SourceInstanceId varchar(128) NULL,
SourceScript varchar(128) NULL,
Actor varchar(128) NULL,
Target varchar(256) NULL,
Status varchar(32) NOT NULL,
HttpStatus int NULL,
DurationMs int NULL,
ErrorMessage nvarchar(1024) NULL,
ErrorDetail nvarchar(max) NULL,
RequestSummary nvarchar(max) NULL,
ResponseSummary nvarchar(max) NULL,
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
ON ps_AuditLog_Month(OccurredAtUtc)
) ON ps_AuditLog_Month(OccurredAtUtc);");
// 4) Reconciliation/query indexes from alog.md §4. All non-clustered
// indexes are partition-aligned on ps_AuditLog_Month(OccurredAtUtc)
// so partition-switch operations only touch a single partition. The
// filtered indexes carry their NOT NULL predicates as documented.
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
ON dbo.AuditLog (OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
ON dbo.AuditLog (CorrelationId)
WHERE CorrelationId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
ON dbo.AuditLog (Channel ASC, Status ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
WHERE Target IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
// The EventId uniqueness index supports InsertIfNotExistsAsync
// (M1-T8). It is INTENTIONALLY non-aligned (placed on [PRIMARY]
// rather than ps_AuditLog_Month).
//
// SQL Server's rule for unique partition-aligned indexes is that the
// partition column must be a SUBSET of the index key. Including
// OccurredAtUtc in the key would change the uniqueness semantics
// from "EventId is globally unique" to "(EventId, OccurredAtUtc)
// is unique", which is the same guarantee the composite PK already
// provides — it would not give us single-column EventId uniqueness.
//
// Trade-off: a non-aligned index disables ALTER TABLE … SWITCH
// PARTITION on AuditLog. The M1 purge story (M2/M3) uses an
// explicit rebuild path that drops and re-creates this index
// around the switch, so the aligned-indexes pattern is preserved
// for partition switching at purge time.
migrationBuilder.Sql(@"
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
ON dbo.AuditLog (EventId)
ON [PRIMARY];");
// 5) DB roles. Both definitions are idempotent so the migration is
// safe to re-apply against a database that already has the role.
// The DENY UPDATE / DENY DELETE on the writer role is deliberate —
// a future db_datawriter membership cannot quietly re-enable
// mutation because DENY outranks GRANT.
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NULL
EXEC sp_executesql N'CREATE ROLE scadalink_audit_writer';
GRANT INSERT ON dbo.AuditLog TO scadalink_audit_writer;
GRANT SELECT ON dbo.AuditLog TO scadalink_audit_writer;
DENY UPDATE ON dbo.AuditLog TO scadalink_audit_writer;
DENY DELETE ON dbo.AuditLog TO scadalink_audit_writer;");
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NULL
EXEC sp_executesql N'CREATE ROLE scadalink_audit_purger';
GRANT SELECT ON dbo.AuditLog TO scadalink_audit_purger;
GRANT ALTER ON SCHEMA::dbo TO scadalink_audit_purger;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Drop in reverse dependency order so each statement's prerequisites
// still exist when it runs. Each DROP is guarded so a partial Up()
// (or a re-applied Down()) cannot fail on missing objects.
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NOT NULL
EXEC sp_executesql N'DROP ROLE scadalink_audit_purger';
IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NOT NULL
EXEC sp_executesql N'DROP ROLE scadalink_audit_writer';");
// Indexes are dropped implicitly when the table goes away, but
// dropping them explicitly first keeps the Down() statement self-
// describing and mirrors the Up() shape.
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Target_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Channel_Status_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_CorrelationId' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Site_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_OccurredAtUtc' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;");
migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.AuditLog', 'U') IS NOT NULL
DROP TABLE dbo.AuditLog;");
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month')
DROP PARTITION SCHEME ps_AuditLog_Month;
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month')
DROP PARTITION FUNCTION pf_AuditLog_Month;");
}
}
}

View File

@@ -41,6 +41,123 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b =>
{
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("OccurredAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Actor")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("DurationMs")
.HasColumnType("int");
b.Property<string>("ErrorDetail")
.HasColumnType("nvarchar(max)");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Extra")
.HasColumnType("nvarchar(max)");
b.Property<string>("ForwardState")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<int?>("HttpStatus")
.HasColumnType("int");
b.Property<DateTime?>("IngestedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<bool>("PayloadTruncated")
.HasColumnType("bit");
b.Property<string>("RequestSummary")
.HasColumnType("nvarchar(max)");
b.Property<string>("ResponseSummary")
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceInstanceId")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("SourceScript")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("SourceSiteId")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<string>("Target")
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("varchar(256)");
b.HasKey("EventId", "OccurredAtUtc");
b.HasIndex("CorrelationId")
.HasDatabaseName("IX_AuditLog_CorrelationId")
.HasFilter("[CorrelationId] IS NOT NULL");
b.HasIndex("EventId")
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("OccurredAtUtc")
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
b.HasIndex("SourceSiteId", "OccurredAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Site_Occurred");
b.HasIndex("Target", "OccurredAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Target_Occurred")
.HasFilter("[Target] IS NOT NULL");
b.HasIndex("Channel", "Status", "OccurredAtUtc")
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
b.ToTable("AuditLog", (string)null);
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
{
b.Property<int>("Id")

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" />