037798b367
Tasks 14, 15 and 16, landed as ONE commit. PLAN DEFECT: these three tasks cannot compile separately. SiteReplicationActor takes a ReplicationService and calls ReplaceAllAsync (Task 14 deletes both); DeploymentManagerActor Tells message types declared in ReplicationMessages.cs (Task 15 deletes it); AkkaHostedService constructs the actor (Task 16). Any ordering leaves a broken intermediate. Combining them also strengthens the invariant Task 14 already stated for itself — the two mechanisms never both run, and never neither. Registered 8 tables in SiteLocalDbSetup.OnReady: sf_messages plus the 7 site config tables. notification_lists and smtp_configurations are deliberately NOT registered — permanently empty by design, so registering them would open a standing replication channel whose only historical payload was plaintext SMTP passwords. Migrate stays the LAST call in OnReady, after all registrations, so migrated rows enter the oplog through live capture triggers. Deleted: SiteReplicationActor, ReplicationMessages.cs, ReplicationService, StoreAndForwardStorage.ReplaceAllAsync, and 6 test files. ReplaceAllAsync is not merely unused but unsafe to keep: a mass DELETE on a now-replicated table would be captured and shipped to the peer. Kept ActiveNodeEvaluator (delivery gate + heartbeat still need it) with its doc corrected, and activeNodeCheck in AkkaHostedService (SiteCommunicationActor). The positional-argument hazard the plan flagged was real: removing DeploymentManagerActor's optional IActorRef? replicationActor shifted 6 trailing optionals, and 4 test call sites bound the wrong arguments with no compile error at some positions. Converted them to named arguments where possible — Props.Create builds an expression tree, which rejects out-of-position named args, so the rest are padded positionally with a comment saying why. The Task 7 'not yet registered' test was INVERTED rather than deleted, and is exact in both directions: too few means a table silently stops replicating, too many means the SMTP tables leak. Added a separate security-named test for those two, and a composite-PK test (LWW keys on the full PK, so a truncated key set would collapse distinct rows). The convergence suites now get their registrations from the real OnReady — their temporary harness registration is deleted, so they prove the cutover rather than agreeing with themselves. Verified: build 0 warnings; SiteRuntime 512, StoreAndForward 130, Host 330, AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97, LocalDb integration 16 — all pass, 0 failures. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
328 lines
14 KiB
C#
328 lines
14 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
using ZB.MOM.WW.Telemetry;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.LocalDb.Replication;
|
|
using ZB.MOM.WW.ScadaBridge.Host;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site
|
|
/// composition root.
|
|
/// <para>
|
|
/// These tests build the actual container from
|
|
/// <see cref="SiteServiceRegistration.Configure"/> rather than asserting on a
|
|
/// hand-assembled <c>ServiceCollection</c>. That is deliberate and load-bearing: this
|
|
/// family has now shipped three wiring defects that only a container-built graph would
|
|
/// have caught — the Secrets Akka replicator that registered into a no-op sink (0.2.0),
|
|
/// the singleton cycle that deadlocked a real host (0.2.2), and Secrets.Ui's missing
|
|
/// <c>IAuditWriter</c> that 500'd only behind a login wall (ScadaBridge#22). A test that
|
|
/// merely checks "AddZbLocalDb was called" would have passed in every one of those cases.
|
|
/// </para>
|
|
/// </summary>
|
|
public class SiteLocalDbWiringTests : IDisposable
|
|
{
|
|
private readonly WebApplication _host;
|
|
private readonly string _tempDbPath;
|
|
private readonly string _tempSiteDbPath;
|
|
private readonly string _tempTrackingDbPath;
|
|
|
|
public SiteLocalDbWiringTests()
|
|
{
|
|
var stamp = Guid.NewGuid();
|
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{stamp}.db");
|
|
_tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_site_{stamp}.db");
|
|
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{stamp}.db");
|
|
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Configuration.Sources.Clear();
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Node:Role"] = "Site",
|
|
["ScadaBridge:Node:NodeName"] = "node-a",
|
|
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
|
["ScadaBridge:Node:SiteId"] = "TestSite",
|
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
["ScadaBridge:Node:GrpcPort"] = "0",
|
|
["ScadaBridge:Database:SiteDbPath"] = _tempSiteDbPath,
|
|
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
|
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
|
|
// The consolidated site database. No Replication section at all — this
|
|
// fixture is also the default-OFF pin: storage must work standalone.
|
|
["LocalDb:Path"] = _tempDbPath,
|
|
});
|
|
|
|
builder.Services.AddGrpc();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
|
|
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
|
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
|
|
|
_host = builder.Build();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
(_host as IDisposable)?.Dispose();
|
|
foreach (var path in new[] { _tempDbPath, _tempSiteDbPath, _tempTrackingDbPath })
|
|
{
|
|
try { File.Delete(path); } catch { /* best effort */ }
|
|
}
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_ILocalDb()
|
|
{
|
|
Assert.NotNull(_host.Services.GetService<ILocalDb>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_RegistersBothReplicatedTables()
|
|
{
|
|
// The whole point of Phase 1. If onReady silently failed to register these,
|
|
// storage would still work and every other test would pass — the pair would
|
|
// just never replicate anything. Assert on the library's own view of what is
|
|
// registered, not on our belief that we called it.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Contains("OperationTracking", db.ReplicatedTables.Keys);
|
|
Assert.Contains("site_events", db.ReplicatedTables.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_ReplicatedTablesHaveExplicitPrimaryKeys()
|
|
{
|
|
// RegisterReplicated refuses a table with no explicit PK, so reaching this
|
|
// assertion at all proves the DDL ran before registration. The PK names are
|
|
// pinned because they are what last-writer-wins conflict resolution keys on.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Equal(
|
|
["TrackedOperationId"], db.ReplicatedTables["OperationTracking"].PkColumns);
|
|
Assert.Equal(["id"], db.ReplicatedTables["site_events"].PkColumns);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_IsASingleton()
|
|
{
|
|
// AddZbLocalDb is one-DB-per-process and first-registration-wins. Two
|
|
// instances would mean two SQLite handles and two trigger installations
|
|
// against the same file.
|
|
var first = _host.Services.GetRequiredService<ILocalDb>();
|
|
var second = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Same(first, second);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Replication_IsRegistered_AndInertWithNoPeer()
|
|
{
|
|
// Task 7's default-OFF pin. This fixture configures NO
|
|
// LocalDb:Replication:PeerAddress, which is the shipping default, so the
|
|
// engine must resolve cleanly and sit idle rather than throw, retry, or
|
|
// report a connection. "Default-off" here means inert, NOT unregistered —
|
|
// the services are always in the graph.
|
|
var status = _host.Services.GetService<ISyncStatus>();
|
|
|
|
Assert.NotNull(status);
|
|
Assert.False(status!.Connected);
|
|
Assert.Null(status.PeerNodeId);
|
|
Assert.Null(status.LastSyncUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Replication_OplogBacklog_IsZero_NotNull_OnAHealthyIdleNode()
|
|
{
|
|
// OplogBacklog is deliberately nullable: null means "unknown" (no provider
|
|
// wired, or the poll failed) and must never be reported as a healthy 0. On a
|
|
// node whose local database is present and readable, a real 0 proves the
|
|
// backlog provider is actually wired to the oplog — a null here would mean
|
|
// Task 9's health signal is about to publish "unknown" forever.
|
|
var status = _host.Services.GetRequiredService<ISyncStatus>();
|
|
|
|
Assert.Equal(0L, status.OplogBacklog);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Replication_HealthBridge_IsRegisteredAsAHostedService()
|
|
{
|
|
// Task 9. Registered via a factory lambda, so ImplementationType is null and the
|
|
// only honest assertion is on the resolved instance.
|
|
var hosted = _host.Services.GetServices<IHostedService>();
|
|
|
|
Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Replication_HealthBridge_PublishesStatusOntoTheHealthReport()
|
|
{
|
|
// End-to-end through the REAL collector: probe once, then collect. This is what
|
|
// catches a bridge that is registered but wired to the wrong provider — the
|
|
// registration test above would pass either way.
|
|
var reporter = _host.Services.GetServices<IHostedService>()
|
|
.OfType<LocalDbReplicationStatusReporter>()
|
|
.Single();
|
|
var collector = _host.Services.GetRequiredService<ISiteHealthCollector>();
|
|
|
|
reporter.Probe();
|
|
var report = collector.CollectReport("TestSite");
|
|
|
|
// No peer configured: not connected, and a REAL 0 backlog rather than null.
|
|
Assert.False(report.LocalDbReplicationConnected);
|
|
Assert.Equal(0L, report.LocalDbOplogBacklog);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_HealthReport_LocalDbFields_AreNull_BeforeTheReporterHasRun()
|
|
{
|
|
// Null means "no data yet", and must be distinguishable from a real
|
|
// "disconnected, zero backlog". A collector that defaulted these to false/0 would
|
|
// report an unwired node as a healthy connected one.
|
|
var collector = new SiteHealthCollector();
|
|
|
|
var report = collector.CollectReport("TestSite");
|
|
|
|
Assert.Null(report.LocalDbReplicationConnected);
|
|
Assert.Null(report.LocalDbOplogBacklog);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Telemetry_Allowlists_TheLocalDbReplicationMeter()
|
|
{
|
|
// ZbTelemetryOptions.Meters is an ALLOWLIST: an unlisted Meter's instruments are
|
|
// never observed, with no error and no missing-metric warning anywhere. The
|
|
// replication meter was silently absent from the rig's /metrics scrape until the
|
|
// live gate looked for it, so pin the allowlist rather than trusting the next
|
|
// reader to remember.
|
|
//
|
|
// This asserts on the allowlist constant, not on OTel's observed-meter set:
|
|
// AddZbTelemetry applies its options to a local instance and never registers
|
|
// IOptions, so there is nothing resolvable to interrogate. The end-to-end proof
|
|
// that localdb_* actually reaches /metrics is the Task 12 live gate; this test
|
|
// exists to stop the entry being dropped again.
|
|
Assert.Contains(LocalDbMetrics.MeterName, SiteServiceRegistration.ObservedMeters);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_CreatesThePhase2Tables_InTheConsolidatedFile()
|
|
{
|
|
// Phase 2 (Task 7). The nine configuration tables and the store-and-forward buffer
|
|
// now live in the consolidated file rather than in scadabridge.db and
|
|
// store-and-forward.db. Asserting through the REAL composition root is what proves
|
|
// OnReady applies both schemas — the stores themselves also call Apply, so a test
|
|
// that went through a store would pass even if OnReady never touched them.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
var tables = TableNames(db);
|
|
|
|
// One from each Phase 1 schema, then every Phase 2 table.
|
|
Assert.Contains("OperationTracking", tables);
|
|
Assert.Contains("site_events", tables);
|
|
Assert.Contains("sf_messages", tables);
|
|
foreach (var table in new[]
|
|
{
|
|
"deployed_configurations", "static_attribute_overrides", "shared_scripts",
|
|
"external_systems", "database_connections", "notification_lists",
|
|
"data_connection_definitions", "smtp_configurations", "native_alarm_state",
|
|
})
|
|
{
|
|
Assert.Contains(table, tables);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_RegistersExactlyTheTenReplicatedTables()
|
|
{
|
|
// The Phase 2 cutover. This is an EXACTLY check rather than a Contains in both
|
|
// directions, and both directions are load-bearing.
|
|
//
|
|
// Too few: the table silently stops replicating. Storage still works, every other
|
|
// test still passes, and the pair just quietly diverges — the failure mode Phase 1
|
|
// existed to end.
|
|
//
|
|
// Too many: notification_lists and smtp_configurations must NOT be here. They are
|
|
// permanently empty by design, and registering them would open a standing
|
|
// replication channel whose only historical payload was plaintext SMTP passwords.
|
|
// A Contains-based test would never catch that.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Equal(
|
|
[
|
|
// Ordinal order: '_' (0x5F) sorts before 'b' (0x62), so
|
|
// data_connection_definitions precedes database_connections.
|
|
"OperationTracking", "data_connection_definitions", "database_connections",
|
|
"deployed_configurations", "external_systems", "native_alarm_state",
|
|
"sf_messages", "shared_scripts", "site_events", "static_attribute_overrides",
|
|
],
|
|
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_DoesNotReplicateTheCentralOnlyNotificationTables()
|
|
{
|
|
// Stated separately from the exact-set test above because this one is a security
|
|
// property, not a wiring property, and deserves to fail with its own name. The
|
|
// tables exist (SiteStorageSchema creates them) — they simply must never be
|
|
// captured. See SiteLocalDbSetup.OnReady for the rationale.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.DoesNotContain("notification_lists", db.ReplicatedTables.Keys);
|
|
Assert.DoesNotContain("smtp_configurations", db.ReplicatedTables.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_ReplicatedPhase2TablesHaveTheirExpectedPrimaryKeys()
|
|
{
|
|
// RegisterReplicated refuses a table with no explicit PK, so reaching these
|
|
// assertions proves the DDL ran before registration. The composite keys are the
|
|
// interesting ones: LWW conflict resolution keys on the FULL PK, so a wrong or
|
|
// truncated key set would silently collapse distinct rows into one.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Equal(["id"], db.ReplicatedTables["sf_messages"].PkColumns);
|
|
Assert.Equal(
|
|
["instance_unique_name"], db.ReplicatedTables["deployed_configurations"].PkColumns);
|
|
Assert.Equal(
|
|
["instance_unique_name", "attribute_name"],
|
|
db.ReplicatedTables["static_attribute_overrides"].PkColumns);
|
|
Assert.Equal(
|
|
["instance_unique_name", "source_canonical_name", "source_reference"],
|
|
db.ReplicatedTables["native_alarm_state"].PkColumns);
|
|
}
|
|
|
|
private static HashSet<string> 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<string>(StringComparer.Ordinal);
|
|
while (reader.Read()) names.Add(reader.GetString(0));
|
|
return names;
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_CreatesTheConfiguredFile()
|
|
{
|
|
// Resolving is what triggers onReady; the file should exist afterwards at the
|
|
// configured path and not somewhere CWD-relative.
|
|
_ = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.True(File.Exists(_tempDbPath),
|
|
$"Expected the consolidated site database at '{_tempDbPath}'.");
|
|
}
|
|
}
|