merge(r2): r2-plan08

# Conflicts:
#	src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs
This commit is contained in:
Joseph Doherty
2026-07-13 11:10:15 -04:00
42 changed files with 1094 additions and 79 deletions
+9
View File
@@ -44,5 +44,14 @@ docker-env2/*/data/
# Local credentials / login captures — never commit
*login*.txt
# Ad-hoc scratch/credential captures at the repo root — never commit
# (arch-review 08 round 2 NF1: a live API key sat in an untracked test.txt)
/test.txt
/*credentials*
/*secret*
# Claude tool worktrees (NF7)
.claire/
# Sister-project deployment artifacts (not part of this solution)
/deploy/
+34 -2
View File
@@ -4,13 +4,45 @@ All notable changes to ScadaBridge are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Detailed per-change history lives in `git log`,
`archreview/plans/00-MASTER-TRACKER.md`, and `docs/plans/` — this file records
operator-facing and breaking changes only.
## [Unreleased]
### Changed — Architecture-review hardening initiative (PLAN-01…PLAN-08, 2026-07-08 → 2026-07-10)
Eight architecture reviews produced eight fix plans; 191 of 192 tasks landed. The
initiative added eager startup options-validation across 12+ components, ClusterClient
message-contract lock tests, secured-write pending-TTL, a login throttle, transport
import single-flight, store-and-forward sweep bounding, and a deploy-readiness
handshake; it excised the vestigial site-notification delivery surface and established
a canonical deferred-work register. Authoritative record:
`archreview/plans/00-MASTER-TRACKER.md` plus the per-plan docs.
### Added — M8 Transport
Encrypted bundle export/import extended from central-only config to site/instance-scoped
config (`Site`s, site `DataConnection`s, `Instance`s + `Area` by name), reconciled across
environments by the `BundleNameMap` name-mapping subsystem, with a per-line Myers diff and
the script trust gate enforced at import review. Post-initiative additions (both 2026-07-10):
an aggregated live alarm stream feeding the Alarm Summary page, and hourly KPI rollups
unblocking 30d/90d trend windows.
### Added — M7 Native alarms + operational UX
Read-only native-alarm mirroring (OPC UA Alarms & Conditions + MxAccess Gateway) onto a
unified condition model; an operator Alarm Summary page; OPC UA node browse/search/type-info
and site-local certificate trust; **two-person secured writes, introducing the `Operator`
and `Verifier` roles**; and an SMS (Twilio) notification delivery adapter.
### Changed — BREAKING: canonical role names + audit separation-of-duties collapse (Task 1.7)
Role string VALUES are standardized onto the canonical vocabulary
(`Administrator`/`Designer`/`Deployer`/`Viewer`; `Operator`/`Engineer` are unused
by ScadaBridge). The legacy ScadaBridge role names were renamed and two were
(`Administrator`/`Designer`/`Deployer`/`Viewer`; `Engineer` remains unused by
ScadaBridge; `Operator` has since been adopted by the M7 two-person secured-write
flow, together with the new `Verifier` role — see the M7 entry above). The legacy
ScadaBridge role names were renamed and two were
**collapsed**:
| Legacy role | Canonical role | Notes |
+2
View File
@@ -187,6 +187,8 @@ Run up to **five plan-lane executor sessions concurrently** (one each for PLAN-0
## Deferred / Won't-Fix Registry (initiative-level)
> 2026-07-12 (plan R2-08 T10): the two live items below (SBR oldest-crash gap, wonder-app-vd03 overlay) now have canonical rows in `docs/plans/2026-07-08-deferred-work-register.md` — that register is the single tracking place; the subsections below remain as the historical narrative.
Consolidated in **PLAN-08 Task 11**`docs/plans/2026-07-08-deferred-work-register.md` (triages the 23-item inventory from report 08 plus each plan's coverage-table deferrals: serializer swap, hash-chain/Parquet, aggregated live alarm stream, central cert-trust persistence, runtime script sandbox, clustered-GUID benchmark, TLS profile, Dockerfile HEALTHCHECK, etc.). Nothing from the review is silently dropped: every report finding is either a task, an ownership pointer, or a registered deferral with rationale.
### Follow-up discovered during P0 execution (2026-07-08)
+11 -10
View File
@@ -5,15 +5,8 @@ Single tracked list of consciously-deferred work. Rules: every deferral gets a r
them and are removed from this table when that plan's task lands.
## Fix-now (owned by archreview plans)
| # | Item | Owner |
|---|------|-------|
| 1 | Transport: Area membership doesn't travel in bundles (EntitySerializer.cs:251,518) | PLAN-05 |
| 2 | CLI.Tests not in slnx | PLAN-08 Task 1 |
| 3 | AuditLog reconciliation dials site NodeA only (SiteEnumerator.cs:32,64) | PLAN-04 |
| 4 | Options validation missing on ~12 components | PLAN-08 Tasks 25 |
| 5 | SmsConfiguration.MaxRetryCount stored but not honored | PLAN-06 |
| 6 | Role-check case-sensitivity asymmetry (ManagementActor.cs:1910) | PLAN-07 |
| 23 | Vestigial site-side notification-list surface | PLAN-08 Task 6 |
All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round 2, 2026-07-12) — rows removed per the rule above; see the round-2 report §1 for the per-item evidence.
## Deferred (with rationale + revisit trigger)
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
@@ -48,4 +41,12 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for
| Communication → HealthMonitoring layering (ICentralHealthAggregator consumed by CentralCommunicationActor.cs:351) | Moving the interface + SiteHealthState to Commons ripples across 5 projects for a cosmetic inversion | Next breaking change to ICentralHealthAggregator |
| docs/components reference docs for ScriptAnalysis, KpiHistory, DelmiaNotifier | Reference docs are substantial (StyleGuide-conformant); README claim scoped instead (PLAN-08 Task 10) | Next doc-writing session touching those components |
| Test-coverage backfill: SiteCallAudit.Tests (31 tests/1.6k LOC), DeploymentManager.Tests | No defect identified; coverage partly lives in ManagementService/Host/Integration suites | First regression escaping either component |
| Failover-timing + broader perf envelope (S&F drain rate, per-subscriber backpressure) | Needs PLAN-01 two-node rig; placeholder harness shipped (PLAN-08 Task 8) | PLAN-01 rig landing |
| Failover-timing + broader perf envelope (S&F drain rate, per-subscriber backpressure) | **Trigger fired 2026-07-08** — the "PLAN-01 rig landing" trigger fired when `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs` landed (before the placeholder harness even shipped), and went unnoticed (NF2). **Owner: PLAN-R2-01**`archreview/plans/PLAN-R2-01-*.md` (failover-envelope measurement task) owns wiring `FailoverTimingTests` to the fixture rig or recording the true blocker. | Owned by PLAN-R2-01; this register row tracks the handoff only |
## Deferred — operational risk (from the initiative tracker, folded in 2026-07-12)
Two live items previously tracked ONLY in `archreview/plans/00-MASTER-TRACKER.md`'s registry are folded in here (NF5) so this register is the single tracking place. The tracker's narrative subsections remain as the historical evidence.
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
|---|------|-------------|------------------------|-----------------|
| SBR | **SBR oldest-crash total-outage gap** — 2-node `keep-oldest` downs the partition *without* the oldest, so a hard crash of the ACTIVE (oldest) central node makes the standby self-down (~10s) → total central outage until the crashed node restarts; only a younger-node crash fails over. | `archreview/plans/00-MASTER-TRACKER.md:194` + auto-memory `sbr-keep-oldest-2node-active-crash-gap` | Remedy is a production SBR topology/strategy decision (keep-majority + a 3rd/lighthouse seed node, static-quorum, or an accepted-risk note) — **owner: user decision**, not silently changeable. | Before the next production deployment that adds a central node, or the first real active-node crash. |
| vd03 | **`deploy/wonder-app-vd03/` overlay edits unapplied** — `appsettings.Central.json` needs `AllowSingleNodeCluster: true` + phantom-seed removal + `NodeName: central-a`; `install.ps1` needs `sc.exe failure` recovery actions. The `deploy/wonder-app-vd03/` artifact directory is intentionally untracked (production config out of source control), so the repo cannot ship the fix. | `archreview/plans/00-MASTER-TRACKER.md:198` (PLAN-01 T16/T20/T23) | Needs on-host access; without `NodeName` that deployment's audit rows stamp NULL `SourceNode`**partially mitigated once PLAN-R2-08 Task 7 lands: the host now FAILS AT BOOT with a key-naming error instead of silently NULLing, so applying the overlay becomes mandatory at the next upgrade.** Owner: whoever maintains the host (user). | Next wonder-app-vd03 deployment/upgrade — **the Task 7 validator makes this row unskippable then.** |
@@ -0,0 +1,25 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Validates <see cref="AuditLogPartitionMaintenanceOptions"/> at host startup
/// (arch-review 08 round 2 NF4). A zero tick interval spins the maintenance
/// hosted service; a zero look-ahead leaves <c>pf_AuditLog_Month</c> without a
/// future monthly boundary, risking inserts into the unbounded tail partition.
/// </summary>
public sealed class AuditLogPartitionMaintenanceOptionsValidator
: OptionsValidatorBase<AuditLogPartitionMaintenanceOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AuditLogPartitionMaintenanceOptions options)
{
builder.RequireThat(options.IntervalSeconds > 0,
$"AuditLog:PartitionMaintenance:{nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds)} " +
$"({options.IntervalSeconds}) must be > 0.");
builder.RequireThat(options.LookaheadMonths > 0,
$"AuditLog:PartitionMaintenance:{nameof(AuditLogPartitionMaintenanceOptions.LookaheadMonths)} " +
$"({options.LookaheadMonths}) must be > 0.");
}
}
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Validates <see cref="AuditLogPurgeOptions"/> at host startup (arch-review 08
/// round 2 NF4). The class self-clamps at resolve time, but a plainly-wrong
/// config (zero cadence, zero batch, zero maintenance-command timeout) should
/// still fail the boot so the operator learns immediately.
/// <see cref="AuditLogPurgeOptions.IntervalOverride"/> is test-only (per the
/// class remarks) and is deliberately not validated.
/// </summary>
public sealed class AuditLogPurgeOptionsValidator : OptionsValidatorBase<AuditLogPurgeOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AuditLogPurgeOptions options)
{
builder.RequireThat(options.IntervalHours > 0,
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.IntervalHours)} " +
$"({options.IntervalHours}) must be > 0.");
// Backing field for the documented "ChannelPurgeBatchSize" config key.
builder.RequireThat(options.ChannelPurgeBatchSizeConfigured > 0,
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured)} " +
$"(config key ChannelPurgeBatchSize = {options.ChannelPurgeBatchSizeConfigured}) must be > 0.");
builder.RequireThat(options.MaintenanceCommandTimeoutMinutes > 0,
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes)} " +
$"({options.MaintenanceCommandTimeoutMinutes}) must be > 0.");
}
}
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Validates <see cref="SiteAuditReconciliationOptions"/> at host startup
/// (arch-review 08 round 2 NF4). A zero tick interval spins the reconciliation
/// singleton, a zero batch pulls nothing per RPC, and a zero stalled-cycle
/// threshold would fire the stalled signal on the first non-draining cycle.
/// <see cref="SiteAuditReconciliationOptions.ReconciliationIntervalOverride"/>
/// is test-only (per the class remarks) and is deliberately not validated.
/// </summary>
public sealed class SiteAuditReconciliationOptionsValidator
: OptionsValidatorBase<SiteAuditReconciliationOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SiteAuditReconciliationOptions options)
{
builder.RequireThat(options.ReconciliationIntervalSeconds > 0,
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds)} " +
$"({options.ReconciliationIntervalSeconds}) must be > 0.");
builder.RequireThat(options.BatchSize > 0,
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.BatchSize)} " +
$"({options.BatchSize}) must be > 0.");
builder.RequireThat(options.StalledAfterNonDrainingCycles > 0,
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.StalledAfterNonDrainingCycles)} " +
$"({options.StalledAfterNonDrainingCycles}) must be > 0.");
}
}
@@ -92,20 +92,23 @@ public static class ServiceCollectionExtensions
// failures as AuditRedactionFailure on the site health report.
services.TryAddSingleton<IAuditRedactionFailureCounter, NoOpAuditRedactionFailureCounter>();
// Site writer + telemetry options bindings.
// BindConfiguration is not used because the configuration root supplied
// by the caller may not be the application root — we go through the
// section explicitly so a partial IConfiguration (e.g. a test stub
// anchored on the AuditLog section's parent) still works.
services.AddOptions<SqliteAuditWriterOptions>()
.Bind(config.GetSection(SiteWriterSectionName));
services.AddOptions<SiteAuditTelemetryOptions>()
.Bind(config.GetSection(SiteTelemetrySectionName));
// Site writer + telemetry options bindings — now bind-and-validate via
// the shared helper so a bad site config fails fast at boot (NF4). The
// helper's GetSection(sectionPath) still resolves the section explicitly
// (NOT BindConfiguration), preserving the partial-config-root rationale:
// the caller's IConfiguration may be a test stub anchored on the AuditLog
// section's parent rather than the application root.
services.AddValidatedOptions<SqliteAuditWriterOptions, SqliteAuditWriterOptionsValidator>(
config, SiteWriterSectionName);
services.AddValidatedOptions<SiteAuditTelemetryOptions, SiteAuditTelemetryOptionsValidator>(
config, SiteTelemetrySectionName);
// Retention purge options (the SiteAuditRetentionService hosted job is
// registered site-only in AddAuditLogHealthMetricsBridge). Binding here is
// inert on central — no hosted service consumes it there.
services.AddOptions<SiteAuditRetentionOptions>()
.Bind(config.GetSection(SiteAuditRetentionOptions.SectionName));
// registered site-only in AddAuditLogHealthMetricsBridge). The binding
// here is inert on central — no hosted service consumes it there — but
// the defaults validate clean, so eager validation on the central root is
// harmless.
services.AddValidatedOptions<SiteAuditRetentionOptions, SiteAuditRetentionOptionsValidator>(
config, SiteAuditRetentionOptions.SectionName);
// SqliteAuditWriter is a singleton with a single owned SqliteConnection
// and a background writer Task; multiple instances would race on the
@@ -365,8 +368,8 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
services.AddOptions<AuditLogPartitionMaintenanceOptions>()
.Bind(config.GetSection(PartitionMaintenanceSectionName));
services.AddValidatedOptions<AuditLogPartitionMaintenanceOptions,
AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
services.AddHostedService<AuditLogPartitionMaintenanceService>();
// I1 (review): bind the two central-singleton options HERE rather than in
@@ -382,10 +385,10 @@ public static class ServiceCollectionExtensions
// 5 min reconciliation tick); production exposes IntervalHours /
// ReconciliationIntervalSeconds only — the test-only *Override knobs are
// not intended to be set from config (see the options classes' remarks).
services.AddOptions<AuditLogPurgeOptions>()
.Bind(config.GetSection(PurgeSectionName));
services.AddOptions<SiteAuditReconciliationOptions>()
.Bind(config.GetSection(ReconciliationSectionName));
services.AddValidatedOptions<AuditLogPurgeOptions, AuditLogPurgeOptionsValidator>(
config, PurgeSectionName);
services.AddValidatedOptions<SiteAuditReconciliationOptions, SiteAuditReconciliationOptionsValidator>(
config, ReconciliationSectionName);
// Central health snapshot — a single object
// that owns the CentralAuditWriteFailures + AuditRedactionFailure
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Validates <see cref="SiteAuditRetentionOptions"/> at host startup
/// (arch-review 08 round 2 NF4). The option class self-clamps at resolve time,
/// but a plainly-wrong config (zero retention window, zero/negative purge
/// period, negative initial delay) should still fail the boot so the operator
/// learns immediately rather than trusting a silent clamp.
/// <see cref="SiteAuditRetentionOptions.PurgeIntervalOverride"/> is test-only
/// (per the class remarks) and is deliberately not validated.
/// </summary>
public sealed class SiteAuditRetentionOptionsValidator : OptionsValidatorBase<SiteAuditRetentionOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SiteAuditRetentionOptions options)
{
builder.RequireThat(options.RetentionDays > 0,
$"AuditLog:SiteRetention:{nameof(SiteAuditRetentionOptions.RetentionDays)} " +
$"({options.RetentionDays}) must be > 0.");
builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
$"AuditLog:SiteRetention:{nameof(SiteAuditRetentionOptions.PurgeInterval)} " +
$"({options.PurgeInterval}) must be > 0.");
builder.RequireThat(options.InitialDelay >= TimeSpan.Zero,
$"AuditLog:SiteRetention:{nameof(SiteAuditRetentionOptions.InitialDelay)} " +
$"({options.InitialDelay}) must be >= 0.");
}
}
@@ -0,0 +1,38 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Validates <see cref="SqliteAuditWriterOptions"/> at host startup
/// (arch-review 08 round 2 NF4). The channel/batch/flush knobs feed the
/// background writer task; a zero anywhere stalls it (nothing drains, nothing
/// flushes), and an empty <c>DatabasePath</c> leaves the SQLite writer with no
/// backing store. <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/>
/// is intentionally NOT validated — a non-positive value has a documented
/// fall-back-to-30s contract in <c>SiteAuditBacklogReporter</c>.
/// </summary>
public sealed class SqliteAuditWriterOptionsValidator : OptionsValidatorBase<SqliteAuditWriterOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SqliteAuditWriterOptions options)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(options.DatabasePath),
$"AuditLog:SiteWriter:{nameof(SqliteAuditWriterOptions.DatabasePath)} must be a non-empty path.");
builder.RequireThat(options.ChannelCapacity > 0,
$"AuditLog:SiteWriter:{nameof(SqliteAuditWriterOptions.ChannelCapacity)} " +
$"({options.ChannelCapacity}) must be > 0.");
builder.RequireThat(options.BatchSize > 0,
$"AuditLog:SiteWriter:{nameof(SqliteAuditWriterOptions.BatchSize)} " +
$"({options.BatchSize}) must be > 0.");
builder.RequireThat(options.FlushIntervalMs > 0,
$"AuditLog:SiteWriter:{nameof(SqliteAuditWriterOptions.FlushIntervalMs)} " +
$"({options.FlushIntervalMs}) must be > 0.");
// BacklogPollIntervalSeconds is deliberately unchecked: a non-positive
// value falls back to the reporter's 30s default (see the option's
// remarks + register row 21 resolution).
}
}
@@ -0,0 +1,36 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// <summary>
/// Validates <see cref="SiteAuditTelemetryOptions"/> at host startup
/// (arch-review 08 round 2 NF4). <c>SiteAuditTelemetryActor</c> uses the two
/// interval knobs as raw scheduling delays (busy after a productive/faulted
/// drain, idle after an empty one), so a zero interval spins the drain loop and
/// a zero batch drains nothing. Idle must be &gt;= busy: a shorter idle interval
/// inverts the actor's back-off-when-empty intent.
/// </summary>
public sealed class SiteAuditTelemetryOptionsValidator : OptionsValidatorBase<SiteAuditTelemetryOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SiteAuditTelemetryOptions options)
{
builder.RequireThat(options.BatchSize > 0,
$"AuditLog:SiteTelemetry:{nameof(SiteAuditTelemetryOptions.BatchSize)} " +
$"({options.BatchSize}) must be > 0.");
builder.RequireThat(options.BusyIntervalSeconds > 0,
$"AuditLog:SiteTelemetry:{nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds)} " +
$"({options.BusyIntervalSeconds}) must be > 0.");
builder.RequireThat(options.IdleIntervalSeconds > 0,
$"AuditLog:SiteTelemetry:{nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds)} " +
$"({options.IdleIntervalSeconds}) must be > 0.");
builder.RequireThat(options.IdleIntervalSeconds >= options.BusyIntervalSeconds,
$"AuditLog:SiteTelemetry:{nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds)} " +
$"({options.IdleIntervalSeconds}) must be >= " +
$"{nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds)} ({options.BusyIntervalSeconds}) " +
"— the idle drain must back off at least as far as the busy drain.");
}
}
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
/// <summary>
/// Validates <see cref="CommunicationOptions"/> at startup (ValidateOnStart) so a
/// malformed "Communication" appsettings section fails fast at boot with a
/// malformed "ScadaBridge:Communication" appsettings section fails fast at boot with a
/// key-naming message instead of surfacing later at first Ask/gRPC use. Every
/// timeout feeds a per-pattern Ask deadline or a gRPC keepalive/stream-lifetime
/// setting, and a zero/negative value there produces an opaque runtime failure
@@ -16,79 +16,79 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
protected override void Validate(ValidationBuilder builder, CommunicationOptions options)
{
builder.RequireThat(options.DeploymentTimeout > TimeSpan.Zero,
$"Communication:DeploymentTimeout must be a positive duration (was {options.DeploymentTimeout}).");
$"ScadaBridge:Communication:DeploymentTimeout must be a positive duration (was {options.DeploymentTimeout}).");
builder.RequireThat(options.LifecycleTimeout > TimeSpan.Zero,
$"Communication:LifecycleTimeout must be a positive duration (was {options.LifecycleTimeout}).");
$"ScadaBridge:Communication:LifecycleTimeout must be a positive duration (was {options.LifecycleTimeout}).");
builder.RequireThat(options.ArtifactDeploymentTimeout > TimeSpan.Zero,
$"Communication:ArtifactDeploymentTimeout must be a positive duration (was {options.ArtifactDeploymentTimeout}).");
$"ScadaBridge:Communication:ArtifactDeploymentTimeout must be a positive duration (was {options.ArtifactDeploymentTimeout}).");
builder.RequireThat(options.QueryTimeout > TimeSpan.Zero,
$"Communication:QueryTimeout must be a positive duration (was {options.QueryTimeout}).");
$"ScadaBridge:Communication:QueryTimeout must be a positive duration (was {options.QueryTimeout}).");
builder.RequireThat(options.IntegrationTimeout > TimeSpan.Zero,
$"Communication:IntegrationTimeout must be a positive duration (was {options.IntegrationTimeout}).");
$"ScadaBridge:Communication:IntegrationTimeout must be a positive duration (was {options.IntegrationTimeout}).");
builder.RequireThat(options.DebugViewTimeout > TimeSpan.Zero,
$"Communication:DebugViewTimeout must be a positive duration (was {options.DebugViewTimeout}).");
$"ScadaBridge:Communication:DebugViewTimeout must be a positive duration (was {options.DebugViewTimeout}).");
builder.RequireThat(options.HealthReportTimeout > TimeSpan.Zero,
$"Communication:HealthReportTimeout must be a positive duration (was {options.HealthReportTimeout}).");
$"ScadaBridge:Communication:HealthReportTimeout must be a positive duration (was {options.HealthReportTimeout}).");
builder.RequireThat(options.NotificationForwardTimeout > TimeSpan.Zero,
$"Communication:NotificationForwardTimeout must be a positive duration (was {options.NotificationForwardTimeout}).");
$"ScadaBridge:Communication:NotificationForwardTimeout must be a positive duration (was {options.NotificationForwardTimeout}).");
builder.RequireThat(options.GrpcKeepAlivePingDelay > TimeSpan.Zero,
$"Communication:GrpcKeepAlivePingDelay must be a positive duration (was {options.GrpcKeepAlivePingDelay}).");
$"ScadaBridge:Communication:GrpcKeepAlivePingDelay must be a positive duration (was {options.GrpcKeepAlivePingDelay}).");
builder.RequireThat(options.GrpcKeepAlivePingTimeout > TimeSpan.Zero,
$"Communication:GrpcKeepAlivePingTimeout must be a positive duration (was {options.GrpcKeepAlivePingTimeout}).");
$"ScadaBridge:Communication:GrpcKeepAlivePingTimeout must be a positive duration (was {options.GrpcKeepAlivePingTimeout}).");
builder.RequireThat(options.GrpcMaxStreamLifetime > TimeSpan.Zero,
$"Communication:GrpcMaxStreamLifetime must be a positive duration (was {options.GrpcMaxStreamLifetime}).");
$"ScadaBridge:Communication:GrpcMaxStreamLifetime must be a positive duration (was {options.GrpcMaxStreamLifetime}).");
builder.RequireThat(options.TransportHeartbeatInterval > TimeSpan.Zero,
$"Communication:TransportHeartbeatInterval must be a positive duration (was {options.TransportHeartbeatInterval}).");
$"ScadaBridge:Communication:TransportHeartbeatInterval must be a positive duration (was {options.TransportHeartbeatInterval}).");
builder.RequireThat(options.ApplicationHeartbeatInterval > TimeSpan.Zero,
$"Communication:ApplicationHeartbeatInterval must be a positive duration (was {options.ApplicationHeartbeatInterval}).");
$"ScadaBridge:Communication:ApplicationHeartbeatInterval must be a positive duration (was {options.ApplicationHeartbeatInterval}).");
builder.RequireThat(options.TransportFailureThreshold > TimeSpan.Zero,
$"Communication:TransportFailureThreshold must be a positive duration (was {options.TransportFailureThreshold}).");
$"ScadaBridge:Communication:TransportFailureThreshold must be a positive duration (was {options.TransportFailureThreshold}).");
builder.RequireThat(options.PendingDeploymentTtl > TimeSpan.Zero,
$"Communication:PendingDeploymentTtl must be a positive duration (was {options.PendingDeploymentTtl}).");
$"ScadaBridge:Communication:PendingDeploymentTtl must be a positive duration (was {options.PendingDeploymentTtl}).");
builder.RequireThat(options.PendingDeploymentPurgeInterval > TimeSpan.Zero,
$"Communication:PendingDeploymentPurgeInterval must be a positive duration (was {options.PendingDeploymentPurgeInterval}).");
$"ScadaBridge:Communication:PendingDeploymentPurgeInterval must be a positive duration (was {options.PendingDeploymentPurgeInterval}).");
builder.RequireThat(options.GrpcMaxConcurrentStreams > 0,
$"Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams}).");
$"ScadaBridge:Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams}).");
// ── Aggregated live alarm cache (plan #10, Task 6) ───────────────────────
// Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator
// immediately when the last viewer leaves), only a negative value is invalid.
builder.RequireThat(options.LiveAlarmCacheLinger >= TimeSpan.Zero,
$"Communication:LiveAlarmCacheLinger must be zero or a positive duration (was {options.LiveAlarmCacheLinger}).");
$"ScadaBridge:Communication:LiveAlarmCacheLinger must be zero or a positive duration (was {options.LiveAlarmCacheLinger}).");
// Reconcile interval is a periodic timer cadence AND the start-retry cadence; a
// zero/negative value would spin or never fire.
builder.RequireThat(options.LiveAlarmCacheReconcileInterval > TimeSpan.Zero,
$"Communication:LiveAlarmCacheReconcileInterval must be a positive duration (was {options.LiveAlarmCacheReconcileInterval}).");
$"ScadaBridge:Communication:LiveAlarmCacheReconcileInterval must be a positive duration (was {options.LiveAlarmCacheReconcileInterval}).");
// Seed fan-out concurrency gates a SemaphoreSlim; at least 1, capped so a
// fat-fingered config can't open an unbounded burst of cross-cluster Asks.
builder.RequireThat(options.LiveAlarmCacheSeedConcurrency is >= 1 and <= 64,
$"Communication:LiveAlarmCacheSeedConcurrency must be between 1 and 64 (was {options.LiveAlarmCacheSeedConcurrency}).");
$"ScadaBridge:Communication:LiveAlarmCacheSeedConcurrency must be between 1 and 64 (was {options.LiveAlarmCacheSeedConcurrency}).");
// Per-site viewer cap must admit at least one viewer, else the page could never go live.
builder.RequireThat(options.LiveAlarmCacheMaxSubscribersPerSite >= 1,
$"Communication:LiveAlarmCacheMaxSubscribersPerSite must be at least 1 (was {options.LiveAlarmCacheMaxSubscribersPerSite}).");
$"ScadaBridge:Communication:LiveAlarmCacheMaxSubscribersPerSite must be at least 1 (was {options.LiveAlarmCacheMaxSubscribersPerSite}).");
// Publish-coalescing window drives a single-shot timer; TimeSpan.Zero is valid
// (publish per delta — legacy), only a negative value is invalid.
builder.RequireThat(options.LiveAlarmCachePublishCoalesce >= TimeSpan.Zero,
$"Communication:LiveAlarmCachePublishCoalesce must be zero or a positive duration (was {options.LiveAlarmCachePublishCoalesce}).");
$"ScadaBridge:Communication:LiveAlarmCachePublishCoalesce must be zero or a positive duration (was {options.LiveAlarmCachePublishCoalesce}).");
}
}
@@ -12,8 +12,12 @@ public static class ServiceCollectionExtensions
/// <returns>The same <see cref="IServiceCollection"/> to allow chaining.</returns>
public static IServiceCollection AddCommunication(this IServiceCollection services)
{
// Canonical section is ScadaBridge:Communication — the section every real
// appsettings actually writes (arch-review 08 round 2 NF8). The Host used to
// supply the real values via a duplicate Configure<> binding; that duplicate is
// gone, so AddCommunication must bind the canonical section itself.
services.AddOptions<CommunicationOptions>()
.BindConfiguration("Communication")
.BindConfiguration("ScadaBridge:Communication")
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<CommunicationOptions>, CommunicationOptionsValidator>());
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
/// <summary>
/// Validates <see cref="DataConnectionOptions"/> at startup (ValidateOnStart) so a
/// malformed "DataConnectionLayer" appsettings section fails fast at boot with a
/// malformed "ScadaBridge:DataConnection" appsettings section fails fast at boot with a
/// key-naming message instead of surfacing later at runtime. The intervals drive
/// the reconnect/tag-resolution timers and the seed-read retry loop; a
/// zero/negative duration (or a non-positive <c>SeedReadMaxAttempts</c>) would
@@ -16,24 +16,24 @@ public sealed class DataConnectionOptionsValidator : OptionsValidatorBase<DataCo
protected override void Validate(ValidationBuilder builder, DataConnectionOptions options)
{
builder.RequireThat(options.ReconnectInterval > TimeSpan.Zero,
$"DataConnectionLayer:ReconnectInterval must be a positive duration (was {options.ReconnectInterval}).");
$"ScadaBridge:DataConnection:ReconnectInterval must be a positive duration (was {options.ReconnectInterval}).");
builder.RequireThat(options.TagResolutionRetryInterval > TimeSpan.Zero,
$"DataConnectionLayer:TagResolutionRetryInterval must be a positive duration (was {options.TagResolutionRetryInterval}).");
$"ScadaBridge:DataConnection:TagResolutionRetryInterval must be a positive duration (was {options.TagResolutionRetryInterval}).");
builder.RequireThat(options.WriteTimeout > TimeSpan.Zero,
$"DataConnectionLayer:WriteTimeout must be a positive duration (was {options.WriteTimeout}).");
$"ScadaBridge:DataConnection:WriteTimeout must be a positive duration (was {options.WriteTimeout}).");
builder.RequireThat(options.SeedReadTimeout > TimeSpan.Zero,
$"DataConnectionLayer:SeedReadTimeout must be a positive duration (was {options.SeedReadTimeout}).");
$"ScadaBridge:DataConnection:SeedReadTimeout must be a positive duration (was {options.SeedReadTimeout}).");
builder.RequireThat(options.StableConnectionThreshold > TimeSpan.Zero,
$"DataConnectionLayer:StableConnectionThreshold must be a positive duration (was {options.StableConnectionThreshold}).");
$"ScadaBridge:DataConnection:StableConnectionThreshold must be a positive duration (was {options.StableConnectionThreshold}).");
builder.RequireThat(options.SeedReadRetryDelay > TimeSpan.Zero,
$"DataConnectionLayer:SeedReadRetryDelay must be a positive duration (was {options.SeedReadRetryDelay}).");
$"ScadaBridge:DataConnection:SeedReadRetryDelay must be a positive duration (was {options.SeedReadRetryDelay}).");
builder.RequireThat(options.SeedReadMaxAttempts > 0,
$"DataConnectionLayer:SeedReadMaxAttempts must be positive (was {options.SeedReadMaxAttempts}).");
$"ScadaBridge:DataConnection:SeedReadMaxAttempts must be positive (was {options.SeedReadMaxAttempts}).");
}
}
@@ -1,8 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
/// <summary>
/// Deployment-wide MxGateway defaults, bound from the "MxGateway" section of
/// appsettings.json. Per-endpoint behavior lives on MxGatewayEndpointConfig.
/// Deployment-wide MxGateway defaults, bound from the "ScadaBridge:MxGateway" section
/// of appsettings.json. Per-endpoint behavior lives on MxGatewayEndpointConfig.
/// </summary>
public class MxGatewayGlobalOptions
{
@@ -1,8 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
/// <summary>
/// Deployment-wide OPC UA application identity. Bound from the "OpcUa" section
/// of appsettings.json. Per-endpoint behavior lives on OpcUaEndpointConfig.
/// Deployment-wide OPC UA application identity. Bound from the "ScadaBridge:OpcUa"
/// section of appsettings.json. Per-endpoint behavior lives on OpcUaEndpointConfig.
/// Empty paths fall back to a default under Path.GetTempPath() so dev runs
/// work without explicit configuration.
/// </summary>
@@ -14,20 +14,26 @@ public static class ServiceCollectionExtensions
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
public static IServiceCollection AddDataConnectionLayer(this IServiceCollection services)
{
// Canonical section is ScadaBridge:DataConnection — the section every real
// appsettings actually writes (arch-review 08 round 2 NF8). The Host used to
// supply the real values via a duplicate Configure<> binding; that duplicate is
// gone, so AddDataConnectionLayer must bind the canonical section itself.
services.AddOptions<DataConnectionOptions>()
.BindConfiguration("DataConnectionLayer")
.BindConfiguration("ScadaBridge:DataConnection")
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<DataConnectionOptions>, DataConnectionOptionsValidator>());
// OpcUaGlobalOptions / MxGatewayGlobalOptions carry only string identity/path
// fields (no numeric or duration knobs), so there is nothing to range-validate
// — no dedicated validator is registered for them.
// — no dedicated validator is registered for them. Canonical sections are
// ScadaBridge:OpcUa / ScadaBridge:MxGateway (NF8): the bare "OpcUa"/"MxGateway"
// sections exist in no appsettings, so binding them read nothing.
services.AddOptions<OpcUaGlobalOptions>()
.BindConfiguration("OpcUa");
.BindConfiguration("ScadaBridge:OpcUa");
services.AddOptions<MxGatewayGlobalOptions>()
.BindConfiguration("MxGateway");
.BindConfiguration("ScadaBridge:MxGateway");
// Register the factory for protocol extensibility
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
@@ -0,0 +1,28 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Validates <see cref="DatabaseOptions"/> at host startup (arch-review 08
/// round 2 NF4). All three connection settings are nullable and role-dependent
/// (central needs the SQL Server strings, a site needs only the SQLite path),
/// so a <c>null</c> value is valid. A present-but-whitespace value is not — it
/// signals a mis-set config key that would fail opaquely at first DB use.
/// </summary>
public sealed class DatabaseOptionsValidator : OptionsValidatorBase<DatabaseOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, DatabaseOptions options)
{
RequireNotWhitespace(builder, options.ConfigurationDb, nameof(DatabaseOptions.ConfigurationDb));
RequireNotWhitespace(builder, options.MachineDataDb, nameof(DatabaseOptions.MachineDataDb));
RequireNotWhitespace(builder, options.SiteDbPath, nameof(DatabaseOptions.SiteDbPath));
}
// null is valid (role-dependent); reject only a present-but-blank value.
private static void RequireNotWhitespace(ValidationBuilder builder, string? value, string field)
{
builder.RequireThat(value is null || !string.IsNullOrWhiteSpace(value),
$"ScadaBridge:Database:{field} is set but blank — either remove it or give it a real value.");
}
}
@@ -0,0 +1,27 @@
using Serilog.Events;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Validates <see cref="LoggingOptions"/> at host startup (arch-review 08 round
/// 2 NF4). <see cref="LoggingOptions.MinimumLevel"/> is parsed into a Serilog
/// <see cref="LogEventLevel"/> by <c>LoggerConfigurationFactory.ParseLevel</c>,
/// which silently falls back to <c>Information</c> for an unrecognised value.
/// This validator surfaces that misconfiguration at boot instead: a present,
/// non-blank value must name a real level (Verbose/Debug/Information/Warning/
/// Error/Fatal, case-insensitive). A null/blank value is treated as "unset"
/// (defaults to Information), mirroring the parser.
/// </summary>
public sealed class LoggingOptionsValidator : OptionsValidatorBase<LoggingOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, LoggingOptions options)
{
builder.RequireThat(
string.IsNullOrWhiteSpace(options.MinimumLevel)
|| Enum.TryParse<LogEventLevel>(options.MinimumLevel, ignoreCase: true, out _),
$"ScadaBridge:Logging:{nameof(LoggingOptions.MinimumLevel)} ('{options.MinimumLevel}') " +
$"is not a recognised Serilog level. Valid: {string.Join(", ", Enum.GetNames<LogEventLevel>())}.");
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Validates <see cref="NodeOptions"/> at host startup (arch-review 08 round 2
/// NF4). The headline invariant: <see cref="NodeOptions.NodeName"/> must be
/// non-empty — an empty value normalises to a NULL <c>SourceNode</c> audit
/// column, silently degrading the audit trail (the wonder-app-vd03 failure
/// mode), so the host must fail fast at boot instead. Ports are bounded to the
/// TCP range; <c>0</c> stays valid because it requests a dynamically-assigned
/// port (CompositionRootTests rely on this).
/// </summary>
public sealed class NodeOptionsValidator : OptionsValidatorBase<NodeOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, NodeOptions options)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(options.NodeName),
$"ScadaBridge:Node:{nameof(NodeOptions.NodeName)} must be a non-empty label " +
"— an empty node name stamps the SourceNode audit column NULL.");
RequirePort(builder, options.RemotingPort, nameof(NodeOptions.RemotingPort));
RequirePort(builder, options.GrpcPort, nameof(NodeOptions.GrpcPort));
RequirePort(builder, options.MetricsPort, nameof(NodeOptions.MetricsPort));
}
// 0 stays valid (dynamic-port request); reject only out-of-TCP-range values.
private static void RequirePort(ValidationBuilder builder, int port, string field)
{
builder.RequireThat(port is >= 0 and <= 65_535,
$"ScadaBridge:Node:{field} ({port}) must be in [0, 65535].");
}
}
@@ -137,7 +137,21 @@ public static class SiteServiceRegistration
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SiteRuntimeOptions>, SiteRuntimeOptionsValidator>());
services.Configure<DataConnectionOptions>(config.GetSection("ScadaBridge:DataConnection"));
// OperationTrackingOptions for the site-local cached-call tracking store
// (registered by AddSiteRuntime). Bind + eagerly validate here — the store
// opens its SQLite connection string at construction, so a blank connection
// string should fail the host at boot with a key-naming message rather than
// throw opaquely on first resolve (arch-review 08r2 NF4/T8).
services.AddOptions<SiteRuntime.Tracking.OperationTrackingOptions>()
.Bind(config.GetSection("ScadaBridge:OperationTracking"))
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SiteRuntime.Tracking.OperationTrackingOptions>,
SiteRuntime.Tracking.OperationTrackingOptionsValidator>());
// NF8: DataConnectionOptions is now bound by AddDataConnectionLayer() itself
// (canonical ScadaBridge:DataConnection section) — the duplicate Host binding
// that used to mask the SCE's wrong section name is deleted.
services.AddOptions<StoreAndForwardOptions>()
.Bind(config.GetSection("ScadaBridge:StoreAndForward"))
@@ -157,19 +171,38 @@ public static class SiteServiceRegistration
/// <param name="config">Application configuration supplying the option values.</param>
public static void BindSharedOptions(IServiceCollection services, IConfiguration config)
{
services.Configure<NodeOptions>(config.GetSection("ScadaBridge:Node"));
// Bind + eagerly validate: an empty NodeName would stamp the SourceNode audit
// column NULL, so fail the host at boot with a key-naming message instead of
// silently degrading the audit trail (arch-review 08r2 NF4).
services.AddOptions<NodeOptions>().Bind(config.GetSection("ScadaBridge:Node")).ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<NodeOptions>, NodeOptionsValidator>());
// Bind + eagerly validate: ClusterOptionsValidator is registered (TryAddEnumerable)
// by the ClusterInfrastructure module, so chaining ValidateOnStart() here makes a bad
// ScadaBridge:Cluster section fail fast at host build instead of lazily on first resolve.
services.AddOptions<ClusterOptions>().Bind(config.GetSection("ScadaBridge:Cluster")).ValidateOnStart();
services.Configure<DatabaseOptions>(config.GetSection("ScadaBridge:Database"));
services.Configure<CommunicationOptions>(config.GetSection("ScadaBridge:Communication"));
// Bind + eagerly validate: a present-but-blank connection setting fails fast
// here rather than opaquely at first DB use (arch-review 08r2 NF4).
services.AddOptions<DatabaseOptions>().Bind(config.GetSection("ScadaBridge:Database")).ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>());
// NF8: CommunicationOptions is now bound by AddCommunication() itself (canonical
// ScadaBridge:Communication section) — the duplicate Host binding that used to mask
// the SCE's wrong section name is deleted.
// Bind + eagerly validate: HealthMonitoringOptionsValidator is registered (TryAddEnumerable)
// by the HealthMonitoring module, so chaining ValidateOnStart() here makes a bad
// ScadaBridge:HealthMonitoring section fail fast at host build instead of lazily on first resolve.
services.AddOptions<HealthMonitoringOptions>().Bind(config.GetSection("ScadaBridge:HealthMonitoring")).ValidateOnStart();
services.Configure<NotificationOptions>(config.GetSection("ScadaBridge:Notification"));
services.Configure<LoggingOptions>(config.GetSection("ScadaBridge:Logging"));
// NF8: NotificationOptions is bound by AddNotificationService() (central-only,
// canonical ScadaBridge:Notification section). Sites don't deliver notifications
// and have no IOptions<NotificationOptions> consumer, so the redundant Host
// duplicate that bound it on both paths is deleted.
// Bind + eagerly validate: an unrecognised MinimumLevel would silently fall back
// to Information; fail fast at boot so the operator's intended floor is honoured
// (arch-review 08r2 NF4).
services.AddOptions<LoggingOptions>().Bind(config.GetSection("ScadaBridge:Logging")).ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<LoggingOptions>, LoggingOptionsValidator>());
// Audit Log — exposes ScadaBridge:Node:NodeName to downstream audit
// writers so they can stamp the SourceNode column. Registered here in
@@ -60,6 +60,19 @@ public static class ServiceCollectionExtensions
});
services.AddSingleton<ISiteStreamSubscriber>(sp => sp.GetRequiredService<SiteStreamManager>());
// Site-local cached-operation tracking store — the SQLite source of truth
// that Tracking.Status(TrackedOperationId) reads and the cached-call telemetry
// forwarder writes. Registered HERE (site-only) so the two comments that claim
// "the operational tracking store is registered by AddSiteRuntime" are true
// again (AuditLog SCE + AkkaHostedService). Before this (arch-review 08 round 2
// NF4/T8) it had no DI registration anywhere in src/, so every consumer resolved
// it as null and silently ran degraded (no cached-drain, no PullSiteCalls
// reconciliation, Tracking.Status audit-only). Central roots never call
// AddSiteRuntime, so this stays absent on central — matching the SITE-ONLY
// contract. OperationTrackingOptions is bound + validated by the Host's
// SiteServiceRegistration site-options block.
services.AddSingleton<Commons.Interfaces.IOperationTrackingStore, Tracking.OperationTrackingStore>();
// Site-local repository implementations backed by SQLite
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
@@ -0,0 +1,26 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
/// <summary>
/// Validates <see cref="OperationTrackingOptions"/> at host startup (arch-review
/// 08 round 2 NF4). <see cref="OperationTrackingStore"/> opens the SQLite
/// connection string in its constructor, so an empty/blank connection string
/// must fail the host at boot rather than throw opaquely on first resolve. The
/// retention window must be positive — a zero would make the host's terminal-row
/// purge delete every tracking row.
/// </summary>
public sealed class OperationTrackingOptionsValidator : OptionsValidatorBase<OperationTrackingOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, OperationTrackingOptions options)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(options.ConnectionString),
$"ScadaBridge:OperationTracking:{nameof(OperationTrackingOptions.ConnectionString)} " +
"must be a non-empty SQLite connection string.");
builder.RequireThat(options.RetentionDays > 0,
$"ScadaBridge:OperationTracking:{nameof(OperationTrackingOptions.RetentionDays)} " +
$"({options.RetentionDays}) must be > 0.");
}
}
@@ -550,8 +550,14 @@ public sealed class EntitySerializer
})
.ToList();
// Instances: resolve template + site by name/identifier. Areas don't travel
// (see export TODO), so AreaId stays null. Connection bindings keep their
// Instances: resolve template + site by name/identifier. Areas DO travel
// by NAME on InstanceDto.AreaName (export resolves AreaId→AreaNameById; the
// importer resolves-or-creates the area under the target site). This helper
// still materializes AreaId = null for the same reason the connection-binding
// FK below is a placeholder: FromBundleContent is NOT on the importer's path
// (BundleImporter walks the raw DTO) and has no target-DB name→id map to
// resolve against. Round-trip fidelity for the area lives on the DTO, not on
// this materialized entity. Connection bindings keep their
// resolved DataConnection NAME on the wire DTO — the importer reads
// bindings from BundleContentDto.Instances[].ConnectionBindings directly and
// resolves ConnectionName→the target environment's DataConnectionId at apply
@@ -0,0 +1,41 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="AuditLogPartitionMaintenanceOptions"/>
/// (arch-review 08 round 2 NF4). A zero tick interval spins the maintenance
/// hosted service; a zero look-ahead leaves the partition function without a
/// future monthly boundary, risking inserts into the unbounded tail partition.
/// </summary>
public class AuditLogPartitionMaintenanceOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
}
[Fact]
public void ZeroIntervalSeconds_Fails()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
var result = validator.Validate(null,
new AuditLogPartitionMaintenanceOptions { IntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroLookaheadMonths_Fails()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
var result = validator.Validate(null,
new AuditLogPartitionMaintenanceOptions { LookaheadMonths = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.LookaheadMonths), StringComparison.Ordinal));
}
}
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="AuditLogPurgeOptions"/>
/// (arch-review 08 round 2 NF4). The class self-clamps at resolve time, but a
/// plainly-wrong config (zero cadence, zero batch, zero command timeout) should
/// still fail the boot so the operator learns immediately.
/// <see cref="AuditLogPurgeOptions.IntervalOverride"/> is test-only and left
/// unvalidated.
/// </summary>
public class AuditLogPurgeOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new AuditLogPurgeOptionsValidator();
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
}
[Fact]
public void ZeroIntervalHours_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null, new AuditLogPurgeOptions { IntervalHours = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal));
}
[Fact]
public void ZeroChannelPurgeBatchSize_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null,
new AuditLogPurgeOptions { ChannelPurgeBatchSizeConfigured = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
}
[Fact]
public void ZeroMaintenanceCommandTimeout_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null,
new AuditLogPurgeOptions { MaintenanceCommandTimeoutMinutes = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes), StringComparison.Ordinal));
}
}
@@ -0,0 +1,54 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="SiteAuditReconciliationOptions"/>
/// (arch-review 08 round 2 NF4). A zero tick interval spins the reconciliation
/// singleton, a zero batch pulls nothing, and a zero stalled-cycle threshold
/// would fire the stalled signal on the first non-draining cycle.
/// <see cref="SiteAuditReconciliationOptions.ReconciliationIntervalOverride"/>
/// is test-only and left unvalidated.
/// </summary>
public class SiteAuditReconciliationOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SiteAuditReconciliationOptionsValidator();
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
}
[Fact]
public void ZeroReconciliationInterval_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { ReconciliationIntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroBatchSize_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { BatchSize = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
}
[Fact]
public void ZeroStalledCycles_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { StalledAfterNonDrainingCycles = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.StalledAfterNonDrainingCycles), StringComparison.Ordinal));
}
}
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Eager startup validation for <see cref="SiteAuditRetentionOptions"/>
/// (arch-review 08 round 2 NF4). Although the option class self-clamps at
/// resolve time, the validator still fails a boot with a plainly-wrong config
/// (zero retention window, zero/negative purge period, negative initial delay)
/// so the operator learns at startup rather than trusting a silent clamp.
/// <c>PurgeIntervalOverride</c> is test-only and left unvalidated.
/// </summary>
public class SiteAuditRetentionOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SiteAuditRetentionOptionsValidator();
Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded);
}
[Fact]
public void ZeroRetentionDays_Fails()
{
var validator = new SiteAuditRetentionOptionsValidator();
var result = validator.Validate(null, new SiteAuditRetentionOptions { RetentionDays = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal));
}
[Fact]
public void ZeroPurgeInterval_Fails()
{
var validator = new SiteAuditRetentionOptionsValidator();
var result = validator.Validate(null,
new SiteAuditRetentionOptions { PurgeInterval = TimeSpan.Zero });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal));
}
[Fact]
public void NegativeInitialDelay_Fails()
{
var validator = new SiteAuditRetentionOptionsValidator();
var result = validator.Validate(null,
new SiteAuditRetentionOptions { InitialDelay = TimeSpan.FromSeconds(-1) });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditRetentionOptions.InitialDelay), StringComparison.Ordinal));
}
}
@@ -0,0 +1,61 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Eager startup validation for <see cref="SiteAuditTelemetryOptions"/>
/// (arch-review 08 round 2 NF4). The drain-loop cadences are used as raw
/// scheduling delays in <c>SiteAuditTelemetryActor</c>; a zero interval spins
/// the drain and a zero batch drains nothing. Idle must back off at least as
/// far as busy (idle &lt; busy inverts the actor's intent).
/// </summary>
public class SiteAuditTelemetryOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SiteAuditTelemetryOptionsValidator();
Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded);
}
[Fact]
public void ZeroBatchSize_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null, new SiteAuditTelemetryOptions { BatchSize = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal));
}
[Fact]
public void ZeroBusyInterval_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null, new SiteAuditTelemetryOptions { BusyIntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroIdleInterval_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null, new SiteAuditTelemetryOptions { IdleIntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void IdleShorterThanBusy_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null,
new SiteAuditTelemetryOptions { BusyIntervalSeconds = 30, IdleIntervalSeconds = 5 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
}
}
@@ -0,0 +1,72 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Eager startup validation for <see cref="SqliteAuditWriterOptions"/>
/// (arch-review 08 round 2 NF4). The site hot-path writer's channel/batch/flush
/// knobs must be positive or the background writer task cannot make progress;
/// an empty <c>DatabasePath</c> would leave the SQLite writer with nowhere to go.
/// <c>BacklogPollIntervalSeconds</c> is deliberately NOT validated — a
/// non-positive value has a documented fall-back-to-30s contract.
/// </summary>
public class SqliteAuditWriterOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SqliteAuditWriterOptionsValidator();
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
}
[Fact]
public void EmptyDatabasePath_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { DatabasePath = "" });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.DatabasePath), StringComparison.Ordinal));
}
[Fact]
public void ZeroChannelCapacity_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { ChannelCapacity = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
}
[Fact]
public void ZeroBatchSize_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { BatchSize = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
}
[Fact]
public void ZeroFlushInterval_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { FlushIntervalMs = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void NonPositiveBacklogPollInterval_StillValid(int value)
{
// Documented fall-back-to-30s contract — must NOT be rejected.
var validator = new SqliteAuditWriterOptionsValidator();
Assert.True(validator.Validate(null,
new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = value }).Succeeded);
}
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Locks the config section <see cref="ServiceCollectionExtensions.AddCommunication"/>
/// binds (arch-review 08 round 2 NF8). Every real appsettings nests this section under
/// <c>ScadaBridge:</c>; binding a root-level <c>Communication</c> section reads config
/// that no deployment writes — the drift the Host duplicate was silently masking.
/// AddCommunication alone must bind the canonical <c>ScadaBridge:Communication</c> section.
/// </summary>
public class CommunicationOptionsBindingTests
{
[Fact]
public void AddCommunication_BindsTheScadaBridgeCommunicationSection()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Communication:DeploymentTimeout"] = "00:01:23",
}).Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddCommunication();
using var sp = services.BuildServiceProvider();
Assert.Equal(TimeSpan.FromSeconds(83),
sp.GetRequiredService<IOptions<CommunicationOptions>>().Value.DeploymentTimeout);
}
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// Locks the config section <see cref="ServiceCollectionExtensions.AddDataConnectionLayer"/>
/// binds (arch-review 08 round 2 NF8). Every real appsettings nests this section under
/// <c>ScadaBridge:</c>; binding a root-level <c>DataConnectionLayer</c> section reads config
/// that no deployment writes — the drift the Host duplicate was silently masking.
/// AddDataConnectionLayer alone must bind the canonical <c>ScadaBridge:DataConnection</c> section.
/// </summary>
public class DataConnectionOptionsBindingTests
{
[Fact]
public void AddDataConnectionLayer_BindsTheScadaBridgeDataConnectionSection()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:DataConnection:ReconnectInterval"] = "00:00:42",
}).Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddDataConnectionLayer();
using var sp = services.BuildServiceProvider();
Assert.Equal(TimeSpan.FromSeconds(42),
sp.GetRequiredService<IOptions<DataConnectionOptions>>().Value.ReconnectInterval);
}
}
@@ -48,6 +48,7 @@ public class CentralActorPathTests : IAsyncLifetime
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:25510",
@@ -171,6 +172,7 @@ public class SiteActorPathTests : IAsyncLifetime
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
@@ -114,6 +114,7 @@ public class CentralAuditWiringTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -294,6 +295,7 @@ public class SiteAuditWiringTests : IDisposable
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",
@@ -108,6 +108,7 @@ public class CentralCompositionRootTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -338,21 +339,28 @@ public class SiteCompositionRootTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
private readonly string _tempTrackingDbPath;
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{Guid.NewGuid()}.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"] = _tempDbPath,
// Point the site-local operation-tracking SQLite store (the ctor opens/creates
// the file eagerly) at a temp path so resolving IOperationTrackingStore in the
// composition-root test does not litter site-tracking.db in the test cwd.
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
@@ -375,6 +383,20 @@ public class SiteCompositionRootTests : IDisposable
{
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempTrackingDbPath); } catch { /* best effort */ }
}
[Fact]
public void Site_Resolves_IOperationTrackingStore()
{
// Site Call Audit's site-local source of truth. AkkaHostedService and
// ScriptRuntimeContext resolve this with GetService and silently degrade
// when absent (audit-only telemetry, no cached-drain, no PullSiteCalls) —
// so its presence on the SITE composition root must be locked here
// (arch-review 08 round 2 NF4/T8: AddSiteRuntime never registered it,
// despite two comments claiming it did).
var store = _host.Services.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>();
Assert.NotNull(store);
}
// --- Singletons ---
@@ -44,6 +44,7 @@ public class HealthCheckTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -41,6 +41,7 @@ public class HostStartupTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -74,6 +75,7 @@ public class HostStartupTests : IDisposable
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",
@@ -103,6 +105,7 @@ public class HostStartupTests : IDisposable
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",
@@ -40,6 +40,7 @@ public class MetricsEndpointTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -0,0 +1,107 @@
using ZB.MOM.WW.ScadaBridge.Host;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Eager startup validation for the shared Host options (arch-review 08 round 2
/// NF4). Covers <see cref="NodeOptionsValidator"/>,
/// <see cref="DatabaseOptionsValidator"/>, and
/// <see cref="LoggingOptionsValidator"/> in one file. The headline case: an
/// empty <see cref="NodeOptions.NodeName"/> normalises to a NULL SourceNode
/// audit column, so it must fail the host at boot rather than silently degrade
/// the audit trail.
/// </summary>
public class NodeOptionsValidatorTests
{
// ---- NodeOptions ----
[Fact]
public void Node_DefaultsWithNodeName_AreValid()
{
var validator = new NodeOptionsValidator();
Assert.True(validator.Validate(null, new NodeOptions { NodeName = "node-a" }).Succeeded);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Node_EmptyOrWhitespaceNodeName_Fails(string nodeName)
{
var validator = new NodeOptionsValidator();
var result = validator.Validate(null, new NodeOptions { NodeName = nodeName });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(NodeOptions.NodeName), StringComparison.Ordinal));
}
[Fact]
public void Node_ZeroRemotingPort_IsValid()
{
// CompositionRootTests uses RemotingPort = 0 to request a dynamic port.
var validator = new NodeOptionsValidator();
Assert.True(validator.Validate(null,
new NodeOptions { NodeName = "node-a", RemotingPort = 0 }).Succeeded);
}
[Theory]
[InlineData(-1)]
[InlineData(65_536)]
public void Node_PortOutOfRange_Fails(int port)
{
var validator = new NodeOptionsValidator();
var result = validator.Validate(null,
new NodeOptions { NodeName = "node-a", GrpcPort = port });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(NodeOptions.GrpcPort), StringComparison.Ordinal));
}
// ---- DatabaseOptions ----
[Fact]
public void Database_AllNull_IsValid()
{
// All three fields are nullable and role-dependent; null stays valid.
var validator = new DatabaseOptionsValidator();
Assert.True(validator.Validate(null, new DatabaseOptions()).Succeeded);
}
[Fact]
public void Database_WhitespaceValue_Fails()
{
var validator = new DatabaseOptionsValidator();
var result = validator.Validate(null, new DatabaseOptions { ConfigurationDb = " " });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(DatabaseOptions.ConfigurationDb), StringComparison.Ordinal));
}
// ---- LoggingOptions ----
[Fact]
public void Logging_DefaultInformation_IsValid()
{
var validator = new LoggingOptionsValidator();
Assert.True(validator.Validate(null, new LoggingOptions()).Succeeded);
}
[Theory]
[InlineData("Verbose")]
[InlineData("debug")]
[InlineData("FATAL")]
public void Logging_RecognisedLevel_IsValid(string level)
{
var validator = new LoggingOptionsValidator();
Assert.True(validator.Validate(null, new LoggingOptions { MinimumLevel = level }).Succeeded);
}
[Fact]
public void Logging_UnknownLevel_Fails()
{
var validator = new LoggingOptionsValidator();
var result = validator.Validate(null, new LoggingOptions { MinimumLevel = "Chatty" });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(LoggingOptions.MinimumLevel), StringComparison.Ordinal));
}
}
@@ -0,0 +1,40 @@
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking;
/// <summary>
/// Eager startup validation for <see cref="OperationTrackingOptions"/>
/// (arch-review 08 round 2 NF4). The store opens the SQLite connection string at
/// construction, so an empty connection string must fail the host at boot; the
/// retention window must be positive or terminal-row purge would delete
/// everything.
/// </summary>
public class OperationTrackingOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new OperationTrackingOptionsValidator();
Assert.True(validator.Validate(null, new OperationTrackingOptions()).Succeeded);
}
[Fact]
public void EmptyConnectionString_Fails()
{
var validator = new OperationTrackingOptionsValidator();
var result = validator.Validate(null, new OperationTrackingOptions { ConnectionString = "" });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(OperationTrackingOptions.ConnectionString), StringComparison.Ordinal));
}
[Fact]
public void ZeroRetentionDays_Fails()
{
var validator = new OperationTrackingOptionsValidator();
var result = validator.Validate(null, new OperationTrackingOptions { RetentionDays = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(OperationTrackingOptions.RetentionDays), StringComparison.Ordinal));
}
}
+5
View File
@@ -0,0 +1,5 @@
# Git hooks
`pre-commit` scans the staged diff for obvious credential material (inbound API tokens `sbk_...`, plaintext password assignments in root `.txt`/`.md` additions) and blocks the commit if found (bypass a false positive with `git commit --no-verify`).
Opt in once with: `git config core.hooksPath tools/hooks`
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Root secret-capture guard (arch-review 08 round 2 NF1).
# Scans ONLY the staged diff for obvious credential material and blocks the
# commit if found. Opt-in: `git config core.hooksPath tools/hooks`.
# Bypass a knowing false positive with: git commit --no-verify
diff=$(git diff --cached -U0)
# 1) Inbound API token: sbk_<hex>_<token>
token=$(printf '%s\n' "$diff" | grep -nE '^\+.*sbk_[0-9a-f]{16,}_[A-Za-z0-9_-]{20,}')
# 2) Plaintext password assignment added to a root-level .txt/.md file.
pw=$(printf '%s\n' "$diff" | grep -nE '^\+.*[Pp]assword[0-9]*[[:space:]]*[:=][[:space:]]*[^[:space:]]')
if [ -n "$token" ] || [ -n "$pw" ]; then
echo "pre-commit: possible secret in staged changes — commit blocked." >&2
[ -n "$token" ] && echo " inbound API token (sbk_...) matched:" >&2 && printf '%s\n' "$token" >&2
[ -n "$pw" ] && echo " plaintext password assignment matched:" >&2 && printf '%s\n' "$pw" >&2
echo "Bypass a false positive with: git commit --no-verify" >&2
exit 1
fi
exit 0