refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,146 @@
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// SourceNode-stamping (#23) integration tests for the
/// <c>AddAuditLogSourceNode</c> migration: applies the EF migrations to a
/// freshly-created MSSQL test database on the running infra/mssql container and
/// asserts that the central <c>AuditLog</c> table carries the new
/// <c>SourceNode varchar(64) NULL</c> column AND a partition-aligned
/// <c>IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)</c> composite index.
/// </summary>
/// <remarks>
/// Mirrors the <c>AddAuditLogParentExecutionId</c> shape: column is an additive
/// metadata-only <c>ALTER TABLE … ADD</c>; the new index is created via raw SQL
/// so it lives on <c>ps_AuditLog_Month(OccurredAtUtc)</c> like every other
/// <c>IX_AuditLog_*</c> index, preserving the partition-switch purge path.
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c>
/// so the runner reports them as Skipped (not Passed) when MSSQL is unreachable.
/// The fixture applies the migrations once at construction time.
/// </remarks>
public class AddAuditLogSourceNodeMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddAuditLogSourceNodeMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsSourceNodeColumn_ToAuditLog()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task SourceNodeColumn_IsNullableVarchar64()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// varchar (ASCII), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.)
// and design doc fixes the column at varchar(64). Catches an EF default to
// nvarchar if the migration ever drops `unicode: false`.
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal("varchar", dataType);
var maxLength = await ScalarAsync<int>(
"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal(64, maxLength);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal("YES", isNullable);
}
[SkippableFact]
public async Task AppliesMigration_CreatesIxAuditLogNodeOccurredIndex()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Locked index name from the design doc / CLAUDE.md.
var indexCount = 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 = 'IX_AuditLog_Node_Occurred';");
Assert.Equal(1, indexCount);
}
[SkippableFact]
public async Task IxAuditLogNodeOccurred_HasExpectedKeyColumnsInOrder()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Key columns in order: SourceNode, OccurredAtUtc. sys.index_columns.key_ordinal
// gives the position in the index key (1-based); is_included_column = 0 means
// it's part of the key, not an INCLUDE.
var keyColumns = new List<(int Ordinal, string Name)>();
await using (var conn = _fixture.OpenConnection())
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT ic.key_ordinal, c.name " +
"FROM sys.indexes i " +
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
"INNER JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id " +
"INNER JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id " +
"WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred' " +
" AND ic.is_included_column = 0 " +
"ORDER BY ic.key_ordinal;";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
keyColumns.Add((reader.GetByte(0), reader.GetString(1)));
}
}
Assert.Equal(2, keyColumns.Count);
Assert.Equal("SourceNode", keyColumns[0].Name);
Assert.Equal("OccurredAtUtc", keyColumns[1].Name);
}
[SkippableFact]
public async Task IxAuditLogNodeOccurred_LivesOnPsAuditLogMonth_PartitionScheme()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Partition-aligned indexes are required so the AuditLog partition-switch
// purge keeps working. Every other IX_AuditLog_* index lives on
// ps_AuditLog_Month(OccurredAtUtc); the new one must too.
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.name = 'IX_AuditLog_Node_Occurred';");
Assert.Equal("ps_AuditLog_Month", schemeName);
}
// --- helpers ------------------------------------------------------------
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))!;
}
}
@@ -0,0 +1,241 @@
using Microsoft.Data.SqlClient;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.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 use <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot(...)</c> from
/// the Xunit.SkippableFact package so the runner reports them as Skipped (not
/// Passed) when MSSQL is unreachable. xunit 2.9.x does not ship a native
/// <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> — those land in xunit v3 — so
/// SkippableFact is the canonical equivalent for this project. The fixture
/// applies the migration once at construction time.
/// </remarks>
public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_CreatesAuditLogTable()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var exists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_NAME = 'AuditLog' AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, exists);
}
[SkippableFact]
public async Task AppliesMigration_CreatesPartitionFunction_pf_AuditLog_Month()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
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}.");
}
[SkippableFact]
public async Task AppliesMigration_CreatesPartitionScheme_ps_AuditLog_Month()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var schemeExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month';");
Assert.Equal(1, schemeExists);
}
[SkippableFact]
public async Task AppliesMigration_TableIsPartitionAligned()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// 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);
}
[SkippableFact]
public async Task AppliesMigration_CreatesFiveNamedIndexes()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
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}.");
}
}
[SkippableFact]
public async Task AppliesMigration_CreatesAuditWriterRole_WithExpectedGrants()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var roleExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_principals " +
"WHERE name = 'scadabridge_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 = 'scadabridge_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 = 'scadabridge_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 = 'scadabridge_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 = 'scadabridge_audit_writer' AND o.name = 'AuditLog' " +
" AND p.permission_name = 'DELETE' AND p.state IN ('G','W');");
Assert.Equal(0, deleteGranted);
}
[SkippableFact]
public async Task AppliesMigration_CreatesAuditPurgerRole_WithExpectedGrants()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var roleExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_principals " +
"WHERE name = 'scadabridge_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 = 'scadabridge_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 = 'scadabridge_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);
}
[SkippableFact]
public async Task AuditWriterRole_CannotUpdateAuditLog()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Set up a dedicated user mapped to scadabridge_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 scadabridge_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 ------------------------------------------------------------
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))!;
}
}
@@ -0,0 +1,70 @@
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Audit Log #23 (ExecutionId Task 5) integration test for the
/// <c>AddNotificationOriginExecutionId</c> migration: applies the EF migrations
/// to a freshly-created MSSQL test database on the running infra/mssql container
/// and asserts that the <c>Notifications</c> table carries the new
/// <c>OriginExecutionId</c> column as a nullable <c>uniqueidentifier</c>.
/// </summary>
/// <remarks>
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
/// fixture applies the migrations once at construction time.
/// </remarks>
public class AddNotificationOriginExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddNotificationOriginExecutionIdMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsOriginExecutionIdColumn_ToNotifications()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task OriginExecutionIdColumn_IsNullableUniqueIdentifier()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
Assert.Equal("uniqueidentifier", dataType);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
Assert.Equal("YES", isNullable);
}
// --- helpers ------------------------------------------------------------
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))!;
}
}
@@ -0,0 +1,71 @@
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Audit Log ParentExecutionId integration test for the
/// <c>AddNotificationOriginParentExecutionId</c> migration: applies the EF
/// migrations to a freshly-created MSSQL test database on the running
/// infra/mssql container and asserts that the <c>Notifications</c> table carries
/// the new <c>OriginParentExecutionId</c> column as a nullable
/// <c>uniqueidentifier</c>.
/// </summary>
/// <remarks>
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
/// fixture applies the migrations once at construction time.
/// </remarks>
public class AddNotificationOriginParentExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddNotificationOriginParentExecutionIdMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsOriginParentExecutionIdColumn_ToNotifications()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task OriginParentExecutionIdColumn_IsNullableUniqueIdentifier()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
Assert.Equal("uniqueidentifier", dataType);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
Assert.Equal("YES", isNullable);
}
// --- helpers ------------------------------------------------------------
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))!;
}
}
@@ -0,0 +1,76 @@
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// SourceNode-stamping (#23) integration tests for the
/// <c>AddNotificationSourceNode</c> migration: applies the EF migrations to a
/// freshly-created MSSQL test database on the running infra/mssql container and
/// asserts that the central <c>Notifications</c> table carries the new
/// <c>SourceNode varchar(64) NULL</c> column. No index — Notification Outbox KPIs
/// are per-site, not per-node, so the column is never a query predicate on this
/// table; it's only echoed onto NotifyDeliver audit rows (#23) for cross-row
/// correlation.
/// </summary>
/// <remarks>
/// <c>Notifications</c> is non-partitioned (operational state, not audit), so this
/// is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
/// </remarks>
public class AddNotificationSourceNodeMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddNotificationSourceNodeMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsSourceNodeColumn_ToNotifications()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task SourceNodeColumn_IsNullableVarchar64()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// varchar(64), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.).
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal("varchar", dataType);
var maxLength = await ScalarAsync<int>(
"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal(64, maxLength);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal("YES", isNullable);
}
// --- helpers ------------------------------------------------------------
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))!;
}
}
@@ -0,0 +1,75 @@
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// SourceNode-stamping (#23) integration tests for the
/// <c>AddSiteCallSourceNode</c> migration: applies the EF migrations to a
/// freshly-created MSSQL test database on the running infra/mssql container and
/// asserts that the central <c>SiteCalls</c> table carries the new
/// <c>SourceNode varchar(64) NULL</c> column. No index — Site Call Audit KPIs
/// are per-site, not per-node, on this table; <c>SourceNode</c> is operational
/// metadata, not a query predicate here.
/// </summary>
/// <remarks>
/// <c>SiteCalls</c> is non-partitioned (operational state, not audit), so this
/// is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
/// </remarks>
public class AddSiteCallSourceNodeMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddSiteCallSourceNodeMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsSourceNodeColumn_ToSiteCalls()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task SourceNodeColumn_IsNullableVarchar64()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// varchar(64), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.).
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal("varchar", dataType);
var maxLength = await ScalarAsync<int>(
"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal(64, maxLength);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';");
Assert.Equal("YES", isNullable);
}
// --- helpers ------------------------------------------------------------
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))!;
}
}
@@ -0,0 +1,125 @@
using Microsoft.Data.SqlClient;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Bundle B2 (#22, #23 M3) integration tests for the <c>AddSiteCallsTable</c>
/// migration: applies the EF migrations to a freshly-created MSSQL test database
/// on the running infra/mssql container and asserts that the resulting
/// <c>SiteCalls</c> table carries the expected columns, primary key, and the
/// two named operational indexes.
/// </summary>
/// <remarks>
/// Unlike <c>AddAuditLogTable</c>, the SiteCalls table is operational (mutable)
/// state — no partition function, no partition scheme, no DB-role restriction.
/// Standard <c>[PRIMARY]</c> filegroup. Tests pair <see cref="SkippableFactAttribute"/>
/// with <c>Skip.IfNot(...)</c> so the runner reports them as Skipped (not Passed)
/// when MSSQL is unreachable. The fixture applies the migration once at
/// construction time.
/// </remarks>
public class AddSiteCallsTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddSiteCallsTableMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_CreatesSiteCallsTable_WithExpectedColumns()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var exists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_NAME = 'SiteCalls' AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, exists);
// Every required column from SiteCall + IngestedAtUtc. We don't pin types
// here because EF's CreateTable layer already encodes them; the
// entity-config tests cover length / unicode / nullability for the
// value-converted PK column. Just confirm the schema has all twelve.
var expectedColumns = new[]
{
"TrackedOperationId",
"Channel",
"Target",
"SourceSite",
"Status",
"RetryCount",
"LastError",
"HttpStatus",
"CreatedAtUtc",
"UpdatedAtUtc",
"TerminalAtUtc",
"IngestedAtUtc",
};
foreach (var column in expectedColumns)
{
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
$"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = '{column}';");
Assert.True(present == 1, $"Expected SiteCalls.{column} to exist; found {present}.");
}
}
[SkippableFact]
public async Task AppliesMigration_CreatesPK_OnTrackedOperationId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Walk sys.indexes for the table's clustered PK index and confirm its
// single key column is TrackedOperationId. SiteCalls is non-partitioned
// so the PK is a simple single-column clustered index.
var pkColumn = await ScalarAsync<string?>(
"SELECT c.name FROM sys.indexes i " +
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
"INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id " +
"INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id " +
"WHERE o.name = 'SiteCalls' AND i.is_primary_key = 1;");
Assert.Equal("TrackedOperationId", pkColumn);
}
[SkippableFact]
public async Task AppliesMigration_CreatesIndex_Source_Created()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
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 = 'SiteCalls' AND i.name = 'IX_SiteCalls_Source_Created';");
Assert.Equal(1, count);
}
[SkippableFact]
public async Task AppliesMigration_CreatesIndex_Status_Updated()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
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 = 'SiteCalls' AND i.name = 'IX_SiteCalls_Status_Updated';");
Assert.Equal(1, count);
}
// --- helpers ------------------------------------------------------------
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))!;
}
}
@@ -0,0 +1,237 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
namespace ZB.MOM.WW.ScadaBridge.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 and <see cref="SkipReason"/> describes why — tests pair
/// <c>[SkippableFact]</c> with <c>Skip.IfNot(_fixture.Available, _fixture.SkipReason)</c>
/// so the runner reports them as Skipped (not silently Passed).
/// </summary>
/// <remarks>
/// xunit 2.9.x has no native <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> (those
/// are v3); the project uses the Xunit.SkippableFact package as the canonical
/// equivalent. The fixture attempts connect + create-db + migrate once at
/// construct time. The Connect Timeout=3 in <see cref="DefaultAdminConnectionString"/>
/// makes the fixture fail fast in a no-container environment (under ~5s total)
/// instead of hanging 30s on SqlClient's default. Only connect-failure exceptions
/// (SqlException, plus the InvalidOperationException SqlClient raises from
/// OpenAsync) flip Available to false — every other exception bubbles up so a
/// real bug is not silently swallowed.
/// </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.
// Connect Timeout=3 makes the fixture fail fast (~3s) in a no-container
// environment rather than hanging on SqlClient's default 30s connect timeout.
private const string DefaultAdminConnectionString =
"Server=localhost,1433;User Id=sa;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=3";
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 = $"ScadaBridgeAuditMigTest_{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;
// Step 1: open the admin connection. This is the only step that may
// legitimately fail when MSSQL is absent; SqlException + the rare
// InvalidOperationException from OpenAsync are the connect-failure
// surfaces we tolerate. Everything else (CREATE DATABASE, MigrateAsync)
// is treated as a hard fixture failure once we *have* a connection.
try
{
using var connection = new SqlConnection(_adminConnectionString);
try
{
connection.Open();
}
catch (SqlException ex)
{
ConnectionString = string.Empty;
Available = false;
SkipReason = $"MSSQL unavailable (connect failed: SqlException {ex.Number}: {ex.Message})";
return;
}
catch (InvalidOperationException ex)
{
ConnectionString = string.Empty;
Available = false;
SkipReason = $"MSSQL unavailable (OpenAsync threw: {ex.Message})";
return;
}
using (var createCmd = connection.CreateCommand())
{
createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];";
createCmd.ExecuteNonQuery();
}
ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName);
// Apply the EF migrations once at fixture construction so each test
// can read from a fully-migrated database without per-test setup.
// Failures here are real bugs — let them bubble.
ApplyMigrationsCore(ConnectionString, CancellationToken.None).GetAwaiter().GetResult();
Available = true;
SkipReason = string.Empty;
}
catch
{
// Best-effort cleanup if we created the database but failed before
// setting Available — otherwise Dispose() would skip the drop.
TryDropOrphanDatabase();
throw;
}
}
private void TryDropOrphanDatabase()
{
if (string.IsNullOrEmpty(ConnectionString))
{
return;
}
try
{
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
{
// Best-effort — orphan databases carry a random guid suffix.
}
}
/// <summary>
/// Applies the EF migrations to the per-fixture test database via a freshly
/// constructed <see cref="ScadaBridgeDbContext"/> pointed at it. Uses the
/// schema-only single-argument constructor — the AuditLog migration does
/// not write secret-bearing columns at apply time. Called once from the
/// constructor; tests do not invoke this directly.
/// </summary>
private static async Task ApplyMigrationsCore(string connectionString, CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(connectionString)
.Options;
await using var context = new ScadaBridgeDbContext(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;
}
}