using Microsoft.Data.SqlClient; using Shouldly; using Xunit; 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. /// /// /// Joined to deliberately, though it never touches the /// fixture: xunit builds a collection fixture before running the collection's tests and serialises them /// within it, which is what keeps 's own startup reaper from /// racing this one. Left in its own (parallel) collection, that reaper can drop the aged database this /// test plants, between the plant and the assertion — observed as an intermittent failure. /// [Collection(nameof(AdminUiSqlServerCollection))] 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); } } [SqlServerFact] public void Reap_leaves_an_aged_name_whose_suffix_is_not_a_guid_alone() { var master = AdminUiSqlServer.ConnectionStringFor("master"); // Aged enough to be reaped on the timestamp alone — only the suffix check saves it. That check is // what bounds the character set of every candidate: without it a database named // "_" + arbitrary text would be fed to the drop statement, and before the // statement was parameterised that was a second-order injection running as sa. var notAGuid = $"{AdminUiSqlServer.TestDatabasePrefix}20250101000000_notaguid"; CreateDatabase(master, notAGuid); try { var reaped = SqlTestDatabaseReaper.ReapOrphans( master, AdminUiSqlServer.TestDatabasePrefix, DateTime.UtcNow - TimeSpan.FromHours(24)); reaped.ShouldNotContain(notAGuid); DatabaseExists(master, notAGuid) .ShouldBeTrue("only names shaped exactly as this code mints them are ever dropped"); } finally { SqlTestDatabaseReaper.TryDropDatabase(master, notAGuid); } } 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); } }