149 lines
6.0 KiB
C#
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);
|
|
}
|
|
}
|