0289d51137
A DeployedSecretsMirror failure on the STANDBY node (e.g. a master-key mismatch
that stops a deployed secret decrypting there) was only logged on that node:
site health reports are sent by the active node only, so central never saw it.
- New 12th LocalDb-replicated table secret_mirror_status (node_name PK,
failures_json of {SecretName, Message}, checked_at; no FKs). Each node's
mirror writes only its own row, and only when its failure set changes plus
once per start-up (every write replicates via CDC). Best-effort: a failed
write is a Warning and is retried next pass. Node key = INodeIdentityProvider
NodeName, falling back to the machine name.
- SecretMirrorStatusReporter (SiteRuntime, every node, 30 s) reads every row
into ISiteHealthCollector.SetSecretMirrorStatuses -> additive nullable
SiteHealthReport.SecretMirrorStatuses (null = no data).
- Wire: SiteHealthReportDto.secret_mirror_statuses = 33 (presence wrapper so
null != empty); vendored CentralControl.cs regenerated; mapper both ways.
- Central UI Health card: 'Secret mirror failing' badge + compact per-node
node / secret / message list.
- Tests: mirror status write-on-change/first-pass/failure isolation, storage
round-trip, reporter/collector, DTO mapper round-trip incl. null/empty,
bUnit, replicated-table set (twelve) + FK guard wording.
364 lines
17 KiB
C#
364 lines
17 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, configured exactly like the PASSIVE half of
|
|
// the rig's replicated pair (site-a node-b): an ApiKey and no PeerAddress.
|
|
//
|
|
// The key is what makes this a replicating node, and therefore what makes the
|
|
// CDC registration assertions below apply at all — capture is installed only
|
|
// when replication is configured. The absent PeerAddress keeps this fixture the
|
|
// default-OFF pin at the same time: nothing dials, so the engine must still
|
|
// resolve and idle rather than throw or report a connection.
|
|
["LocalDb:Path"] = _tempDbPath,
|
|
["LocalDb:Replication:ApiKey"] = "wiring-test-localdb-sync-key",
|
|
});
|
|
|
|
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.
|
|
//
|
|
// This fixture IS a replicating node: it carries LocalDb:Replication:ApiKey (the
|
|
// passive half of the rig's site-a pair), and configured-ness is PeerAddress OR
|
|
// ApiKey. So false/0 is the correct, meaningful answer here — "replication is on,
|
|
// no session is up yet, and the oplog really is empty". Register row 80 is about
|
|
// the OTHER case, which is pinned by UnreplicatedSiteLocalDbHealthTests: a node
|
|
// with NEITHER key must report null/null, because the engine's inert false/0 there
|
|
// means "not applicable", not "disconnected".
|
|
//
|
|
// Do not weaken this assertion to null without checking which fixture you are in —
|
|
// conflating "no PeerAddress" with "replication unconfigured" is precisely the
|
|
// mistake that produced row 80.
|
|
var reporter = _host.Services.GetServices<IHostedService>()
|
|
.OfType<LocalDbReplicationStatusReporter>()
|
|
.Single();
|
|
var collector = _host.Services.GetRequiredService<ISiteHealthCollector>();
|
|
|
|
reporter.Probe();
|
|
var report = collector.CollectReport("TestSite");
|
|
|
|
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 plus the site-secrets tables.
|
|
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",
|
|
"deployed_secrets", "mirrored_secrets", "secret_mirror_status",
|
|
})
|
|
{
|
|
Assert.Contains(table, tables);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_RegistersExactlyTheTwelveReplicatedTables()
|
|
{
|
|
// The Phase 2 cutover, extended by site-secrets artifact deploy's deployed_secrets
|
|
// and review finding M1's per-node secret_mirror_status.
|
|
// 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, smtp_configurations, and mirrored_secrets must NOT
|
|
// be here. notification_lists/smtp_configurations are permanently empty by design
|
|
// (their only historical payload was a plaintext SMTP password); mirrored_secrets is
|
|
// node-local fact (which names THIS node has mirrored), not site-wide state. A
|
|
// Contains-based test would never catch either.
|
|
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", "deployed_secrets", "external_systems",
|
|
"native_alarm_state", "secret_mirror_status", "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_DoesNotReplicateTheNodeLocalMirroredSecretsLedger()
|
|
{
|
|
// mirrored_secrets exists (SiteStorageSchema creates it) but must never be captured:
|
|
// it records which names THIS node has mirrored into its own secret store, which is
|
|
// per-node fact, not site-wide state — see SiteLocalDbSetup.ReplicatedTables.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.DoesNotContain("mirrored_secrets", 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);
|
|
Assert.Equal(["name"], db.ReplicatedTables["deployed_secrets"].PkColumns);
|
|
Assert.Equal(["node_name"], db.ReplicatedTables["secret_mirror_status"].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}'.");
|
|
}
|
|
}
|