147 lines
6.3 KiB
C#
147 lines
6.3 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.LocalDb;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// Deferred-work register row 105 (arch-review 2026-08-19 P4-7 → LocalDb 0.3.3): a replicated
|
|
/// table must never be the parent of a foreign key with a row-mutating <c>ON DELETE</c> action
|
|
/// (<c>CASCADE</c> / <c>SET NULL</c> / <c>SET DEFAULT</c>). LocalDb's LWW applier upserts with
|
|
/// <c>INSERT OR REPLACE</c> under <c>foreign_keys=ON</c>, so the implicit delete would fire the
|
|
/// child action while change-capture is suppressed — the child rows change on the applying node
|
|
/// only and the pair diverges silently. LocalDb 0.3.3 refuses such a parent at
|
|
/// <c>RegisterReplicated</c> time, which on a site node is a BOOT failure of the replicating
|
|
/// pair. This test moves that discovery from the first replicated boot to the build: it walks
|
|
/// the real site schema (every table <c>SiteLocalDbSetup.OnReady</c> creates) and asserts none
|
|
/// of the eleven <see cref="SiteLocalDbSetup.ReplicatedTables"/> (incl. <c>deployed_secrets</c>,
|
|
/// added for site-secrets artifact deploy) has such an inbound FK.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The schema is built with an UNREPLICATED config on purpose: a replicated one would make
|
|
/// 0.3.3's own guard throw first and mask which table/FK is at fault. The checker query mirrors
|
|
/// LocalDb's (<c>sqlite_master JOIN pragma_foreign_key_list</c>), and a negative control proves
|
|
/// it actually finds an offender.
|
|
/// </remarks>
|
|
public class SiteLocalDbReplicatedTableFkGuardTests
|
|
{
|
|
[Fact]
|
|
public void NoReplicatedTable_IsAParentOfARowMutatingOnDeleteForeignKey()
|
|
{
|
|
var path = TempDbPath();
|
|
try
|
|
{
|
|
var config = ConfigFor(path);
|
|
var services = new ServiceCollection();
|
|
services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config));
|
|
using (var provider = services.BuildServiceProvider())
|
|
using (var scope = provider.CreateScope())
|
|
{
|
|
// Resolving ILocalDb runs SqliteLocalDb's constructor and then OnReady — i.e.
|
|
// creates every table the consolidated site file will ever hold.
|
|
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ILocalDb>());
|
|
}
|
|
|
|
var tables = TableNames(path);
|
|
foreach (var table in SiteLocalDbSetup.ReplicatedTables)
|
|
{
|
|
Assert.Contains(table, tables); // the list and the schema must not drift apart
|
|
|
|
var offenders = FindRowMutatingFkChildren(path, table);
|
|
Assert.True(offenders.Count == 0,
|
|
$"Replicated table '{table}' is the parent of a row-mutating ON DELETE foreign key " +
|
|
$"({string.Join(", ", offenders)}). LocalDb 0.3.3 refuses to register it, so a " +
|
|
"replicating site pair would fail to boot — model cross-table cleanup as explicit " +
|
|
"application deletes instead (register row 105).");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
Cleanup(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NegativeControl_CheckerFindsACascadingChild()
|
|
{
|
|
// The checker must be able to fail, or the test above proves nothing.
|
|
var path = TempDbPath();
|
|
try
|
|
{
|
|
using (var conn = Open(path))
|
|
using (var cmd = conn.CreateCommand())
|
|
{
|
|
cmd.CommandText =
|
|
"CREATE TABLE parent_t (id TEXT PRIMARY KEY);" +
|
|
"CREATE TABLE child_cascade (id INTEGER PRIMARY KEY, parent_id TEXT REFERENCES parent_t(id) ON DELETE CASCADE);" +
|
|
"CREATE TABLE child_restrict (id INTEGER PRIMARY KEY, parent_id TEXT REFERENCES parent_t(id) ON DELETE RESTRICT);";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
var offenders = FindRowMutatingFkChildren(path, "parent_t");
|
|
|
|
var only = Assert.Single(offenders);
|
|
Assert.Equal("child_cascade.parent_id ON DELETE CASCADE", only);
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
Cleanup(path);
|
|
}
|
|
}
|
|
|
|
/// <summary>Same shape as LocalDb 0.3.3's <c>EnsureNoInboundActionOnDelete</c> query.</summary>
|
|
private static List<string> FindRowMutatingFkChildren(string path, string parentTable)
|
|
{
|
|
using var conn = Open(path);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText =
|
|
"SELECT m.name, fk.\"from\", fk.\"on_delete\" " +
|
|
"FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) fk " +
|
|
"WHERE m.type = 'table' AND fk.\"table\" = $t COLLATE NOCASE " +
|
|
"AND UPPER(fk.\"on_delete\") IN ('CASCADE', 'SET NULL', 'SET DEFAULT')";
|
|
cmd.Parameters.AddWithValue("$t", parentTable);
|
|
using var reader = cmd.ExecuteReader();
|
|
var result = new List<string>();
|
|
while (reader.Read())
|
|
result.Add($"{reader.GetString(0)}.{reader.GetString(1)} ON DELETE {reader.GetString(2)}");
|
|
return result;
|
|
}
|
|
|
|
private static HashSet<string> TableNames(string path)
|
|
{
|
|
using var conn = Open(path);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
|
|
using var reader = cmd.ExecuteReader();
|
|
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
while (reader.Read()) names.Add(reader.GetString(0));
|
|
return names;
|
|
}
|
|
|
|
private static SqliteConnection Open(string path)
|
|
{
|
|
var conn = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
|
|
conn.Open();
|
|
return conn;
|
|
}
|
|
|
|
private static string TempDbPath() =>
|
|
Path.Combine(Path.GetTempPath(), $"scadabridge_fkguard_{Guid.NewGuid():N}.db");
|
|
|
|
private static IConfiguration ConfigFor(string path) =>
|
|
new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = path })
|
|
.Build();
|
|
|
|
private static void Cleanup(string path)
|
|
{
|
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
|
{
|
|
try { File.Delete(path + suffix); } catch (IOException) { }
|
|
}
|
|
}
|
|
}
|