diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs index 251857f4..91bf2c5d 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs @@ -1,3 +1,5 @@ +using System.Data.Common; +using System.Globalization; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Xunit; @@ -13,9 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// public sealed class SchemaComplianceFixture : IDisposable { + /// The name prefix every Configuration test database carries; also the reaper's LIKE anchor. + internal const string TestDatabasePrefix = "OtOpcUaConfig_Test_"; + private const string DefaultServer = "10.100.0.35,14330"; private const string DefaultSaPassword = "OtOpcUaDev_2026!"; + /// How long a test database must have existed before the reaper considers it certainly orphaned. + private static readonly TimeSpan OrphanAge = TimeSpan.FromHours(24); + + private static readonly Lazy Reaper = new(RunReaper, LazyThreadSafetyMode.ExecutionAndPublication); + /// Gets the name of the test database. public string DatabaseName { get; } /// Gets the connection string for the test database. @@ -27,7 +37,13 @@ public sealed class SchemaComplianceFixture : IDisposable var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; - DatabaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; + // A killed test process never reaches Dispose, so its database outlives the run. Reap the ones old + // enough to be certainly nobody's, once per process. This fixture has no reachability probe, so an + // unreachable server must leave behaviour exactly as it was: the reaper silently does nothing and + // Migrate() below throws as it always has. + _ = Reaper.Value; + + DatabaseName = $"{TestDatabasePrefix}{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; ConnectionString = $"Server={server};Database={DatabaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;"; @@ -48,6 +64,16 @@ public sealed class SchemaComplianceFixture : IDisposable return conn; } + private static int RunReaper() + { + var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; + var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; + var master = + $"Server={server};Database=master;User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;"; + + return SqlTestDatabaseReaper.ReapOrphans(master, TestDatabasePrefix, DateTime.UtcNow - OrphanAge).Count; + } + /// Disposes the fixture and drops the test database. public void Dispose() { @@ -68,6 +94,156 @@ END"; } } +/// +/// Drops test databases that a killed test process left behind on the shared SQL Server. +/// +/// +/// +/// names its database +/// {prefix}{yyyyMMddHHmmss-UTC}_{guid:N} and drops it in Dispose — which a +/// Ctrl-C, a crashed runner or a killed container never reaches, so the database survives +/// forever on an instance nobody gardens. +/// +/// +/// This is DROP DATABASE against a server other people share, so the rules are deliberately +/// narrow and every one of them is load-bearing: +/// +/// +/// +/// the LIKE is an exact prefix with _ escaped (an unescaped _ is a +/// single-character wildcard in T-SQL) — never widen it; +/// +/// +/// a name whose embedded timestamp does not parse is left alone, because an unparseable +/// name is by definition not one this code created; +/// +/// +/// only names strictly older than the cutoff are dropped. At 24 hours that is far beyond +/// any run, so the reaper can never race a live sibling run on the same server; and +/// +/// +/// nothing here ever throws. Unlike this project's Dispose, which deliberately lets a +/// teardown failure surface, opportunistic housekeeping must never fail a green run. +/// +/// +/// +/// Deliberately duplicated from the AdminUI.Tests copy rather than shared: the two suites have no +/// common test-support assembly, and a new one purely for ~100 lines of best-effort housekeeping +/// costs more than the duplication. +/// +/// +internal static class SqlTestDatabaseReaper +{ + private const string TimestampFormat = "yyyyMMddHHmmss"; + + /// + /// Drops every database matching whose embedded creation timestamp is + /// strictly older than . + /// + /// A connection string pointed at master. + /// The exact test-database name prefix, e.g. OtOpcUaConfig_Test_. + /// Databases created strictly before this UTC instant are dropped. + /// The names actually dropped; empty when there was nothing to reap or anything went wrong. + internal static IReadOnlyList ReapOrphans(string masterConnectionString, string prefix, DateTime cutoffUtc) + { + var reaped = new List(); + + try + { + foreach (var name in ListCandidates(masterConnectionString, prefix)) + { + if (!TryParseCreatedUtc(name, prefix, out var createdUtc)) continue; + if (createdUtc >= cutoffUtc) continue; + + try + { + if (TryDropDatabase(masterConnectionString, name)) reaped.Add(name); + } + catch (Exception) + { + // One stubborn database must not stop the sweep, nor fail the run that triggered it. + } + } + } + catch (Exception) + { + // Enumeration failed (server down, no permission). Reaping is opportunistic housekeeping. + } + + if (reaped.Count > 0) + { + Console.WriteLine( + $"[sql-test-reaper] dropped {reaped.Count} orphaned test database(s) older than " + + $"{cutoffUtc:yyyy-MM-dd HH:mm:ss}Z: {string.Join(", ", reaped)}"); + } + + return reaped; + } + + /// Drops the named database, swallowing database errors — reaping is best-effort. + /// A connection string pointed at master. + /// The database to drop. + /// when the statement ran without error. + internal static bool TryDropDatabase(string masterConnectionString, string databaseName) + { + try + { + using var conn = new SqlConnection(masterConnectionString); + conn.Open(); + + using var cmd = conn.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(); + return true; + } + catch (DbException) + { + return false; + } + } + + private static List ListCandidates(string masterConnectionString, string prefix) + { + var names = new List(); + + using var conn = new SqlConnection(masterConnectionString); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"SELECT name FROM sys.databases WHERE name LIKE @pattern ESCAPE '\'"; + cmd.Parameters.Add(new SqlParameter("@pattern", prefix.Replace("_", @"\_", StringComparison.Ordinal) + "%")); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) names.Add(reader.GetString(0)); + + return names; + } + + private static bool TryParseCreatedUtc(string databaseName, string prefix, out DateTime createdUtc) + { + createdUtc = default; + + if (!databaseName.StartsWith(prefix, StringComparison.Ordinal)) return false; + + var rest = databaseName.AsSpan(prefix.Length); + + // Expect exactly "<14-digit timestamp>_" — anything else is not a name this code minted. + if (rest.Length < TimestampFormat.Length + 1 || rest[TimestampFormat.Length] != '_') return false; + + return DateTime.TryParseExact( + rest[..TimestampFormat.Length], + TimestampFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out createdUtc); + } +} + [CollectionDefinition(nameof(SchemaComplianceCollection))] public sealed class SchemaComplianceCollection : ICollectionFixture { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs index 26a8b7bb..55bcea41 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using System.Globalization; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Xunit; @@ -74,6 +75,10 @@ public sealed class AdminUiSqlServerFixture : IDisposable SkipReason = AdminUiSqlServer.SkipReason; if (SkipReason is not null) return; + // A killed test process never reaches Dispose, so its database outlives the run. Reap the ones + // old enough to be certainly nobody's (once per process, and only after the probe answered). + AdminUiSqlServer.ReapOrphanedTestDatabasesOnce(); + DatabaseName = $"OtOpcUaAdminUi_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; ConnectionString = AdminUiSqlServer.ConnectionStringFor(DatabaseName); @@ -106,27 +111,8 @@ public sealed class AdminUiSqlServerFixture : IDisposable { if (ConnectionString is null || DatabaseName is null) return; - try - { - var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString; - - using var conn = new SqlConnection(master); - conn.Open(); - - using var cmd = conn.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 (DbException) - { - // Teardown is best-effort: a dropped connection here must not fail an otherwise-green run. - // The database name carries a timestamp, so a leaked one is identifiable. - } + var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString; + SqlTestDatabaseReaper.TryDropDatabase(master, DatabaseName); } private sealed class SqlServerContextFactory(string connectionString) @@ -150,15 +136,30 @@ END"; /// internal static class AdminUiSqlServer { + /// The name prefix every AdminUI test database carries; also the reaper's LIKE anchor. + public const string TestDatabasePrefix = "OtOpcUaAdminUi_Test_"; + private const string DefaultServer = "10.100.0.35,14330"; private const string DefaultSaPassword = "OtOpcUaDev_2026!"; private const int ProbeTimeoutSeconds = 3; + /// How long a test database must have existed before the reaper considers it certainly orphaned. + private static readonly TimeSpan OrphanAge = TimeSpan.FromHours(24); + private static readonly Lazy Probe = new(RunProbe, LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly Lazy Reaper = new(RunReaper, LazyThreadSafetyMode.ExecutionAndPublication); + /// Gets the reason SQL Server is unusable, or when it answered. public static string? SkipReason => Probe.Value; + /// + /// Reaps orphaned AdminUI test databases older than 24 hours, at most once per process. Call only + /// after came back — the reaper never throws, but + /// against a dead server it would just burn a connect timeout for nothing. + /// + public static void ReapOrphanedTestDatabasesOnce() => _ = Reaper.Value; + /// Builds a connection string for the named database on the configured test server. /// The database to connect to. /// The connection string. @@ -171,6 +172,12 @@ internal static class AdminUiSqlServer "TrustServerCertificate=True;Encrypt=False;"; } + private static int RunReaper() => + SqlTestDatabaseReaper.ReapOrphans( + ConnectionStringFor("master"), + TestDatabasePrefix, + DateTime.UtcNow - OrphanAge).Count; + private static string? RunProbe() { var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; @@ -194,6 +201,152 @@ internal static class AdminUiSqlServer } } +/// +/// Drops test databases that a killed test process left behind on the shared SQL Server. +/// +/// +/// +/// Every fixture here names its database {prefix}{yyyyMMddHHmmss-UTC}_{guid:N} and drops it +/// in Dispose — which a Ctrl-C, a crashed runner or a killed container never reaches, +/// so the database survives forever on an instance nobody gardens. +/// +/// +/// This is DROP DATABASE against a server other people share, so the rules are deliberately +/// narrow and every one of them is load-bearing: +/// +/// +/// +/// the LIKE is an exact prefix with _ escaped (an unescaped _ is a +/// single-character wildcard in T-SQL) — never widen it; +/// +/// +/// a name whose embedded timestamp does not parse is left alone, because an unparseable +/// name is by definition not one this code created; +/// +/// +/// only names strictly older than the cutoff are dropped. At 24 hours that is far beyond +/// any run, so the reaper can never race a live sibling run on the same server; and +/// +/// +/// nothing here ever throws. A reaper that fails a green run would be worse than the leak it +/// cleans up. +/// +/// +/// +internal static class SqlTestDatabaseReaper +{ + private const string TimestampFormat = "yyyyMMddHHmmss"; + + /// + /// Drops every database matching whose embedded creation timestamp is + /// strictly older than . + /// + /// A connection string pointed at master. + /// The exact test-database name prefix, e.g. OtOpcUaAdminUi_Test_. + /// Databases created strictly before this UTC instant are dropped. + /// The names actually dropped; empty when there was nothing to reap or anything went wrong. + internal static IReadOnlyList ReapOrphans(string masterConnectionString, string prefix, DateTime cutoffUtc) + { + var reaped = new List(); + + try + { + foreach (var name in ListCandidates(masterConnectionString, prefix)) + { + if (!TryParseCreatedUtc(name, prefix, out var createdUtc)) continue; + if (createdUtc >= cutoffUtc) continue; + + try + { + if (TryDropDatabase(masterConnectionString, name)) reaped.Add(name); + } + catch (Exception) + { + // One stubborn database must not stop the sweep, nor fail the run that triggered it. + } + } + } + catch (Exception) + { + // Enumeration failed (server down, no permission). Reaping is opportunistic housekeeping. + } + + if (reaped.Count > 0) + { + Console.WriteLine( + $"[sql-test-reaper] dropped {reaped.Count} orphaned test database(s) older than " + + $"{cutoffUtc:yyyy-MM-dd HH:mm:ss}Z: {string.Join(", ", reaped)}"); + } + + return reaped; + } + + /// Drops the named database, swallowing database errors — teardown is best-effort. + /// A connection string pointed at master. + /// The database to drop. + /// when the statement ran without error. + internal static bool TryDropDatabase(string masterConnectionString, string databaseName) + { + try + { + using var conn = new SqlConnection(masterConnectionString); + conn.Open(); + + using var cmd = conn.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(); + return true; + } + catch (DbException) + { + // Teardown is best-effort: a dropped connection here must not fail an otherwise-green run. + // The database name carries a timestamp, so a leaked one is reaped by the next run instead. + return false; + } + } + + private static List ListCandidates(string masterConnectionString, string prefix) + { + var names = new List(); + + using var conn = new SqlConnection(masterConnectionString); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"SELECT name FROM sys.databases WHERE name LIKE @pattern ESCAPE '\'"; + cmd.Parameters.Add(new SqlParameter("@pattern", prefix.Replace("_", @"\_", StringComparison.Ordinal) + "%")); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) names.Add(reader.GetString(0)); + + return names; + } + + private static bool TryParseCreatedUtc(string databaseName, string prefix, out DateTime createdUtc) + { + createdUtc = default; + + if (!databaseName.StartsWith(prefix, StringComparison.Ordinal)) return false; + + var rest = databaseName.AsSpan(prefix.Length); + + // Expect exactly "<14-digit timestamp>_" — anything else is not a name this code minted. + if (rest.Length < TimestampFormat.Length + 1 || rest[TimestampFormat.Length] != '_') return false; + + return DateTime.TryParseExact( + rest[..TimestampFormat.Length], + TimestampFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out createdUtc); + } +} + /// /// A that skips itself when the shared test SQL Server is unreachable. /// The xunit v2 equivalent of xunit v3's Assert.Skip — see diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SqlTestDatabaseReaperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SqlTestDatabaseReaperTests.cs new file mode 100644 index 00000000..1523cef8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SqlTestDatabaseReaperTests.cs @@ -0,0 +1,109 @@ +using Microsoft.Data.SqlClient; +using Shouldly; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Pins against the real shared SQL Server: an aged orphan is +/// dropped, a fresh sibling is not. +/// +/// +/// A pin, not a red-first test. The reaper is time-gated and the behaviour it protects — "a +/// database created by a run that is long over gets dropped, one created by a run that may still be +/// going does not" — has no observable failing state to start from; what it needs is a guard against +/// someone later widening the age window, the LIKE, or the timestamp parse. It therefore drives +/// the internal core with an explicit cutoff rather than waiting 24 hours. +/// +public sealed class SqlTestDatabaseReaperTests +{ + [SqlServerFact] + public void Reap_drops_databases_older_than_the_cutoff_and_leaves_fresh_ones_alone() + { + var master = AdminUiSqlServer.ConnectionStringFor("master"); + + // A name that carries a timestamp from 2025 — unambiguously older than any cutoff this suite uses. + var aged = $"{AdminUiSqlServer.TestDatabasePrefix}20250101000000_{Guid.NewGuid():N}"; + var fresh = $"{AdminUiSqlServer.TestDatabasePrefix}{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; + + CreateDatabase(master, aged); + + try + { + CreateDatabase(master, fresh); + + try + { + var reaped = SqlTestDatabaseReaper.ReapOrphans( + master, + AdminUiSqlServer.TestDatabasePrefix, + DateTime.UtcNow - TimeSpan.FromHours(24)); + + reaped.ShouldContain(aged); + DatabaseExists(master, aged).ShouldBeFalse("the aged orphan should have been reaped"); + + reaped.ShouldNotContain(fresh); + DatabaseExists(master, fresh).ShouldBeTrue("a database from a possibly-live run must survive"); + } + finally + { + SqlTestDatabaseReaper.TryDropDatabase(master, fresh); + } + } + finally + { + // No-op when the reaper already took it; belt-and-braces if an assertion failed first. + SqlTestDatabaseReaper.TryDropDatabase(master, aged); + } + } + + [SqlServerFact] + public void Reap_leaves_a_matching_name_whose_timestamp_does_not_parse_alone() + { + var master = AdminUiSqlServer.ConnectionStringFor("master"); + var unparseable = $"{AdminUiSqlServer.TestDatabasePrefix}notatimestamp_{Guid.NewGuid():N}"; + + CreateDatabase(master, unparseable); + + try + { + // The cutoff stays at the production 24 hours — deliberately NOT a far-future one, which would + // sweep away the live database of an AdminUI fixture running in parallel on this shared server. + // It still bites: an unparseable name that fell through to default(DateTime) would read as year + // 0001 and be dropped. + var reaped = SqlTestDatabaseReaper.ReapOrphans( + master, + AdminUiSqlServer.TestDatabasePrefix, + DateTime.UtcNow - TimeSpan.FromHours(24)); + + reaped.ShouldNotContain(unparseable); + DatabaseExists(master, unparseable) + .ShouldBeTrue("a name this code could not have minted is never dropped"); + } + finally + { + SqlTestDatabaseReaper.TryDropDatabase(master, unparseable); + } + } + + private static void CreateDatabase(string masterConnectionString, string databaseName) + { + using var conn = new SqlConnection(masterConnectionString); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE DATABASE [{databaseName}];"; + cmd.ExecuteNonQuery(); + } + + private static bool DatabaseExists(string masterConnectionString, string databaseName) + { + using var conn = new SqlConnection(masterConnectionString); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT DB_ID(@name);"; + cmd.Parameters.Add(new SqlParameter("@name", databaseName)); + + return cmd.ExecuteScalar() is not (null or DBNull); + } +}