Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SqlTestDatabaseReaperTests.cs
T

149 lines
6.0 KiB
C#

using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Pins <see cref="SqlTestDatabaseReaper"/> against the real shared SQL Server: an aged orphan is
/// dropped, a fresh sibling is not.
/// </summary>
/// <remarks>
/// A <b>pin, not a red-first test</b>. 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 <c>LIKE</c>, or the timestamp parse. It therefore drives
/// the internal core with an explicit cutoff rather than waiting 24 hours.
/// </remarks>
/// <remarks>
/// Joined to <see cref="AdminUiSqlServerCollection"/> 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 <see cref="AdminUiSqlServerFixture"/>'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.
/// </remarks>
[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
// "<prefix><aged timestamp>_" + 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);
}
}