using System.Globalization; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; /// /// A real SQL Server database for the handful of AdminUI tests whose subject is the SQL Server /// provider's LINQ translation rather than the C# around it — EF InMemory evaluates those /// queries in-process, so it can neither fail on an untranslatable shape nor reproduce SQL Server's /// collation semantics. Creates a uniquely-named database, applies the EF migrations to it, and drops /// it at collection teardown. /// /// /// /// Shaped after ZB.MOM.WW.OtOpcUa.Configuration.Tests.SchemaComplianceFixture (same env /// vars, same unique-name + Migrate() + drop-via-master lifecycle) with one deliberate /// deviation: that fixture lets the connection failure surface as a constructor throw, so /// Configuration.Tests fails hard when SQL Server is unreachable. AdminUI.Tests is an ordinary /// unit suite that must stay green offline, so this fixture probes reachability FIRST and records /// a instead of throwing. Tests carry , /// which skips them when the probe failed. /// /// /// The skip is an attribute rather than the Assert.Skip(fixture.SkipReason) call the S7 /// integration suite uses, because AdminUI.Tests is one of the three projects pinned to /// xunit v2 by Akka.TestKit.Xunit2 (see Directory.Packages.props) and /// Assert.Skip is xunit v3 only. Overriding FactAttribute.Skip is the v2 equivalent /// and reports a genuine "skipped", not a vacuous pass. Both the attribute and this fixture read /// the same one-shot probe in , so a session probes once. /// /// /// The probe being one-shot bounds what "stays green offline" guarantees: it covers SQL /// being unreachable before the run. A connectivity blip during a run is never /// re-probed, so the affected tests surface as SqlException failures, not skips. /// /// /// Every test in the collection shares ONE database (migrating is the expensive part), so tests /// must isolate themselves by seeding uniquely-named rows, or by clearing the table they measure. /// xunit serialises a collection's tests, so a clear-then-seed is safe. /// /// public sealed class AdminUiSqlServerFixture : IDisposable { /// Gets the name of the per-run test database, or when SQL is unreachable. public string? DatabaseName { get; } /// Gets the connection string for the test database, or when SQL is unreachable. public string? ConnectionString { get; } /// /// Gets the reason the SQL-backed tests cannot run (server unreachable, migration failed), or /// when the database is ready. /// public string? SkipReason { get; } /// /// Gets a context factory pointed at the migrated test database — the seam the services under test /// (DeploymentDriftProbe, RawTreeService) take. Throws when /// is set; guard with . /// public IDbContextFactory Factory => _factory ?? throw new InvalidOperationException(SkipReason ?? "SQL Server fixture is not available."); private readonly SqlServerContextFactory? _factory; /// Initializes a new instance of the class. public AdminUiSqlServerFixture() { // Probe FIRST — see the remarks. A dead server must leave this constructor without throwing, // because xunit builds collection fixtures before it discovers that every test is skipped. 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(); // Mint from the same constant the reaper's LIKE anchors on — a rename that touched only one of the // two would leave every future database unreapable, with nothing to signal it. DatabaseName = $"{AdminUiSqlServer.TestDatabasePrefix}{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; ConnectionString = AdminUiSqlServer.ConnectionStringFor(DatabaseName); try { var options = new DbContextOptionsBuilder() .UseSqlServer(ConnectionString) .Options; using var ctx = new OtOpcUaConfigDbContext(options); ctx.Database.Migrate(); } catch (Exception ex) { // A half-created database would leak, so tear it down before standing the suite down. TryDropDatabase(); SkipReason = $"Could not migrate the AdminUI SQL test database: {ex.Message}"; DatabaseName = null; ConnectionString = null; return; } _factory = new SqlServerContextFactory(ConnectionString); } /// Disposes the fixture, dropping the test database. public void Dispose() => TryDropDatabase(); private void TryDropDatabase() { if (ConnectionString is null || DatabaseName is null) return; var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString; SqlTestDatabaseReaper.TryDropDatabase(master, DatabaseName); } private sealed class SqlServerContextFactory(string connectionString) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new(new DbContextOptionsBuilder() .UseSqlServer(connectionString) .Options); public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext()); } } /// /// One-shot reachability probe + connection-string builder shared by /// and . The attribute is /// constructed at discovery time and the fixture at collection start, so the probe must be cached or /// a run pays the connect timeout twice. /// 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. public static string ConnectionStringFor(string database) { var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; return $"Server={server};Database={database};User Id=sa;Password={password};" + "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; try { var probe = new SqlConnectionStringBuilder(ConnectionStringFor("master")) { ConnectTimeout = ProbeTimeoutSeconds, }.ConnectionString; using var conn = new SqlConnection(probe); conn.Open(); return null; } catch (Exception ex) { return $"SQL Server at '{server}' is unreachable ({ex.GetType().Name}: {ex.Message}); " + "set OTOPCUA_CONFIG_TEST_SERVER / OTOPCUA_CONFIG_TEST_SA_PASSWORD to point at a reachable instance."; } } } /// /// 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 matching name is dropped only when its whole shape is one this code could have /// minted — prefix, a parseable 14-digit UTC timestamp, _, then 32 hex digits. That is /// belt and braces against second-order injection: the name is also passed as a /// parameter through QUOTENAME rather than interpolated into DDL, but the shape /// check independently guarantees a candidate cannot contain ] or '; /// /// /// 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"; private const int ConnectTimeoutSeconds = 3; /// /// 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 every failure — this is called unwrapped from /// Dispose and from test finally blocks, so it must be genuinely best-effort. /// /// /// The database name is an identifier, which cannot be parameterised directly, so it rides /// in as a parameter and QUOTENAME escapes it server-side. Interpolating it into the DDL /// instead would make any attacker-chosen database name on this shared server a second-order /// injection executed as sa. /// /// 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(); // The QUOTENAME result must be assembled into a variable first: EXEC() accepts only a // concatenation of literals and variables, so the tempting one-liner // EXEC('ALTER DATABASE ' + QUOTENAME(@name) + …) is a syntax error, not a subtle bug. cmd.CommandText = @" DECLARE @sql nvarchar(max); IF DB_ID(@name) IS NOT NULL BEGIN SET @sql = N'ALTER DATABASE ' + QUOTENAME(@name) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE; ' + N'DROP DATABASE ' + QUOTENAME(@name) + N';'; EXEC sp_executesql @sql; END"; cmd.Parameters.Add(new SqlParameter("@name", databaseName)); cmd.ExecuteNonQuery(); return true; } catch (Exception) { // 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(); // A short connect timeout, because this runs BEFORE whatever the fixture does next: on an // unreachable server the default ~15 s would simply be added to the latency the caller already pays. var probe = new SqlConnectionStringBuilder(masterConnectionString) { ConnectTimeout = ConnectTimeoutSeconds, }.ConnectionString; using var conn = new SqlConnection(probe); 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>_<32 hex digits>" — anything else is not a name this code // minted. Validating the GUID tail is what bounds the character set of every candidate name: a // "N"-format GUID is hex only, so no candidate can carry a ']' or a quote into the drop statement. if (rest.Length < TimestampFormat.Length + 1 || rest[TimestampFormat.Length] != '_') return false; if (!Guid.TryParseExact(rest[(TimestampFormat.Length + 1)..], "N", out _)) 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 /// 's remarks for why this project cannot use the latter. /// [AttributeUsage(AttributeTargets.Method)] public sealed class SqlServerFactAttribute : FactAttribute { /// Initializes a new instance of the class. public SqlServerFactAttribute() { if (AdminUiSqlServer.SkipReason is { } reason) Skip = reason; } } /// Collection definition binding the SQL-backed AdminUI tests to one shared database. [CollectionDefinition(nameof(AdminUiSqlServerCollection))] public sealed class AdminUiSqlServerCollection : ICollectionFixture { }