using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// WP1.3 — CDC capture is installed only on a node that actually replicates. /// /// /// /// These assert on the TRIGGERS in sqlite_master rather than on /// ILocalDb.ReplicatedTables, because the trigger set is what costs anything: the /// registry entry is a dictionary lookup, while each trigger runs two extra INSERTs plus a /// json_object serialization of the full row inside every write transaction on the /// table. An unreplicated node paid that on every store-and-forward enqueue, every static /// override, every event log row, forever, for an oplog with no reader. /// /// /// The naming __localdb_{table}_{ai|au|ad} is the library's /// (TriggerSqlGenerator.TriggerName), matched by prefix here so a fourth trigger kind /// would be caught rather than quietly ignored. /// /// public class SiteLocalDbCdcRegistrationTests : IDisposable { private readonly string _root; private readonly List _providers = []; public SiteLocalDbCdcRegistrationTests() { _root = Path.Combine(Path.GetTempPath(), $"localdb-cdc-{Guid.NewGuid():N}"); Directory.CreateDirectory(_root); } public void Dispose() { foreach (var provider in _providers) { try { provider.Dispose(); } catch { /* best effort */ } } Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } GC.SuppressFinalize(this); } [Fact] public void UnreplicatedNode_InstallsNoCaptureTriggers() { // No LocalDb:Replication section at all — site-b and site-c on the rig. var db = BuildDatabase(Config()); Assert.Empty(CaptureTriggers(db)); } [Fact] public void UnreplicatedNode_StillCreatesEveryTable() { // Skipping registration must not skip the DDL: the node is fully functional // standalone, it just has nobody to ship its changes to. A guard placed around the // schema block instead of the registration block would fail here and nowhere else. var db = BuildDatabase(Config()); var tables = TableNames(db); foreach (var table in AllSiteTables) Assert.Contains(table, tables); } [Fact] public void InitiatorNode_InstallsCaptureTriggers() { // PeerAddress + ApiKey — site-a node-a, the half that dials. var db = BuildDatabase(Config( peerAddress: "http://peer:8083", apiKey: "cdc-test-key")); AssertCaptureTriggersCoverTheReplicatedTables(db); } [Fact] public void PassiveNode_WithApiKeyButNoPeerAddress_StillInstallsCaptureTriggers() { // The reason the predicate is an OR. Site-a node-b sets ApiKey and nothing else: // one bidirectional stream, dialled by one side. Keying on PeerAddress alone would // leave this node capturing nothing, so its own writes would never reach the // initiator and the pair would converge in one direction only — silently. var db = BuildDatabase(Config(apiKey: "cdc-test-key")); AssertCaptureTriggersCoverTheReplicatedTables(db); } [Fact] public void ReplicatedNode_InstallsNoCaptureTriggersOnTheCentralOnlyNotificationTables() { // The security property, restated at the trigger level: notification_lists and // smtp_configurations exist but must never be captured, because the only payload // they ever historically held was plaintext SMTP passwords. var db = BuildDatabase(Config(apiKey: "cdc-test-key")); var triggers = CaptureTriggers(db); Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_notification_lists_", StringComparison.Ordinal)); Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_smtp_configurations_", StringComparison.Ordinal)); } [Fact] public void UnreplicatedNode_StillRunsTheLegacyMigrator() { // The migrator is deliberately outside the guard. It renames the legacy file to // ".migrated" on success, which is the observable proof it ran without needing a // capture trigger to look at. var legacyPath = Path.Combine(_root, "legacy-store-and-forward.db"); SeedLegacyStoreAndForward(legacyPath); _ = BuildDatabase(Config(storeAndForwardPath: legacyPath)); Assert.False(File.Exists(legacyPath)); Assert.True(File.Exists(legacyPath + ".migrated")); } // ---- helpers ---------------------------------------------------------------------- /// The ten replicated tables plus the two deliberately-unregistered ones. private static readonly string[] AllSiteTables = [ "OperationTracking", "site_events", "sf_messages", "deployed_configurations", "static_attribute_overrides", "shared_scripts", "external_systems", "database_connections", "data_connection_definitions", "native_alarm_state", "notification_lists", "smtp_configurations", ]; private static readonly string[] ReplicatedTables = [ "OperationTracking", "site_events", "sf_messages", "deployed_configurations", "static_attribute_overrides", "shared_scripts", "external_systems", "database_connections", "data_connection_definitions", "native_alarm_state", ]; private static void AssertCaptureTriggersCoverTheReplicatedTables(ILocalDb db) { var triggers = CaptureTriggers(db); foreach (var table in ReplicatedTables) { // All three kinds, so a partial install is a failure rather than a pass. foreach (var suffix in new[] { "ai", "au", "ad" }) Assert.Contains($"__localdb_{table}_{suffix}", triggers); } } private ILocalDb BuildDatabase(IConfiguration config) { var provider = new ServiceCollection() .AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config)) .BuildServiceProvider(); _providers.Add(provider); return provider.GetRequiredService(); } private IConfiguration Config( string? peerAddress = null, string? apiKey = null, string? storeAndForwardPath = null) { var values = new Dictionary { ["LocalDb:Path"] = Path.Combine(_root, "consolidated.db"), ["ScadaBridge:Node:NodeName"] = "node-a", // Every legacy path is pinned inside this test's own directory. Left unset they // resolve CWD-relative (./data/…), and the migrator RENAMES whatever it finds — // so an unlucky run could eat a real file from the test binary's output folder. ["ScadaBridge:StoreAndForward:SqliteDbPath"] = storeAndForwardPath ?? Path.Combine(_root, "absent-store-and-forward.db"), ["ScadaBridge:Database:SiteDbPath"] = Path.Combine(_root, "absent-scadabridge.db"), ["ScadaBridge:SiteEventLog:DatabasePath"] = Path.Combine(_root, "absent-events.db"), ["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={Path.Combine(_root, "absent-tracking.db")}", }; if (peerAddress is not null) values["LocalDb:Replication:PeerAddress"] = peerAddress; if (apiKey is not null) values["LocalDb:Replication:ApiKey"] = apiKey; return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); } private static void SeedLegacyStoreAndForward(string path) { using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={path}"); connection.Open(); ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardSchema.Apply(connection); } /// /// Every LocalDb capture trigger in the file. Matched on the library's /// __localdb_ prefix in C# rather than with SQL LIKE, where the underscores /// are single-character wildcards and the pattern would need escaping to mean itself. /// private static HashSet CaptureTriggers(ILocalDb db) { using var connection = db.CreateConnection(); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'trigger'"; using var reader = cmd.ExecuteReader(); var names = new HashSet(StringComparer.Ordinal); while (reader.Read()) { var name = reader.GetString(0); if (name.StartsWith("__localdb_", StringComparison.Ordinal)) names.Add(name); } return names; } private static HashSet TableNames(ILocalDb db) { using var connection = db.CreateConnection(); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'"; using var reader = cmd.ExecuteReader(); var names = new HashSet(StringComparer.Ordinal); while (reader.Read()) names.Add(reader.GetString(0)); return names; } }