Compare commits

..

11 Commits

Author SHA1 Message Date
Joseph Doherty 75007b9edd docs(archreview): sync Status Board — PLAN-08 Complete (11/11); INITIATIVE COMPLETE (191/192, sole gap = PLAN-07 T33 documented skip)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 07:15:08 -04:00
Joseph Doherty d8487eab2d docs: close arch-review 08 drift batch — Commons Observability folder, site-repo exception, csproj separator, docs/components scope, DelmiaNotifier convention exception
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 07:00:09 -04:00
Joseph Doherty d61881ffaf refactor(site-runtime): excise vestigial site-side notification-list surface — repo, DI registration, dead write paths (arch-review 08 §1.3/#23)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 07:00:09 -04:00
Joseph Doherty 7ff1263ac0 docs(plans): deferred-work register — triage the 23-item arch-review inventory into fix-now (plan-owned) vs deferred-with-rationale
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
Joseph Doherty e156c7fa8a test(commons): contract-lock tests for top ClusterClient records — enforce additive-only evolution under version skew (arch-review 08 §1.8)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
Joseph Doherty fcc9c7bf8e test(perf): failover-timing harness placeholder documenting the 25s envelope protocol, pending PLAN-01 two-node rig (arch-review 08 §2.3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
Joseph Doherty b879e51541 test(perf): site-wide stream throughput measurement — first real perf-envelope test (arch-review 08 §2.3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
Joseph Doherty 6bf9dfb3c5 feat(options): eager startup validation for central-component options (Transport, SiteCallAudit, DeploymentManager) (arch-review 08 §1.5)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
Joseph Doherty fc918d4679 feat(options): eager startup validation for edge/management options (InboundAPI, ESG, Notification, ManagementService) (arch-review 08 §1.5)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
Joseph Doherty 8b775f4ae1 feat(options): eager startup validation for site-pipeline options (SiteRuntime, S&F, SiteEventLog) (arch-review 08 §1.5)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
Joseph Doherty af2cfde484 feat(options): eager startup validation for Communication + DataConnectionLayer options (arch-review 08 §1.5)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-10 06:53:30 -04:00
66 changed files with 1864 additions and 461 deletions
+1
View File
@@ -51,6 +51,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
- Each component document follows a consistent structure: Purpose, Location, Responsibilities, detailed design sections, Dependencies, and Interactions.
- The README.md component table must stay in sync with actual component documents. When a component is added, removed, or renamed, update the table.
- Cross-component references in Dependencies and Interactions sections must be kept accurate across all documents. When a component's role changes, update references in all affected documents.
- Exception: DelmiaNotifier (#27) is an external client tool, not a cluster component; its spec is the project README (`src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`) plus `docs/plans/2026-06-26-delmia-recipe-notifier-design.md` — there is intentionally no `Component-DelmiaNotifier.md`.
## Refinement Process
+3
View File
@@ -49,6 +49,9 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.Playwright" Version="1.58.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<!-- Pinned to the version Akka.NET already pulls transitively (no version-bump ride-along);
used by Commons.Tests contract-lock tests that exercise the Akka default JSON serializer. -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
+1 -1
View File
@@ -108,7 +108,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
### Component Reference Documentation
The design documents above are the **specs** (what each component does and why). For developer **reference** docs that describe how the shipped code works — with real code examples, drawn from `src/` and written to the [Style Guide](StyleGuide.md) — see [docs/components/](docs/components/). One doc per component (plus the shared TreeView), indexed in [docs/components/README.md](docs/components/README.md).
The design documents above are the **specs** (what each component does and why). For developer **reference** docs that describe how the shipped code works — with real code examples, drawn from `src/` and written to the [Style Guide](StyleGuide.md) — see [docs/components/](docs/components/). Reference docs exist for 24 of the 27 components (plus the shared TreeView); ScriptAnalysis (#25), KpiHistory (#26), and DelmiaNotifier (#27) are pending — tracked in [docs/plans/2026-07-08-deferred-work-register.md](docs/plans/2026-07-08-deferred-work-register.md). Indexed in [docs/components/README.md](docs/components/README.md).
### Reference Documentation
+4 -2
View File
@@ -17,7 +17,7 @@ Every plan follows the TDD bite-sized-task format and ships a co-located `.tasks
| [PLAN-05](PLAN-05-templates-deployment-transport.md) | Templates, Deployment & Transport | 25 | 25 | ✅ Complete (all 25 tasks; merged to `main` `ec23db35` via fast-forward; worktree removed, branch deleted) | 33 line-items: 27 fixed, 6 deferred with rationale |
| [PLAN-06](PLAN-06-edge-integrations.md) | Edge Integrations | 24 | 24 | ✅ Complete (all 24 tasks; merged to `main` `3e964ac2` via fast-forward; worktree removed, branch deleted) | 24 findings: 21 fixed, 3 deferred/won't-fix |
| [PLAN-07](PLAN-07-ui-management-security.md) | UI, Management & Security | 33 | 32 | ✅ Complete (all 32 active tasks; T33 `skipped-duplicate` — CLI.Tests already in slnx via PLAN-08 T1; merged to `main` via fast-forward; worktree removed, branch deleted) | 24 items: 20 fixed, 3 partial (deferrals documented), 1 won't-fix |
| [PLAN-08](PLAN-08-conventions-tests.md) | Conventions & Tests | 11 | 1 | 🟨 In progress (P0: T1) | 13 covered, 4 owned by other plans, ~20 triaged into deferred-work register |
| [PLAN-08](PLAN-08-conventions-tests.md) | Conventions & Tests | 11 | 11 | ✅ Complete (all 11 tasks; T1 in P0, T2–T11 in Wave 6; merged to `main` via fast-forward; worktrees removed, branches deleted) | 13 covered, 4 owned by other plans, ~20 triaged into deferred-work register |
Status values: ⬜ Not started · 🟨 In progress · ✅ Complete · ⏸ Blocked
@@ -37,9 +37,11 @@ Status values: ⬜ Not started · 🟨 In progress · ✅ Complete · ⏸ Blocke
**Wave 5 — PLAN-07 COMPLETE (2026-07-10):** all 32 active tasks (T4–T32; T1–T3 landed in P0; **T33 `skipped-duplicate`** — CLI.Tests already in the slnx via PLAN-08 T1) delivered on worktree branch `plan07` and **merged to `main` via fast-forward** (main was still at PLAN-06's `4fb754f9`, zero divergence; all worktrees removed, branch deleted). UI/Management/Security hardening across six report-and-wait waves of file-disjoint parallel subagents against the dominant **ManagementActor single-writer lane** (15 tasks serialized in one integration worktree; the file-disjoint remainder ran in dedicated per-task worktrees, cherry-picked in with a per-wave combined build + impacted-suite barrier): **C1** auth-disabled artifact killed + `DisableLoginGuard` fail-fast (T1–T3, P0); **C2** `DeployToSiteAsync` single-site deploy + fleet-wide site-scope guard (T4/T5); **C3** connection/external-system/DB-connection secret elision via `ConfigSecretScrubber` + sentinel-preserving update (T6–T9); **UA1/C6/C5** any-of role gates + `ListSecuredWrites` gating + area-gate reconciliation (Designer|Deployer) + role-casing canonicalization (T10–T12); **UA2** the frozen `GetRequiredRoles` authorization matrix over all **138** registered commands + dispatch-coverage guard (T13/T24); **S2/P3** real server-side secured-write TTL + expiry-at-approve/reject + opportunistic list sweep + list paging + UI history pager & submission-age dialog (T14–T18); **S1** 5-min transport/deploy Ask timeout + shipped config (T19/T20); **C4** additive `SiteIdentifier` on browse/verify/cert commands + actor dispatch (Designer browse/verify, Admin cert-trust) + dispatch-coverage + CLI parity (T21–T25); **P1/UA5** `LoginThrottle` per-username+IP LDAP-bind lockout wired into every bind surface (T26/T27); **P2** additive Skip/Take paging on template/instance lists (T28); **S3** `PollGate` reentrancy guard on Health/AlarmSummary timers (T29); **S4** transport single-flight + export base64-copy elimination (T30); **S5/C7/C8/P4/UA6** debug-stream fault logging + event-log-filter doc fix + AlarmSummary memoization/a11y + the stale CLAUDE.md `SourceNode` note corrected (C7/C8 both verified true — T31/T32). Two message-contract additions (`AuditKind.SecuredWriteExpire`, secured-write list paging params) landed additively; no EF migrations needed. **Terminal verification:** full slnx build 0 warn/0 err; 28 of 30 test assemblies green (ManagementService 448 incl. the frozen matrix + dispatch-coverage, CentralUI 898, CLI 359, Security 175, Commons 609, Communication 263, Transport 138, ConfigDB 341, SiteRuntime 494, Host 260, + all others), after fixing one stale enum-count lock-in test (`AuditEnumTests` 15→16 for T15's additive `SecuredWriteExpire` — the per-task filtered runs never hit that Commons guard, so the full sweep caught it, exactly as in PLAN-04). Only residue: the same environmental 8 `CentralUI.PlaywrightTests` Deployment E2E failures (`SQLite Error 14` on `instance deploy` — needs a live docker cluster; PLAN-07 touched zero files in that project's deployment-DB path). Initiative total: **181 of 192** (sum of the Done column; PLAN-07's max is 32/33 with T33 a documented skip).
**Wave 6 — PLAN-08 COMPLETE (2026-07-10): INITIATIVE COMPLETE.** All 10 remaining tasks (T2–T11; T1 landed in P0) delivered on integration branch `plan08` and **merged to `main` via fast-forward** (main was still at PLAN-07's `187fbd7c`, zero divergence; all worktrees removed, branches deleted). Conventions/tests/underdeveloped-areas hardening across **two** report-and-wait waves of file-disjoint parallel subagents — the initiative's single highest-parallelism slice (8 concurrent implementers in Wave 1): **§1.5 eager options validation** — new `OptionsValidatorBase<T>` validators wired via `.ValidateOnStart()` + `TryAddEnumerable` across **12 components** in four tasks: Communication + DataConnectionLayer (T2, + 3 extra TimeSpan fields beyond the plan; string-only global-options classes correctly skipped), the site pipeline SiteRuntime/StoreAndForward/SiteEventLog bound in Host (T3), edge/management InboundAPI/ESG/Notification/ManagementService (T4, correctly leaving `SecuredWritePendingTtl`≤0 and empty `ApiKeyPepper` valid as legitimate disable/dev defaults), central Transport/SiteCallAudit/DeploymentManager (T5, incl. the security-sensitive `Pbkdf2Iterations >= 100_000` floor; validating base not `Resolved*` properties); **§1.3/#23** excised the vestigial site-side notification-list surface — deleted `SiteNotificationRepository`, its DI registration, and the dead `StoreNotificationListAsync`/`StoreSmtpConfigurationAsync` write paths, keeping `PurgeCentralOnlyNotificationConfigAsync` as the security cleanup (T6, with a `Site_NotificationRepository_IsNotRegistered` design-invariant lock replacing the old registration test); **§2.3** the first real perf-envelope test — site-wide stream throughput (100k events, ≥10k/s + ≥90% delivery assertions — T7) plus a skipped failover-timing harness documenting the 25s-envelope protocol pending PLAN-01's two-node rig (T8); **§1.8** contract-lock tests for the 7 highest-traffic ClusterClient records enforcing additive-only evolution under version skew (T9, which surfaced a real wire-behavior finding: Newtonsoft ignores C# ctor optional-param defaults, so a missing trailing field deserializes to `default(T)` not the C# default — the test now locks that reality); **§1.1/§1.3/§1.4/§4** the docs-drift batch — Commons `Observability/` folder, the site-repo `NotSupportedException` exception, the `..\`→`../` csproj-separator fix, the scoped "24 of 27 reference docs" README claim, and the DelmiaNotifier convention-exception bullet in CLAUDE.md (T10); and **§3** the deferred-work register triaging the 23-item inventory into fix-now (plan-owned) vs deferred-with-rationale+revisit-trigger (T11). No EF migrations, no wire-contract changes, no new runtime components — all convention-hardening around existing seams. **Terminal verification:** full slnx build 0 warn/0 err; the entire `dotnet test` sweep green across all 30 test assemblies — now including **CLI.Tests (359) at the solution level** for the first time (T1's slnx addition) — except two documented non-PLAN-08 residues: (1) a **pre-existing** `Communication.Tests` gRPC observable-gauge timing flake (`SiteStreamGrpcServerTests.SiteConnectionUpGauge_*`, proven to flake 3/6 on base `main` `187fbd7c`; PLAN-08 touched zero `Grpc/` files), and (2) the same environmental 8–9 `CentralUI.PlaywrightTests` Deployment E2E failures (`SQLite Error 14` on `instance deploy` — needs a live docker cluster). Initiative total: **191 of 192** (sum of the Done column) — the single remaining gap is PLAN-07 T33, a documented `skipped-duplicate` (CLI.Tests slnx addition, delivered instead by PLAN-08 T1). **All 8 archreview plans complete.**
## Parallelization Map — Incomplete Tasks (2026-07-09)
Covers the plans with open tasks: PLAN-08 (10). PLAN-01/02/03/04/05/06/07 are complete — nothing to parallelize there (PLAN-05 finished 2026-07-10; PLAN-06 finished 2026-07-10; PLAN-07 finished 2026-07-10; the section below is retained for its cross-plan mutex analysis, which still governs PLAN-08 and documents how PLAN-07's ManagementActor lane was run). **"Ready"** = every `blockedBy` entry is already `completed` in the plan's `.tasks.json`, so the task can start now. Ready sets are recomputed below from the live manifests; re-derive after each merge.
Covers the plans with open tasks: **none — all 8 plans complete as of 2026-07-10 (Wave 6, PLAN-08).** PLAN-01/02/03/04/05/06/07/08 are complete; the section below is retained for its cross-plan mutex analysis and the record of how PLAN-07's ManagementActor lane and PLAN-08's highest-parallelism validator slice were run. **"Ready"** = every `blockedBy` entry is already `completed` in the plan's `.tasks.json`, so the task can start now. Ready sets are recomputed below from the live manifests; re-derive after each merge.
### Cross-plan serialization mutexes (a task holding one must be the ONLY task touching that surface, anywhere)
@@ -10,63 +10,63 @@
{
"id": 2,
"subject": "Task 2: Options validation — Communication + DataConnectionLayer",
"status": "pending",
"status": "completed",
"blockedBy": []
},
{
"id": 3,
"subject": "Task 3: Options validation — site pipeline (SiteRuntime, StoreAndForward, SiteEventLogging)",
"status": "pending",
"status": "completed",
"blockedBy": []
},
{
"id": 4,
"subject": "Task 4: Options validation — edge + management (InboundAPI, ExternalSystemGateway, NotificationService, ManagementService)",
"status": "pending",
"status": "completed",
"blockedBy": []
},
{
"id": 5,
"subject": "Task 5: Options validation — central components (Transport, SiteCallAudit, DeploymentManager)",
"status": "pending",
"status": "completed",
"blockedBy": []
},
{
"id": 6,
"subject": "Task 6: Remove the vestigial site-side notification-list surface",
"status": "pending",
"status": "completed",
"blockedBy": [3]
},
{
"id": 7,
"subject": "Task 7: PerformanceTests — site-stream throughput test",
"status": "pending",
"status": "completed",
"blockedBy": []
},
{
"id": 8,
"subject": "Task 8: PerformanceTests — failover-timing benchmark harness placeholder",
"status": "pending",
"status": "completed",
"blockedBy": []
},
{
"id": 9,
"subject": "Task 9: Contract-lock tests for top ClusterClient message records",
"status": "pending",
"status": "completed",
"blockedBy": []
},
{
"id": 10,
"subject": "Task 10: Docs-code drift + conventions cleanup batch (Low findings, consolidated)",
"status": "pending",
"status": "completed",
"blockedBy": [1, 11]
},
{
"id": 11,
"subject": "Task 11: Deferred-work register — triage the 23-item inventory into a tracked list",
"status": "pending",
"status": "completed",
"blockedBy": []
}
],
"lastUpdated": "2026-07-08"
"lastUpdated": "2026-07-10"
}
+4
View File
@@ -5,6 +5,10 @@ code in `src/` actually works — with real code examples. These complement the
[design specs](../requirements/) in `docs/requirements/`: the specs say what a
component should do and why; these docs say how the code does it.
Reference docs exist for 24 of the 27 components (plus the shared TreeView, listed below).
ScriptAnalysis (#25), KpiHistory (#26), and DelmiaNotifier (#27) are pending — tracked in
[../plans/2026-07-08-deferred-work-register.md](../plans/2026-07-08-deferred-work-register.md).
| # | Component | Description |
|---|-----------|-------------|
| 1 | [Template Engine](TemplateEngine.md) | Template modeling, inheritance, composition, flattening, diffs, and semantic validation as implemented by the engine's services. |
+1 -1
View File
@@ -12,7 +12,7 @@ The component code lives in `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/`:
- `Scripts/` — `ScriptCompilationService`, `ScriptExecutionScheduler`, `SharedScriptLibrary`, `ScriptRuntimeContext`, `ScopeAccessors`, `TriggerExpressionGlobals`.
- `Streaming/` — `SiteStreamManager` (the site-wide Akka broadcast stream).
- `Persistence/` — `SiteStorageService` (raw SQLite via `Microsoft.Data.Sqlite`), `SiteStorageInitializer`.
- `Repositories/` — `SiteExternalSystemRepository`, `SiteNotificationRepository`.
- `Repositories/` — `SiteExternalSystemRepository`. (The `SiteNotificationRepository` variant was removed 2026-07-10, arch-review 08 §1.3/#23, because notification config is central-only and never lives on a site.)
- `Tracking/` — `OperationTrackingStore`, `OperationTrackingOptions`.
`ServiceCollectionExtensions.AddSiteRuntime(connectionString)` registers all singletons; the `Host` calls it and wires the `DeploymentManagerActor` cluster singleton separately via `AkkaHostedService`.
@@ -0,0 +1,44 @@
# Deferred-Work Register (established 2026-07-08, from architecture review 08)
Single tracked list of consciously-deferred work. Rules: every deferral gets a row
(rationale + revisit trigger); fix-now items reference the archreview plan that owns
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 2–5 |
| 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 |
## Deferred (with rationale + revisit trigger)
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
|---|------|-------------|------------------------|-----------------|
| 7 | SecuredWrite audit rows leave SourceNode NULL | CLAUDE.md Security | Logged follow-up; low-volume channel | First per-node forensic query against secured writes |
| 8 | Hash-chain tamper evidence (T1); CLI verify-chain is a no-op stub | audit-log roadmap :12 | v1.x by locked decision; append-only DB roles are the control | Compliance requirement for cryptographic tamper evidence |
| 9 | Parquet audit archival (T2); endpoint returns 501 | AuditEndpoints.cs:204 | v1.x; 501 + CLI messaging are honest | AuditLog partition volume nears retention ceiling |
| 10 | Aggregated live alarm stream for Alarm Summary | m7 design :252 | Snapshot fan-out acceptable at current instance counts | Alarm Summary latency complaints or >~50 instances/site |
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
| 12 | Native-alarm-source-override CSV import | m7 follow-ups | Parity gap only; attribute CSV shipped | First bulk native-alarm rollout request |
| 13 | WaitForAttribute quality-gated mode | ScriptRuntimeContext.cs:405 | Planned enhancement per spec §4.2 | First script needing Good-only waits |
| 14 | WaitForAttribute in Test-Run sandbox | waitfor-deferred :222 | Test-Run parity gap; production unaffected | Author feedback on Test-Run fidelity |
| 15 | BrowseNext final-page signal not surfaced | BrowseCommands.cs:34 | One wasted round-trip | UX complaint on browse paging |
| 16 | StubOpcUaClient throws on browse | m7 design :245 | Limits offline UI coverage only | Next browse/search UI regression |
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
| 20 | Deployment EXPIRED-row purge | DeploymentManagerRepository.cs:310 | Read path already compensates | EXPIRED rows visible in ops queries |
| 21 | SiteAuditBacklogReporter threshold consolidation | SiteAuditBacklogReporter.cs:28 | Config-shape cleanup only | Next SqliteAuditWriterOptions change |
| 22 | KPI history hourly rollups | Component-KpiHistory.md | YAGNI; 90-day retention bounds table | KpiSample query latency on dashboards |
## New deferrals from review 08 (this plan)
| Item | Rationale | Revisit trigger |
|------|-----------|-----------------|
| 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 |
+4
View File
@@ -291,6 +291,10 @@ ZB.MOM.WW.ScadaBridge.Commons/
│ │ # SiteCallRelayMessages
│ └── Management/ # HTTP/ClusterClient management commands + registry,
│ # including TransportCommands (Export/Preview/Import bundle)
├── Observability/ # ScadaBridgeTelemetry — shared Meter ("ZB.MOM.WW.ScadaBridge") +
│ # counters/observable-gauges (deployments applied, inbound-API
│ # requests/abandoned execs, S&F replication failures, site
│ # connections up, S&F queue depth) with emit helpers
├── Serialization/ # OpcUaEndpointConfigSerializer (typed↔legacy JSON)
└── Validators/ # OpcUaEndpointConfigValidator
```
@@ -167,6 +167,8 @@ Repository interfaces are defined in **Commons** alongside the POCO entity class
Each implementation class uses the DbContext internally and works with the POCO entity classes from Commons. Consuming components depend only on Commons (for interfaces and entities) — they never reference this component or EF Core directly. The DI container in the Host wires the implementations to the interfaces.
Site-side exception: read-only SQLite-backed implementations of shared repository interfaces live in `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/` (currently `SiteExternalSystemRepository`) — same Commons interface, site-local store, writes throw `NotSupportedException` because site artifacts are managed exclusively by deployment from Central.
#### Native Alarm Source Repository Methods
`ITemplateEngineRepository` (implemented by `TemplateEngineRepository`) gains CRUD for both native alarm source entities, mirroring the existing alarm-override methods one-for-one:
@@ -0,0 +1,69 @@
using ZB.MOM.WW.Configuration;
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
/// 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
/// far from the offending config key.
/// </summary>
public sealed class CommunicationOptionsValidator : OptionsValidatorBase<CommunicationOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, CommunicationOptions options)
{
builder.RequireThat(options.DeploymentTimeout > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.ArtifactDeploymentTimeout > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.IntegrationTimeout > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.HealthReportTimeout > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.GrpcKeepAlivePingDelay > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.GrpcMaxStreamLifetime > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.ApplicationHeartbeatInterval > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.PendingDeploymentTtl > TimeSpan.Zero,
$"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}).");
builder.RequireThat(options.GrpcMaxConcurrentStreams > 0,
$"Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams}).");
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication;
@@ -11,7 +13,10 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddCommunication(this IServiceCollection services)
{
services.AddOptions<CommunicationOptions>()
.BindConfiguration("Communication");
.BindConfiguration("Communication")
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<CommunicationOptions>, CommunicationOptionsValidator>());
services.AddSingleton<CommunicationService>();
services.AddSingleton<SiteStreamGrpcClientFactory>();
@@ -24,6 +24,7 @@
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Net.Client" />
<PackageReference Include="Grpc.Tools" PrivateAssets="All" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,39 @@
using ZB.MOM.WW.Configuration;
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
/// 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
/// otherwise produce an opaque failure far from the offending config key.
/// </summary>
public sealed class DataConnectionOptionsValidator : OptionsValidatorBase<DataConnectionOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, DataConnectionOptions options)
{
builder.RequireThat(options.ReconnectInterval > TimeSpan.Zero,
$"DataConnectionLayer: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}).");
builder.RequireThat(options.WriteTimeout > TimeSpan.Zero,
$"DataConnectionLayer: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}).");
builder.RequireThat(options.StableConnectionThreshold > TimeSpan.Zero,
$"DataConnectionLayer: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}).");
builder.RequireThat(options.SeedReadMaxAttempts > 0,
$"DataConnectionLayer:SeedReadMaxAttempts must be positive (was {options.SeedReadMaxAttempts}).");
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
@@ -13,8 +15,14 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddDataConnectionLayer(this IServiceCollection services)
{
services.AddOptions<DataConnectionOptions>()
.BindConfiguration("DataConnectionLayer");
.BindConfiguration("DataConnectionLayer")
.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.
services.AddOptions<OpcUaGlobalOptions>()
.BindConfiguration("OpcUa");
@@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Validates <see cref="DeploymentManagerOptions"/> at startup. Each timeout is
/// applied as a linked-CTS deadline around a lifecycle command, per-site artifact
/// deployment, or operation-lock acquisition; a zero/negative value makes the
/// deadline fire immediately (or the <see cref="System.Threading.CancellationTokenSource"/>
/// constructor throw), breaking deployments with an opaque failure. Registered
/// with <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:DeploymentManager</c>
/// section fails fast at boot with a clear, key-naming message.
/// </summary>
public sealed class DeploymentManagerOptionsValidator : OptionsValidatorBase<DeploymentManagerOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, DeploymentManagerOptions options)
{
builder.RequireThat(options.LifecycleCommandTimeout > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:LifecycleCommandTimeout must be a positive duration " +
$"(was {options.LifecycleCommandTimeout}); it bounds a lifecycle command round-trip.");
builder.RequireThat(options.ArtifactDeploymentTimeoutPerSite > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:ArtifactDeploymentTimeoutPerSite must be a positive duration " +
$"(was {options.ArtifactDeploymentTimeoutPerSite}); it bounds per-site artifact deployment.");
builder.RequireThat(options.OperationLockTimeout > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:OperationLockTimeout must be a positive duration " +
$"(was {options.OperationLockTimeout}); it bounds acquiring an instance operation lock.");
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -27,8 +29,14 @@ public static class ServiceCollectionExtensions
{
// Ensure the options class is always resolvable.
// The Host binds OptionsSection to appsettings.json; absent that binding
// the declared option-class defaults apply.
services.AddOptions<DeploymentManagerOptions>();
// the declared option-class defaults apply. Eager startup validation
// (arch-review 08 §1.5): ValidateOnStart makes a zero/negative timeout —
// which would fire a linked-CTS deadline immediately (or throw in the
// CancellationTokenSource ctor) — fail fast at boot with a clear,
// key-naming message. Applies even though defaults are code-configured.
services.AddOptions<DeploymentManagerOptions>().ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<DeploymentManagerOptions>, DeploymentManagerOptionsValidator>());
services.AddSingleton<OperationLockManager>();
// Push-based deployment-status notification. Registered
@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,33 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
/// <summary>
/// Validates <see cref="ExternalSystemGatewayOptions"/> at startup.
/// <see cref="ExternalSystemGatewayOptions.DefaultHttpTimeout"/> bounds each outbound call,
/// <see cref="ExternalSystemGatewayOptions.MaxConcurrentConnectionsPerSystem"/> is fed into
/// <c>SocketsHttpHandler.MaxConnectionsPerServer</c> (a non-positive value throws in the handler
/// constructor), and <see cref="ExternalSystemGatewayOptions.MaxResponseBodyBytes"/> caps buffered
/// response bodies. Registered with <c>ValidateOnStart()</c> so a bad
/// <c>ScadaBridge:ExternalSystemGateway</c> section fails fast at boot with a clear, key-naming
/// message rather than crashing the HTTP client factory or silently disabling the gateway.
/// </summary>
public sealed class ExternalSystemGatewayOptionsValidator : OptionsValidatorBase<ExternalSystemGatewayOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, ExternalSystemGatewayOptions options)
{
builder.RequireThat(options.DefaultHttpTimeout > TimeSpan.Zero,
$"ScadaBridge:ExternalSystemGateway:DefaultHttpTimeout must be a positive duration " +
$"(was {options.DefaultHttpTimeout}); it bounds each outbound external-system HTTP call.");
builder.RequireThat(options.MaxConcurrentConnectionsPerSystem > 0,
$"ScadaBridge:ExternalSystemGateway:MaxConcurrentConnectionsPerSystem must be greater than zero " +
$"(was {options.MaxConcurrentConnectionsPerSystem}); it is fed into " +
"SocketsHttpHandler.MaxConnectionsPerServer, which rejects non-positive values.");
builder.RequireThat(options.MaxResponseBodyBytes > 0,
$"ScadaBridge:ExternalSystemGateway:MaxResponseBodyBytes must be greater than zero " +
$"(was {options.MaxResponseBodyBytes}); it caps the outbound response body buffered into memory.");
}
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -21,7 +22,11 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddExternalSystemGateway(this IServiceCollection services)
{
services.AddOptions<ExternalSystemGatewayOptions>()
.BindConfiguration("ScadaBridge:ExternalSystemGateway");
.BindConfiguration("ScadaBridge:ExternalSystemGateway")
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<ExternalSystemGatewayOptions>,
ExternalSystemGatewayOptionsValidator>());
services.AddHttpClient();
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
+7 -1
View File
@@ -1,3 +1,5 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Health;
@@ -291,7 +293,11 @@ try
// Options binding
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaBridge:Security"));
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaBridge:InboundApi"));
builder.Services.AddOptions<InboundApiOptions>()
.Bind(builder.Configuration.GetSection("ScadaBridge:InboundApi"))
.ValidateOnStart();
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<InboundApiOptions>, InboundApiOptionsValidator>());
builder.Services.Configure<DeploymentManagerOptions>(
builder.Configuration.GetSection(ZB.MOM.WW.ScadaBridge.DeploymentManager.ServiceCollectionExtensions.OptionsSection));
@@ -1,3 +1,5 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Communication;
@@ -121,10 +123,33 @@ public static class SiteServiceRegistration
// Options binding
BindSharedOptions(services, config);
services.Configure<SiteRuntimeOptions>(config.GetSection("ScadaBridge:SiteRuntime"));
// Bind + eagerly validate the site-pipeline options. The validators live with their
// owner component projects (SiteRuntime / StoreAndForward / SiteEventLogging) per the
// "options classes owned by component projects" convention, but these three sections
// are bound HERE in the Host (not in a component SCE), so both the ValidateOnStart()
// chain and the TryAddEnumerable validator registration happen here. A bad section
// fails fast at host build with a clear, key-naming message instead of crashing an
// actor / hosted service later with an opaque ArgumentOutOfRangeException.
services.AddOptions<SiteRuntimeOptions>()
.Bind(config.GetSection("ScadaBridge:SiteRuntime"))
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SiteRuntimeOptions>, SiteRuntimeOptionsValidator>());
services.Configure<DataConnectionOptions>(config.GetSection("ScadaBridge:DataConnection"));
services.Configure<StoreAndForwardOptions>(config.GetSection("ScadaBridge:StoreAndForward"));
services.Configure<SiteEventLogOptions>(config.GetSection("ScadaBridge:SiteEventLog"));
services.AddOptions<StoreAndForwardOptions>()
.Bind(config.GetSection("ScadaBridge:StoreAndForward"))
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<StoreAndForwardOptions>, StoreAndForwardOptionsValidator>());
services.AddOptions<SiteEventLogOptions>()
.Bind(config.GetSection("ScadaBridge:SiteEventLog"))
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SiteEventLogOptions>, SiteEventLogOptionsValidator>());
}
/// <summary>Binds shared options sections (Node, Cluster, Database, Communication, etc.) used by both site and central roles.</summary>
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Validates <see cref="InboundApiOptions"/> at startup. <see cref="InboundApiOptions.DefaultMethodTimeout"/>
/// bounds each inbound-API method execution and <see cref="InboundApiOptions.MaxRequestBodyBytes"/> caps the
/// accepted <c>POST /api/{methodName}</c> body before it is buffered — a zero/negative timeout would cancel
/// every request immediately (or never), and a non-positive body cap would reject every request with HTTP 413.
/// Registered with <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:InboundApi</c> section fails fast at boot
/// with a clear, key-naming message rather than degrading the endpoint at runtime.
/// <para>
/// <see cref="InboundApiOptions.ApiKeyPepper"/> is intentionally NOT validated here: an empty pepper is a
/// legitimate dev default, and production pepper strength is owned by the shared auth-key library / startup
/// validator, not by this options validator.
/// </para>
/// </summary>
public sealed class InboundApiOptionsValidator : OptionsValidatorBase<InboundApiOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, InboundApiOptions options)
{
builder.RequireThat(options.DefaultMethodTimeout > TimeSpan.Zero,
$"ScadaBridge:InboundApi:DefaultMethodTimeout must be a positive duration " +
$"(was {options.DefaultMethodTimeout}); it bounds each inbound-API method execution.");
builder.RequireThat(options.MaxRequestBodyBytes > 0,
$"ScadaBridge:InboundApi:MaxRequestBodyBytes must be greater than zero " +
$"(was {options.MaxRequestBodyBytes}); it caps the accepted request body before buffering.");
}
}
@@ -32,6 +32,7 @@
AddZbApiKeyAuth (DI helper) lives in this package and brings the
Abstractions contracts (IApiKeyVerifier / ApiKeyOptions) transitively. -->
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
/// <summary>
/// Validates <see cref="ManagementServiceOptions"/> at startup. Both
/// <see cref="ManagementServiceOptions.CommandTimeout"/> and
/// <see cref="ManagementServiceOptions.LongRunningCommandTimeout"/> are used as Ask timeouts against
/// the ManagementActor; a zero/negative value makes every management command fail immediately (or Ask
/// forever). Registered with <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:ManagementService</c>
/// section fails fast at boot with a clear, key-naming message.
/// <para>
/// <see cref="ManagementServiceOptions.SecuredWritePendingTtl"/> is intentionally NOT required positive:
/// a non-positive value legitimately disables server-side secured-write expiry.
/// </para>
/// </summary>
public sealed class ManagementServiceOptionsValidator : OptionsValidatorBase<ManagementServiceOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, ManagementServiceOptions options)
{
builder.RequireThat(options.CommandTimeout > TimeSpan.Zero,
$"ScadaBridge:ManagementService:CommandTimeout must be a positive duration " +
$"(was {options.CommandTimeout}); it is the Ask timeout for management commands.");
builder.RequireThat(options.LongRunningCommandTimeout > TimeSpan.Zero,
$"ScadaBridge:ManagementService:LongRunningCommandTimeout must be a positive duration " +
$"(was {options.LongRunningCommandTimeout}); it is the Ask timeout for transport/deploy commands.");
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
@@ -13,7 +15,11 @@ public static class ServiceCollectionExtensions
{
services.AddSingleton<ManagementActorHolder>();
services.AddOptions<ManagementServiceOptions>()
.BindConfiguration("ScadaBridge:ManagementService");
.BindConfiguration("ScadaBridge:ManagementService")
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<ManagementServiceOptions>,
ManagementServiceOptionsValidator>());
return services;
}
}
@@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="Akka" />
<PackageReference Include="Akka.Cluster.Tools" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
@@ -22,6 +23,6 @@
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.ScadaBridge.InboundAPI\ZB.MOM.WW.ScadaBridge.InboundAPI.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// Validates <see cref="NotificationOptions"/> at startup. The values are the SMTP fallback used
/// by the central Email delivery adapter when the deployed <c>SmtpConfiguration</c> leaves the
/// corresponding field unset: <see cref="NotificationOptions.ConnectionTimeoutSeconds"/> becomes an
/// SMTP connect timeout and <see cref="NotificationOptions.MaxConcurrentConnections"/> caps the
/// connection pool — a non-positive value there is not a usable fallback. Registered with
/// <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:Notification</c> section fails fast at boot with
/// a clear, key-naming message.
/// </summary>
public sealed class NotificationOptionsValidator : OptionsValidatorBase<NotificationOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, NotificationOptions options)
{
builder.RequireThat(options.ConnectionTimeoutSeconds > 0,
$"ScadaBridge:Notification:ConnectionTimeoutSeconds must be greater than zero " +
$"(was {options.ConnectionTimeoutSeconds}); it is the SMTP connect-timeout fallback.");
builder.RequireThat(options.MaxConcurrentConnections > 0,
$"ScadaBridge:Notification:MaxConcurrentConnections must be greater than zero " +
$"(was {options.MaxConcurrentConnections}); it caps the SMTP connection pool.");
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
@@ -18,7 +20,11 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddNotificationService(this IServiceCollection services)
{
services.AddOptions<NotificationOptions>()
.BindConfiguration("ScadaBridge:Notification");
.BindConfiguration("ScadaBridge:Notification")
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<NotificationOptions>,
NotificationOptionsValidator>());
services.AddHttpClient();
services.AddSingleton<OAuth2TokenService>();
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi;
@@ -37,8 +38,15 @@ public static class ServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
// Eager startup validation (arch-review 08 §1.5): ValidateOnStart makes a
// bad ScadaBridge:SiteCallAudit section — a zero/negative cadence that would
// spin an Akka scheduler, or a non-positive KPI/retention knob — fail fast
// at boot with a clear, key-naming message.
services.AddOptions<SiteCallAuditOptions>()
.BindConfiguration(OptionsSection);
.BindConfiguration(OptionsSection)
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SiteCallAuditOptions>, SiteCallAuditOptionsValidator>());
// KPI History: the central recorder enumerates every registered
// IKpiSampleSource each sampling pass; this one snapshots the SiteCalls
@@ -0,0 +1,48 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit;
/// <summary>
/// Validates <see cref="SiteCallAuditOptions"/> at startup. The durations feed
/// stuck-call detection, KPI windowing, the central→site relay Ask timeout, and
/// the two central-singleton scheduler cadences (reconciliation + purge); a
/// zero/negative value makes an Akka scheduler spin or a KPI window collapse.
/// Registered with <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:SiteCallAudit</c>
/// section fails fast at boot with a clear, key-naming message. Validates the
/// BASE properties — the <c>Resolved*</c> computed properties clamp bad cadence
/// values away and would hide the misconfiguration.
/// </summary>
public sealed class SiteCallAuditOptionsValidator : OptionsValidatorBase<SiteCallAuditOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SiteCallAuditOptions options)
{
builder.RequireThat(options.StuckAgeThreshold > TimeSpan.Zero,
$"ScadaBridge:SiteCallAudit:StuckAgeThreshold must be a positive duration " +
$"(was {options.StuckAgeThreshold}); it is the age past which a cached call is flagged stuck.");
builder.RequireThat(options.KpiInterval > TimeSpan.Zero,
$"ScadaBridge:SiteCallAudit:KpiInterval must be a positive duration " +
$"(was {options.KpiInterval}); it is the trailing window for the throughput KPIs.");
builder.RequireThat(options.RelayTimeout > TimeSpan.Zero,
$"ScadaBridge:SiteCallAudit:RelayTimeout must be a positive duration " +
$"(was {options.RelayTimeout}); it is the Ask timeout for the central→site Retry/Discard relay.");
builder.RequireThat(options.ReconciliationInterval > TimeSpan.Zero,
$"ScadaBridge:SiteCallAudit:ReconciliationInterval must be a positive duration " +
$"(was {options.ReconciliationInterval}); it is the period of the per-site reconciliation pull.");
builder.RequireThat(options.ReconciliationBatchSize > 0,
$"ScadaBridge:SiteCallAudit:ReconciliationBatchSize must be positive " +
$"(was {options.ReconciliationBatchSize}); it is the max rows requested per PullSiteCalls RPC.");
builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
$"ScadaBridge:SiteCallAudit:PurgeInterval must be a positive duration " +
$"(was {options.PurgeInterval}); it is the period of the daily terminal-row purge tick.");
builder.RequireThat(options.RetentionDays > 0,
$"ScadaBridge:SiteCallAudit:RetentionDays must be positive " +
$"(was {options.RetentionDays}); it is the retention window for terminal SiteCalls rows.");
}
}
@@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.Extensions.Options" />
<!-- BindConfiguration extension for the SiteCallAuditOptions binding. -->
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,56 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging;
/// <summary>
/// Validates <see cref="SiteEventLogOptions"/> at startup. Retention/purge values
/// drive the daily purge PeriodicTimer (a zero/negative period trips
/// <see cref="ArgumentOutOfRangeException"/>), the paging bounds gate query result
/// sizes, and the write-queue capacity sizes a bounded channel; a
/// <c>MaxQueryPageSize</c> below <c>QueryPageSize</c> would silently clamp the
/// default page below itself. Registered with <c>ValidateOnStart()</c> so a bad
/// <c>ScadaBridge:SiteEventLog</c> section fails fast at boot with a clear,
/// key-naming message.
/// </summary>
public sealed class SiteEventLogOptionsValidator : OptionsValidatorBase<SiteEventLogOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SiteEventLogOptions options)
{
builder.RequireThat(options.RetentionDays > 0,
$"ScadaBridge:SiteEventLog:RetentionDays must be greater than 0 " +
$"(was {options.RetentionDays}).");
builder.RequireThat(options.MaxStorageMb > 0,
$"ScadaBridge:SiteEventLog:MaxStorageMb must be greater than 0 " +
$"(was {options.MaxStorageMb}).");
builder.RequireThat(!string.IsNullOrWhiteSpace(options.DatabasePath),
"ScadaBridge:SiteEventLog:DatabasePath must be a non-empty path; " +
"it is the SQLite file backing the site event log.");
builder.RequireThat(options.QueryPageSize > 0,
$"ScadaBridge:SiteEventLog:QueryPageSize must be greater than 0 " +
$"(was {options.QueryPageSize}).");
builder.RequireThat(options.WriteQueueCapacity > 0,
$"ScadaBridge:SiteEventLog:WriteQueueCapacity must be greater than 0 " +
$"(was {options.WriteQueueCapacity}); it bounds the background write channel.");
builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
$"ScadaBridge:SiteEventLog:PurgeInterval must be a positive duration " +
$"(was {options.PurgeInterval}); it is the purge PeriodicTimer period.");
// The hard page-size ceiling clamps a caller-supplied PageSize; a ceiling
// below the default QueryPageSize would silently clamp the default below
// itself. Only meaningful when both are positive (owned above), so stay
// silent when either is non-positive rather than double-firing.
builder.RequireThat(
!(options.QueryPageSize > 0
&& options.MaxQueryPageSize > 0
&& options.MaxQueryPageSize < options.QueryPageSize),
$"ScadaBridge:SiteEventLog:MaxQueryPageSize ({options.MaxQueryPageSize}) " +
$"must be >= QueryPageSize ({options.QueryPageSize}): the ceiling clamps a caller's " +
"requested page size, so it cannot be smaller than the default page size.");
}
}
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -799,6 +799,12 @@ public class SiteStorageService
/// idempotent and is invoked on every artifact apply / deploy so existing exposure
/// is cleared, not just future writes. The tables themselves are retained (the
/// schema is harmless once empty); only their contents are removed.
///
/// The site-side write paths (<c>StoreNotificationListAsync</c>/<c>StoreSmtpConfigurationAsync</c>)
/// and the <c>SiteNotificationRepository</c> were removed 2026-07-10 (arch-review 08 §1.3/#23)
/// because notification config is central-only and must never be written to a site — this
/// purge is the sole remaining touchpoint, kept only to scrub DBs written by older builds;
/// do not reintroduce site-side notification writes.
/// </summary>
/// <returns>A task that completes when both tables have been emptied.</returns>
public async Task PurgeCentralOnlyNotificationConfigAsync()
@@ -813,80 +819,6 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Stores or updates a notification list.
/// </summary>
/// <param name="name">The name of the notification list.</param>
/// <param name="recipientEmails">List of recipient email addresses.</param>
/// <returns>A task that completes when the notification list has been stored or updated.</returns>
public async Task StoreNotificationListAsync(string name, IReadOnlyList<string> recipientEmails)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO notification_lists (name, recipient_emails, updated_at)
VALUES (@name, @emails, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
recipient_emails = excluded.recipient_emails,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@emails", System.Text.Json.JsonSerializer.Serialize(recipientEmails));
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
// ── SMTP Configuration CRUD ──
/// <summary>
/// Stores or updates an SMTP configuration.
/// </summary>
/// <param name="name">The name of the SMTP configuration.</param>
/// <param name="server">SMTP server hostname.</param>
/// <param name="port">SMTP server port.</param>
/// <param name="authMode">Authentication mode ('None', 'BasicAuth', 'OAuth2').</param>
/// <param name="fromAddress">Email address used as the sender.</param>
/// <param name="username">Username for authentication, if applicable.</param>
/// <param name="password">Password for authentication, if applicable.</param>
/// <param name="oauthConfig">OAuth2 configuration JSON, if applicable.</param>
/// <returns>A task that completes when the SMTP configuration has been stored or updated.</returns>
public async Task StoreSmtpConfigurationAsync(
string name, string server, int port, string authMode, string fromAddress,
string? username, string? password, string? oauthConfig)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
VALUES (@name, @server, @port, @authMode, @fromAddress, @username, @password, @oauthConfig, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
server = excluded.server,
port = excluded.port,
auth_mode = excluded.auth_mode,
from_address = excluded.from_address,
username = excluded.username,
password = excluded.password,
oauth_config = excluded.oauth_config,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@server", server);
command.Parameters.AddWithValue("@port", port);
command.Parameters.AddWithValue("@authMode", authMode);
command.Parameters.AddWithValue("@fromAddress", fromAddress);
command.Parameters.AddWithValue("@username", (object?)username ?? DBNull.Value);
command.Parameters.AddWithValue("@password", (object?)password ?? DBNull.Value);
command.Parameters.AddWithValue("@oauthConfig", (object?)oauthConfig ?? DBNull.Value);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
// ── Data Connection Definition CRUD ──
/// <summary>
@@ -1,300 +0,0 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
/// <summary>
/// Site-side read-only implementation of <see cref="INotificationRepository"/>
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
/// Write operations throw <see cref="NotSupportedException"/> because site-local
/// artifacts are managed exclusively through deployment from Central.
/// </summary>
public class SiteNotificationRepository : INotificationRepository
{
private readonly SiteStorageService _storage;
/// <summary>
/// Initializes a new instance of the <see cref="SiteNotificationRepository"/> class.
/// </summary>
/// <param name="storage">The site storage service for database access.</param>
public SiteNotificationRepository(SiteStorageService storage)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
}
// ── NotificationList (read) ──
/// <inheritdoc />
public async Task<NotificationList?> GetListByNameAsync(
string name, CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, recipient_emails
FROM notification_lists
WHERE name = @name";
command.Parameters.AddWithValue("@name", name);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return null;
return MapNotificationList(reader);
}
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(
CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = "SELECT name, recipient_emails FROM notification_lists";
var results = new List<NotificationList>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapNotificationList(reader));
}
return results;
}
/// <inheritdoc />
public async Task<NotificationList?> GetNotificationListByIdAsync(
int id, CancellationToken cancellationToken = default)
{
// The SQLite table is keyed by name, not by integer ID.
// Scan all rows and match on the synthetic ID derived from the name.
var all = await GetAllNotificationListsAsync(cancellationToken);
return all.FirstOrDefault(l => l.Id == id);
}
// ── NotificationRecipient (read) ──
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(
int notificationListId, CancellationToken cancellationToken = default)
{
// Find the parent list to get its name, then parse the recipient_emails JSON.
var list = await GetNotificationListByIdAsync(notificationListId, cancellationToken);
if (list is null)
return Array.Empty<NotificationRecipient>();
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT recipient_emails
FROM notification_lists
WHERE name = @name";
command.Parameters.AddWithValue("@name", list.Name);
var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<NotificationRecipient>();
return ParseRecipientEmails(json, notificationListId);
}
/// <inheritdoc />
public async Task<NotificationRecipient?> GetRecipientByIdAsync(
int id, CancellationToken cancellationToken = default)
{
// Scan all lists and their recipients to find the matching synthetic ID.
var lists = await GetAllNotificationListsAsync(cancellationToken);
foreach (var list in lists)
{
var recipients = await GetRecipientsByListIdAsync(list.Id, cancellationToken);
var match = recipients.FirstOrDefault(r => r.Id == id);
if (match is not null)
return match;
}
return null;
}
// ── SmtpConfiguration (read) ──
/// <inheritdoc />
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(
CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, server, port, auth_mode, from_address, username, password, oauth_config
FROM smtp_configurations";
var results = new List<SmtpConfiguration>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapSmtpConfiguration(reader));
}
return results;
}
/// <inheritdoc />
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(
int id, CancellationToken cancellationToken = default)
{
var all = await GetAllSmtpConfigurationsAsync(cancellationToken);
return all.FirstOrDefault(s => s.Id == id);
}
// ── Write operations (not supported on site) ──
/// <inheritdoc />
public Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
// ── SmsConfiguration (central-only — never deployed to or served from the site) ──
/// <inheritdoc />
public Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task AddSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteSmsConfigurationAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
// ── Private helpers ──
/// <summary>
/// Creates a new SQLite connection against the site database via
/// <see cref="SiteStorageService.CreateConnection"/> instead of
/// reaching into its private connection-string field via reflection.
/// </summary>
private SqliteConnection CreateConnection() => _storage.CreateConnection();
private static NotificationList MapNotificationList(SqliteDataReader reader)
{
var name = reader.GetString(0);
var list = new NotificationList(name)
{
Id = GenerateSyntheticId(name)
};
// Eagerly populate Recipients from the JSON column.
var emailsJson = reader.GetString(1);
var recipients = ParseRecipientEmails(emailsJson, list.Id);
foreach (var r in recipients)
{
list.Recipients.Add(r);
}
return list;
}
private static IReadOnlyList<NotificationRecipient> ParseRecipientEmails(
string json, int notificationListId)
{
try
{
var emails = JsonSerializer.Deserialize<List<string>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (emails is null)
return Array.Empty<NotificationRecipient>();
return emails.Select(email => new NotificationRecipient(
name: email,
emailAddress: email)
{
Id = GenerateSyntheticId($"{notificationListId}:{email}"),
NotificationListId = notificationListId
}).ToList();
}
catch (JsonException)
{
return Array.Empty<NotificationRecipient>();
}
}
private static SmtpConfiguration MapSmtpConfiguration(SqliteDataReader reader)
{
var name = reader.GetString(0);
return new SmtpConfiguration(
host: reader.GetString(1),
authType: reader.GetString(3),
fromAddress: reader.GetString(4))
{
Id = GenerateSyntheticId(name),
Port = reader.GetInt32(2),
Credentials = reader.IsDBNull(5) ? null : reader.GetString(5),
TlsMode = reader.IsDBNull(7) ? null : reader.GetString(7)
};
}
/// <summary>
/// Generates a stable positive integer ID from a string name.
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
/// which is randomized per process on .NET Core and would change every restart.
/// </summary>
private static int GenerateSyntheticId(string name) => SyntheticId.From(name);
}
@@ -62,7 +62,6 @@ public static class ServiceCollectionExtensions
// Site-local repository implementations backed by SQLite
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
services.AddScoped<INotificationRepository, SiteNotificationRepository>();
// Notify-and-fetch: typed HttpClient for fetching deployment configs from central.
services.AddHttpClient<IDeploymentConfigFetcher, HttpDeploymentConfigFetcher>()
@@ -0,0 +1,60 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime;
/// <summary>
/// Validates <see cref="SiteRuntimeOptions"/> at startup. These values size the
/// staggered Instance-Actor startup batches, the dedicated script-execution thread
/// pool, and the site-wide Akka stream's per-subscriber buffer; a zero or negative
/// count either creates a degenerate thread scheduler / stream stage or trips an
/// <see cref="ArgumentOutOfRangeException"/> deep inside actor construction with an
/// opaque message that never names the offending config key. Registered with
/// <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:SiteRuntime</c> section fails
/// fast at boot with a clear, key-naming message.
/// </summary>
public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase<SiteRuntimeOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SiteRuntimeOptions options)
{
builder.RequireThat(options.StartupBatchSize > 0,
$"ScadaBridge:SiteRuntime:StartupBatchSize must be greater than 0 " +
$"(was {options.StartupBatchSize}); it sizes each staggered Instance-Actor startup batch.");
builder.RequireThat(options.StartupBatchDelayMs >= 0,
$"ScadaBridge:SiteRuntime:StartupBatchDelayMs must be >= 0 " +
$"(was {options.StartupBatchDelayMs}); it is the inter-batch startup delay.");
builder.RequireThat(options.MaxScriptCallDepth > 0,
$"ScadaBridge:SiteRuntime:MaxScriptCallDepth must be greater than 0 " +
$"(was {options.MaxScriptCallDepth}); it bounds recursive CallScript/CallShared depth.");
builder.RequireThat(options.ScriptExecutionTimeoutSeconds > 0,
$"ScadaBridge:SiteRuntime:ScriptExecutionTimeoutSeconds must be greater than 0 " +
$"(was {options.ScriptExecutionTimeoutSeconds}).");
builder.RequireThat(options.StreamBufferSize > 0,
$"ScadaBridge:SiteRuntime:StreamBufferSize must be greater than 0 " +
$"(was {options.StreamBufferSize}); it is the per-subscriber site-stream buffer size.");
builder.RequireThat(options.ScriptExecutionThreadCount > 0,
$"ScadaBridge:SiteRuntime:ScriptExecutionThreadCount must be greater than 0 " +
$"(was {options.ScriptExecutionThreadCount}); it sizes the dedicated script-execution scheduler.");
builder.RequireThat(options.MirroredAlarmCapPerSource > 0,
$"ScadaBridge:SiteRuntime:MirroredAlarmCapPerSource must be greater than 0 " +
$"(was {options.MirroredAlarmCapPerSource}).");
builder.RequireThat(options.NativeAlarmRetryIntervalMs > 0,
$"ScadaBridge:SiteRuntime:NativeAlarmRetryIntervalMs must be greater than 0 " +
$"(was {options.NativeAlarmRetryIntervalMs}).");
builder.RequireThat(options.ConfigFetchTimeoutSeconds > 0,
$"ScadaBridge:SiteRuntime:ConfigFetchTimeoutSeconds must be greater than 0 " +
$"(was {options.ConfigFetchTimeoutSeconds}).");
builder.RequireThat(options.ConfigFetchRetryCount >= 0,
$"ScadaBridge:SiteRuntime:ConfigFetchRetryCount must be >= 0 " +
$"(was {options.ConfigFetchRetryCount}).");
}
}
@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,35 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward;
/// <summary>
/// Validates <see cref="StoreAndForwardOptions"/> at startup. The retry intervals
/// feed the background sweep timer (a zero/negative period trips
/// <see cref="ArgumentOutOfRangeException"/> in the timer constructor) and the
/// SQLite path is opened for the S&amp;F buffer; an empty path yields an opaque
/// connection failure at first enqueue. Registered with <c>ValidateOnStart()</c>
/// so a bad <c>ScadaBridge:StoreAndForward</c> section fails fast at boot with a
/// clear, key-naming message.
/// </summary>
public sealed class StoreAndForwardOptionsValidator : OptionsValidatorBase<StoreAndForwardOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, StoreAndForwardOptions options)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(options.SqliteDbPath),
"ScadaBridge:StoreAndForward:SqliteDbPath must be a non-empty path; " +
"it is the SQLite file backing the store-and-forward buffer.");
builder.RequireThat(options.DefaultRetryInterval > TimeSpan.Zero,
$"ScadaBridge:StoreAndForward:DefaultRetryInterval must be a positive duration " +
$"(was {options.DefaultRetryInterval}); it is the default per-message retry interval.");
builder.RequireThat(options.RetryTimerInterval > TimeSpan.Zero,
$"ScadaBridge:StoreAndForward:RetryTimerInterval must be a positive duration " +
$"(was {options.RetryTimerInterval}); it is the background retry-sweep timer period.");
builder.RequireThat(options.DefaultMaxRetries >= 0,
$"ScadaBridge:StoreAndForward:DefaultMaxRetries must be >= 0 " +
$"(was {options.DefaultMaxRetries}).");
}
}
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -22,7 +22,14 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddTransport(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection);
// Eager startup validation (arch-review 08 §1.5): ValidateOnStart makes a
// bad ScadaBridge:Transport section — a zero/negative cap, or (critically)
// a Pbkdf2Iterations below the security floor that would silently weaken
// bundle key derivation — fail fast at boot with a clear, key-naming
// message instead of surfacing deep inside the import pipeline.
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection).ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<TransportOptions>, TransportOptionsValidator>());
services.TryAddSingleton(TimeProvider.System);
// Pipeline building blocks: stateless services live as singletons; the
@@ -0,0 +1,67 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Transport;
/// <summary>
/// Validates <see cref="TransportOptions"/> at startup. These knobs gate bundle
/// size, the zip-bomb defences, unlock rate limiting, the schema-version stamp,
/// and — security-critically — the PBKDF2 iteration count used to derive the
/// bundle encryption key. A zero/negative value would either disable a cap,
/// crash the import pipeline, or (for <see cref="TransportOptions.Pbkdf2Iterations"/>)
/// silently weaken key derivation. Registered with <c>ValidateOnStart()</c> so a
/// bad <c>ScadaBridge:Transport</c> section fails fast at boot with a clear,
/// key-naming message.
/// </summary>
public sealed class TransportOptionsValidator : OptionsValidatorBase<TransportOptions>
{
/// <summary>
/// Minimum acceptable PBKDF2 iteration count. A config typo (e.g. 6_000 in
/// place of the 600_000 default) must not silently weaken bundle key
/// derivation, so anything below this floor is rejected at startup.
/// </summary>
private const int MinPbkdf2Iterations = 100_000;
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, TransportOptions options)
{
builder.RequireThat(options.BundleSessionTtlMinutes > 0,
$"ScadaBridge:Transport:BundleSessionTtlMinutes must be a positive number of minutes " +
$"(was {options.BundleSessionTtlMinutes}); it is the TTL for an in-progress import session.");
builder.RequireThat(options.MaxBundleSizeMb > 0,
$"ScadaBridge:Transport:MaxBundleSizeMb must be positive " +
$"(was {options.MaxBundleSizeMb}); it caps the accepted bundle size.");
builder.RequireThat(options.MaxBundleEntryDecompressedMb > 0,
$"ScadaBridge:Transport:MaxBundleEntryDecompressedMb must be positive " +
$"(was {options.MaxBundleEntryDecompressedMb}); it caps the decompressed size of any single zip entry.");
builder.RequireThat(options.MaxBundleEntryCount > 0,
$"ScadaBridge:Transport:MaxBundleEntryCount must be positive " +
$"(was {options.MaxBundleEntryCount}); it caps the number of entries inside a bundle zip.");
builder.RequireThat(options.MaxBundleEntryCompressionRatio > 0,
$"ScadaBridge:Transport:MaxBundleEntryCompressionRatio must be positive " +
$"(was {options.MaxBundleEntryCompressionRatio}); it caps the per-entry compression ratio.");
builder.RequireThat(options.MaxUnlockAttemptsPerSession > 0,
$"ScadaBridge:Transport:MaxUnlockAttemptsPerSession must be positive " +
$"(was {options.MaxUnlockAttemptsPerSession}); it caps failed passphrase attempts before a session locks.");
builder.RequireThat(options.MaxUnlockAttemptsPerIpPerHour > 0,
$"ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour must be positive " +
$"(was {options.MaxUnlockAttemptsPerIpPerHour}); it caps unlock attempts per IP address per hour.");
builder.RequireThat(options.Pbkdf2Iterations >= MinPbkdf2Iterations,
$"ScadaBridge:Transport:Pbkdf2Iterations must be at least {MinPbkdf2Iterations:N0} " +
$"(was {options.Pbkdf2Iterations}); a lower value silently weakens bundle key derivation.");
builder.RequireThat(options.SchemaVersionMajor > 0,
$"ScadaBridge:Transport:SchemaVersionMajor must be positive " +
$"(was {options.SchemaVersionMajor}); it is the major bundle-schema version this instance emits and accepts.");
builder.RequireThat(!string.IsNullOrWhiteSpace(options.SourceEnvironment),
"ScadaBridge:Transport:SourceEnvironment must be a non-empty name; it is stamped into " +
"BundleManifest.SourceEnvironment and the export filename.");
}
}
@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,195 @@
using Newtonsoft.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Contract-lock tests for the highest-traffic ClusterClient record contracts.
/// ClusterClient traffic uses Akka.NET's default Newtonsoft-JSON serializer and the
/// design supports system-wide artifact version skew across sites, so these records
/// must obey additive-only evolution:
/// (a) an old-shape payload (missing newer trailing optional fields) still
/// deserializes, with the missing fields taking their constructor defaults;
/// (b) an unknown field from a *newer* peer is ignored (never throws);
/// (c) a round-trip preserves the values that were sent.
/// If a change breaks one of these tests, it breaks rolling cross-cluster
/// compatibility — do NOT "fix the test"; make the change additive (append optional
/// trailing constructor parameters, never reorder/rename/remove/retype existing ones).
/// </summary>
public class ClusterClientContractLockTests
{
// Mirrors Akka.NET's NewtonSoftJsonSerializer tolerance: unknown members are ignored
// rather than treated as errors, which is what lets a newer peer add fields safely.
private static readonly JsonSerializerSettings AkkaLikeSettings = new()
{
MissingMemberHandling = MissingMemberHandling.Ignore,
};
private static T RoundTrip<T>(T value)
{
var json = JsonConvert.SerializeObject(value, AkkaLikeSettings);
return JsonConvert.DeserializeObject<T>(json, AkkaLikeSettings)!;
}
// ---------------------------------------------------------------------
// (a) Old-shape payloads: newer trailing optional fields absent → defaults.
// ---------------------------------------------------------------------
[Fact]
public void CreateDataConnectionCommand_OldShape_WithoutNewerTrailingFields_Deserializes()
{
// Shape as emitted before BackupConfiguration/FailoverRetryCount existed.
const string oldShape =
"""{"SiteId":3,"Name":"conn1","Protocol":"OpcUa","PrimaryConfiguration":"{}"}""";
var cmd = JsonConvert.DeserializeObject<CreateDataConnectionCommand>(oldShape, AkkaLikeSettings);
Assert.NotNull(cmd);
Assert.Equal(3, cmd!.SiteId);
Assert.Equal("conn1", cmd.Name);
Assert.Equal("OpcUa", cmd.Protocol);
Assert.Equal("{}", cmd.PrimaryConfiguration);
// NOTE (locked reality): under Akka's Newtonsoft-JSON serializer a missing trailing
// field takes default(T) — NOT the C# optional-parameter default. So a reference type
// is null (BackupConfiguration) and a value type is 0 (FailoverRetryCount), even though
// the record declares `FailoverRetryCount = 3`. Version-skew callers therefore cannot
// rely on the C# default reaching the wire; additive fields must be safe at default(T).
Assert.Null(cmd.BackupConfiguration); // reference-type missing field → null
Assert.Equal(0, cmd.FailoverRetryCount); // value-type missing field → default(int), not 3
}
[Fact]
public void DeployInstanceCommand_OldShape_WithoutFetchFields_Deserializes()
{
// Shape as emitted before the notify-and-fetch CentralFetchBaseUrl/FetchToken pair.
const string oldShape =
"""{"DeploymentId":"dep-1","InstanceUniqueName":"i","RevisionHash":"r","FlattenedConfigurationJson":"{}","DeployedBy":"me","Timestamp":"2026-07-08T00:00:00+00:00"}""";
var cmd = JsonConvert.DeserializeObject<DeployInstanceCommand>(oldShape, AkkaLikeSettings);
Assert.NotNull(cmd);
Assert.Equal("dep-1", cmd!.DeploymentId);
Assert.Equal("i", cmd.InstanceUniqueName);
Assert.Null(cmd.CentralFetchBaseUrl); // default applied
Assert.Null(cmd.FetchToken); // default applied
}
[Fact]
public void NotificationSubmit_OldShape_WithoutExecutionCorrelationFields_Deserializes()
{
// Shape as emitted before OriginExecutionId/OriginParentExecutionId/SourceNode.
const string oldShape =
"""{"NotificationId":"n-1","ListName":"ops","Subject":"s","Body":"b","SourceSiteId":"site-1","SourceInstanceId":null,"SourceScript":null,"SiteEnqueuedAt":"2026-07-08T00:00:00+00:00"}""";
var msg = JsonConvert.DeserializeObject<NotificationSubmit>(oldShape, AkkaLikeSettings);
Assert.NotNull(msg);
Assert.Equal("n-1", msg!.NotificationId);
Assert.Equal("ops", msg.ListName);
Assert.Null(msg.OriginExecutionId); // default applied
Assert.Null(msg.OriginParentExecutionId); // default applied
Assert.Null(msg.SourceNode); // default applied
}
// ---------------------------------------------------------------------
// (b) Unknown fields from a newer peer are ignored (never throw).
// ---------------------------------------------------------------------
[Fact]
public void AttributeValueChanged_UnknownFieldFromNewerPeer_IsIgnored()
{
const string newerShape =
"""{"InstanceUniqueName":"i","AttributePath":"a.b","AttributeName":"b","Value":1,"Quality":"Good","Timestamp":"2026-07-08T00:00:00+00:00","SomeFutureField":"x"}""";
var evt = JsonConvert.DeserializeObject<AttributeValueChanged>(newerShape, AkkaLikeSettings);
Assert.NotNull(evt);
Assert.Equal("i", evt!.InstanceUniqueName);
Assert.Equal("Good", evt.Quality);
}
[Fact]
public void GetAttributeRequest_UnknownFieldFromNewerPeer_IsIgnored()
{
const string newerShape =
"""{"CorrelationId":"c-1","InstanceUniqueName":"i","AttributeName":"a","Timestamp":"2026-07-08T00:00:00+00:00","FutureFlag":true}""";
var req = JsonConvert.DeserializeObject<GetAttributeRequest>(newerShape, AkkaLikeSettings);
Assert.NotNull(req);
Assert.Equal("c-1", req!.CorrelationId);
Assert.Equal("a", req.AttributeName);
}
[Fact]
public void SetStaticAttributeCommand_UnknownFieldFromNewerPeer_IsIgnored()
{
const string newerShape =
"""{"CorrelationId":"c-1","InstanceUniqueName":"i","AttributeName":"a","Value":"42","Timestamp":"2026-07-08T00:00:00+00:00","NewerAudience":"x"}""";
var cmd = JsonConvert.DeserializeObject<SetStaticAttributeCommand>(newerShape, AkkaLikeSettings);
Assert.NotNull(cmd);
Assert.Equal("c-1", cmd!.CorrelationId);
Assert.Equal("42", cmd.Value);
}
// ---------------------------------------------------------------------
// (c) Round-trip preserves values.
// ---------------------------------------------------------------------
[Fact]
public void ManagementEnvelope_RoundTrip_PreservesCorrelationIdAndUser()
{
var user = new AuthenticatedUser(
"multi-role", "Multi Role",
new[] { "Administrator", "Designer" }, new[] { "site-1", "site-2" });
var envelope = new ManagementEnvelope(
user, new GetDataConnectionCommand(7), "corr-1");
var round = RoundTrip(envelope);
Assert.Equal("corr-1", round.CorrelationId);
Assert.Equal("multi-role", round.User.Username);
Assert.Equal(new[] { "Administrator", "Designer" }, round.User.Roles);
Assert.Equal(new[] { "site-1", "site-2" }, round.User.PermittedSiteIds);
}
[Fact]
public void AttributeValueChanged_RoundTrip_PreservesValues()
{
var evt = new AttributeValueChanged(
"inst-1", "path.to.attr", "attr", 42, "Good",
new DateTimeOffset(2026, 7, 8, 12, 0, 0, TimeSpan.Zero));
var round = RoundTrip(evt);
Assert.Equal("inst-1", round.InstanceUniqueName);
Assert.Equal("path.to.attr", round.AttributePath);
Assert.Equal("attr", round.AttributeName);
Assert.Equal("Good", round.Quality);
Assert.Equal(evt.Timestamp, round.Timestamp);
}
[Fact]
public void CreateDataConnectionCommand_RoundTrip_PreservesValues()
{
var cmd = new CreateDataConnectionCommand(
SiteId: 5, Name: "conn-x", Protocol: "MxAccess",
PrimaryConfiguration: "{\"a\":1}", BackupConfiguration: "{\"b\":2}",
FailoverRetryCount: 9);
var round = RoundTrip(cmd);
Assert.Equal(5, round.SiteId);
Assert.Equal("conn-x", round.Name);
Assert.Equal("MxAccess", round.Protocol);
Assert.Equal("{\"a\":1}", round.PrimaryConfiguration);
Assert.Equal("{\"b\":2}", round.BackupConfiguration);
Assert.Equal(9, round.FailoverRetryCount);
}
}
@@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
@@ -0,0 +1,39 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Regression: <see cref="CommunicationOptions"/> timeouts feed per-pattern Ask
/// deadlines and gRPC keepalive/stream-lifetime settings; a zero/negative value
/// (or a non-positive <c>GrpcMaxConcurrentStreams</c>) must be rejected at startup
/// by an <see cref="IValidateOptions{TOptions}"/> with a clear, key-naming message
/// rather than surfacing at first Ask/gRPC use.
/// </summary>
public class CommunicationOptionsValidatorTests
{
private static ValidateOptionsResult Validate(CommunicationOptions options) =>
new CommunicationOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new CommunicationOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroDeploymentTimeout_IsRejected()
{
var result = Validate(new CommunicationOptions { DeploymentTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("DeploymentTimeout", result.FailureMessage);
}
[Fact]
public void NonPositiveGrpcMaxConcurrentStreams_IsRejected()
{
var result = Validate(new CommunicationOptions { GrpcMaxConcurrentStreams = 0 });
Assert.True(result.Failed);
Assert.Contains("GrpcMaxConcurrentStreams", result.FailureMessage);
}
}
@@ -0,0 +1,39 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// Regression: <see cref="DataConnectionOptions"/> intervals/timeouts drive the
/// DCL reconnect/tag-resolution timers and the seed-read retry loop; a
/// zero/negative duration (or a non-positive <c>SeedReadMaxAttempts</c>) must be
/// rejected at startup by an <see cref="IValidateOptions{TOptions}"/> with a
/// clear, key-naming message rather than surfacing later at runtime.
/// </summary>
public class DataConnectionOptionsValidatorTests
{
private static ValidateOptionsResult Validate(DataConnectionOptions options) =>
new DataConnectionOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new DataConnectionOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroReconnectInterval_IsRejected()
{
var result = Validate(new DataConnectionOptions { ReconnectInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("ReconnectInterval", result.FailureMessage);
}
[Fact]
public void NonPositiveSeedReadMaxAttempts_IsRejected()
{
var result = Validate(new DataConnectionOptions { SeedReadMaxAttempts = 0 });
Assert.True(result.Failed);
Assert.Contains("SeedReadMaxAttempts", result.FailureMessage);
}
}
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// Arch-review 08 §1.5: <see cref="DeploymentManagerOptions"/> timeouts are
/// applied as linked-CTS deadlines around lifecycle commands, per-site artifact
/// deployment, and operation-lock acquisition. A zero/negative duration makes a
/// deadline fire immediately (or a <c>CancellationTokenSource</c> constructor
/// throw), so a misconfigured section must be rejected at startup with a clear,
/// key-naming message rather than silently breaking deployments.
/// </summary>
public class DeploymentManagerOptionsValidatorTests
{
private static ValidateOptionsResult Validate(DeploymentManagerOptions options) =>
new DeploymentManagerOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new DeploymentManagerOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroLifecycleCommandTimeout_IsRejected()
{
var result = Validate(new DeploymentManagerOptions { LifecycleCommandTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("LifecycleCommandTimeout", result.FailureMessage);
}
[Fact]
public void NegativeLifecycleCommandTimeout_IsRejected()
{
var result = Validate(new DeploymentManagerOptions { LifecycleCommandTimeout = TimeSpan.FromSeconds(-1) });
Assert.True(result.Failed);
Assert.Contains("LifecycleCommandTimeout", result.FailureMessage);
}
[Fact]
public void ZeroArtifactDeploymentTimeoutPerSite_IsRejected()
{
var result = Validate(new DeploymentManagerOptions { ArtifactDeploymentTimeoutPerSite = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("ArtifactDeploymentTimeoutPerSite", result.FailureMessage);
}
[Fact]
public void ZeroOperationLockTimeout_IsRejected()
{
var result = Validate(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("OperationLockTimeout", result.FailureMessage);
}
}
@@ -0,0 +1,67 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
/// <summary>
/// Arch-review 08 §1.5: <see cref="ExternalSystemGatewayOptions"/> feeds a per-call HTTP
/// timeout, a per-system connection cap (used as <c>SocketsHttpHandler.MaxConnectionsPerServer</c>),
/// and a response-body byte cap into the gateway. A zero/negative timeout or a non-positive
/// count crashes the handler or silently disables the gateway; reject them fast at startup.
/// </summary>
public class ExternalSystemGatewayOptionsValidatorTests
{
private static ValidateOptionsResult Validate(ExternalSystemGatewayOptions options) =>
new ExternalSystemGatewayOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new ExternalSystemGatewayOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroDefaultHttpTimeout_IsRejected()
{
var result = Validate(new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("DefaultHttpTimeout", result.FailureMessage);
}
[Fact]
public void NegativeDefaultHttpTimeout_IsRejected()
{
var result = Validate(new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromSeconds(-1) });
Assert.True(result.Failed);
Assert.Contains("DefaultHttpTimeout", result.FailureMessage);
}
[Fact]
public void ZeroMaxConcurrentConnectionsPerSystem_IsRejected()
{
var result = Validate(new ExternalSystemGatewayOptions { MaxConcurrentConnectionsPerSystem = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage);
}
[Fact]
public void NegativeMaxConcurrentConnectionsPerSystem_IsRejected()
{
var result = Validate(new ExternalSystemGatewayOptions { MaxConcurrentConnectionsPerSystem = -1 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage);
}
[Fact]
public void ZeroMaxResponseBodyBytes_IsRejected()
{
var result = Validate(new ExternalSystemGatewayOptions { MaxResponseBodyBytes = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxResponseBodyBytes", result.FailureMessage);
}
}
@@ -424,7 +424,8 @@ public class SiteCompositionRootTests : IDisposable
public static IEnumerable<object[]> SiteScopedServices => new[]
{
new object[] { typeof(IExternalSystemRepository) },
new object[] { typeof(INotificationRepository) },
// INotificationRepository is intentionally absent — notification config is
// central-only (arch-review 08 §1.3/#23); the site-side variant was removed.
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
@@ -442,11 +443,15 @@ public class SiteCompositionRootTests : IDisposable
}
[Fact]
public void Site_NotificationRepository_IsSiteImplementation()
public void Site_NotificationRepository_IsNotRegistered()
{
// Notification delivery is central-only (CLAUDE.md "Notification delivery is
// central-only"); the site-local notification_lists/smtp_configurations tables
// are purged on every artifact apply, so a site-side INotificationRepository
// could only ever serve empty results. It must not be registered at all.
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
Assert.IsType<SiteNotificationRepository>(repo);
var repo = scope.ServiceProvider.GetService<INotificationRepository>();
Assert.Null(repo);
}
// --- Options ---
@@ -0,0 +1,68 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// Arch-review 08 §1.5: <see cref="InboundApiOptions"/> feeds a per-request timeout
/// (<see cref="InboundApiOptions.DefaultMethodTimeout"/>) and a body-size cap
/// (<see cref="InboundApiOptions.MaxRequestBodyBytes"/>) into the inbound-API request
/// pipeline; a zero/negative timeout or a non-positive body cap must be rejected fast at
/// startup with a clear, key-naming message rather than silently degrading the endpoint.
/// The <see cref="InboundApiOptions.ApiKeyPepper"/> is deliberately NOT required non-empty
/// here (empty is a legitimate dev default; production pepper strength is enforced elsewhere).
/// </summary>
public class InboundApiOptionsValidatorTests
{
private static ValidateOptionsResult Validate(InboundApiOptions options) =>
new InboundApiOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new InboundApiOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void EmptyApiKeyPepper_IsValid()
{
var result = Validate(new InboundApiOptions { ApiKeyPepper = string.Empty });
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroDefaultMethodTimeout_IsRejected()
{
var result = Validate(new InboundApiOptions { DefaultMethodTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("DefaultMethodTimeout", result.FailureMessage);
}
[Fact]
public void NegativeDefaultMethodTimeout_IsRejected()
{
var result = Validate(new InboundApiOptions { DefaultMethodTimeout = TimeSpan.FromSeconds(-1) });
Assert.True(result.Failed);
Assert.Contains("DefaultMethodTimeout", result.FailureMessage);
}
[Fact]
public void ZeroMaxRequestBodyBytes_IsRejected()
{
var result = Validate(new InboundApiOptions { MaxRequestBodyBytes = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxRequestBodyBytes", result.FailureMessage);
}
[Fact]
public void NegativeMaxRequestBodyBytes_IsRejected()
{
var result = Validate(new InboundApiOptions { MaxRequestBodyBytes = -1 });
Assert.True(result.Failed);
Assert.Contains("MaxRequestBodyBytes", result.FailureMessage);
}
}
@@ -0,0 +1,59 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Arch-review 08 §1.5: <see cref="ManagementServiceOptions"/> supplies the Ask timeouts
/// the ManagementActor client uses for command and long-running transport/deploy calls. A
/// zero/negative timeout makes every command fail (or Ask forever); reject it fast at startup
/// with a key-naming message. <see cref="ManagementServiceOptions.SecuredWritePendingTtl"/> is
/// deliberately NOT required positive — a non-positive value legitimately disables server-side
/// secured-write expiry.
/// </summary>
public class ManagementServiceOptionsValidatorTests
{
private static ValidateOptionsResult Validate(ManagementServiceOptions options) =>
new ManagementServiceOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new ManagementServiceOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void NonPositiveSecuredWritePendingTtl_IsValid()
{
// A non-positive TTL disables server-side expiry by design — not a config error.
var result = Validate(new ManagementServiceOptions { SecuredWritePendingTtl = TimeSpan.Zero });
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroCommandTimeout_IsRejected()
{
var result = Validate(new ManagementServiceOptions { CommandTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("CommandTimeout", result.FailureMessage);
}
[Fact]
public void NegativeCommandTimeout_IsRejected()
{
var result = Validate(new ManagementServiceOptions { CommandTimeout = TimeSpan.FromSeconds(-1) });
Assert.True(result.Failed);
Assert.Contains("CommandTimeout", result.FailureMessage);
}
[Fact]
public void ZeroLongRunningCommandTimeout_IsRejected()
{
var result = Validate(new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("LongRunningCommandTimeout", result.FailureMessage);
}
}
@@ -0,0 +1,58 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// <summary>
/// Arch-review 08 §1.5: <see cref="NotificationOptions"/> supplies the SMTP fallback
/// connection timeout and max-concurrent-connections used by the central Email delivery
/// adapter when the deployed <c>SmtpConfiguration</c> leaves them unset. A non-positive
/// value there is not a usable fallback; reject it fast at startup with a key-naming message.
/// </summary>
public class NotificationOptionsValidatorTests
{
private static ValidateOptionsResult Validate(NotificationOptions options) =>
new NotificationOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new NotificationOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroConnectionTimeoutSeconds_IsRejected()
{
var result = Validate(new NotificationOptions { ConnectionTimeoutSeconds = 0 });
Assert.True(result.Failed);
Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage);
}
[Fact]
public void NegativeConnectionTimeoutSeconds_IsRejected()
{
var result = Validate(new NotificationOptions { ConnectionTimeoutSeconds = -1 });
Assert.True(result.Failed);
Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage);
}
[Fact]
public void ZeroMaxConcurrentConnections_IsRejected()
{
var result = Validate(new NotificationOptions { MaxConcurrentConnections = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentConnections", result.FailureMessage);
}
[Fact]
public void NegativeMaxConcurrentConnections_IsRejected()
{
var result = Validate(new NotificationOptions { MaxConcurrentConnections = -1 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentConnections", result.FailureMessage);
}
}
@@ -0,0 +1,33 @@
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.Failover;
/// <summary>
/// Failover-timing benchmark harness (placeholder).
///
/// Design envelope under test (CLAUDE.md "Cluster &amp; Failover"): failure
/// detection 2s heartbeat / 10s threshold, SBR stable-after 15s, total
/// failover ~25s for a hard node loss.
///
/// Measurement protocol (to be wired to the two-node cluster rig delivered
/// by PLAN-01 — see archreview/plans/PLAN-01-*.md; do not duplicate that rig
/// here):
/// 1. Form a real 2-node cluster (docker/ topology or Akka.Remote in-proc
/// multi-ActorSystem rig from PLAN-01).
/// 2. Confirm a cluster singleton (e.g. site DeploymentManager) is hosted
/// on node A; record T0.
/// 3. Hard-kill node A (process kill / ActorSystem.Abort — not
/// CoordinatedShutdown; graceful stop exercises a different path).
/// 4. Poll node B for singleton re-host; record T1 at first successful
/// response from the migrated singleton.
/// 5. Assert T1 - T0 &lt;= 40s (25s design + margin) and report the number.
/// </summary>
public class FailoverTimingTests
{
[Trait("Category", "Performance")]
[Fact(Skip = "Requires the real two-node failover rig delivered by PLAN-01 " +
"(overall review P2-10). This class documents the measurement " +
"protocol; wire it to the rig when PLAN-01 lands.")]
public void HardKillFailover_SingletonRehostedWithinDesignEnvelope()
{
// Intentionally empty until the PLAN-01 rig exists.
}
}
@@ -0,0 +1,102 @@
using System.Diagnostics;
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.Streaming;
/// <summary>
/// Throughput measurement for the site-wide attribute/alarm stream
/// (<see cref="SiteStreamManager"/>: <c>Source.ActorRef</c> → <c>BroadcastHub</c>
/// with per-subscriber buffering, <c>DropHead</c> overflow). Design envelope: this
/// stream carries every attribute value change on a site; it must comfortably
/// sustain tens of thousands of events/sec. Thresholds are deliberately
/// conservative (CI-machine safe) — this test exists to catch order-of-magnitude
/// regressions, not to benchmark.
/// </summary>
/// <remarks>
/// <para>
/// This is the first of the two highest-value real measurements arch-review 08 §2.3
/// called for. It follows the existing <c>HotPathLatencyTests</c> style:
/// <see cref="Stopwatch"/>-based, <c>[Trait("Category", "Performance")]</c>,
/// conservative thresholds so CI stays green on slow machines.
/// </para>
/// <para>
/// It spins up a real (non-clustered) <see cref="ActorSystem"/> rather than using
/// Akka.TestKit — the PerformanceTests project references SiteRuntime (which brings
/// in Akka + Akka.Streams transitively) but not Akka.TestKit.Xunit2, so no csproj
/// change is required.
/// </para>
/// </remarks>
public class SiteStreamThroughputTests
{
private sealed class CountingActor : ReceiveActor
{
public static long Received;
public CountingActor()
{
// The manager Tells the raw ISiteStreamEvent record (AttributeValueChanged)
// straight to the subscriber — no wrapper — so we match on the record.
Receive<AttributeValueChanged>(_ => Interlocked.Increment(ref Received));
ReceiveAny(_ => { });
}
}
[Trait("Category", "Performance")]
[Fact]
public async Task SiteStream_SustainsAtLeast10kEventsPerSecond_ToOneSubscriber()
{
const int eventCount = 100_000;
CountingActor.Received = 0;
var system = ActorSystem.Create("SiteStreamThroughputTests");
try
{
// Buffer sized to the whole burst so DropHead should not fire.
var manager = new SiteStreamManager(
new SiteRuntimeOptions { StreamBufferSize = eventCount },
NullLogger<SiteStreamManager>.Instance);
manager.Initialize(system);
var counter = system.ActorOf(Props.Create<CountingActor>());
manager.Subscribe("PerfInstance", counter);
var sw = Stopwatch.StartNew();
for (var i = 0; i < eventCount; i++)
{
manager.PublishAttributeValueChanged(new AttributeValueChanged(
"PerfInstance", "Attr", "Attr", i, "Good", DateTimeOffset.UtcNow));
}
// Drain: wait until the counter goes quiet (no growth across a 200ms window).
long last = -1;
var deadline = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < deadline)
{
var now = Interlocked.Read(ref CountingActor.Received);
if (now == last && now > 0) break;
last = now;
await Task.Delay(200);
}
sw.Stop();
var received = Interlocked.Read(ref CountingActor.Received);
var eventsPerSecond = received / sw.Elapsed.TotalSeconds;
// DropHead means under extreme pressure some events may drop; with the
// buffer sized to the burst, deliver ratio should be ~1.0.
Assert.True(received >= eventCount * 0.9,
$"Expected >=90% delivery, got {received}/{eventCount}");
Assert.True(eventsPerSecond >= 10_000,
$"Expected >=10k events/s sustained, got {eventsPerSecond:F0}/s over {sw.Elapsed.TotalSeconds:F1}s " +
$"(delivered {received}/{eventCount})");
}
finally
{
await system.Terminate();
}
}
}
@@ -0,0 +1,81 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
/// <summary>
/// Arch-review 08 §1.5: <see cref="SiteCallAuditOptions"/> drives stuck-call
/// detection, KPI windowing, the central→site relay Ask timeout, and the two
/// central-singleton scheduler cadences plus their retention window. A
/// zero/negative duration or non-positive count must be rejected at startup with
/// a clear, key-naming message rather than making an Akka scheduler spin or a KPI
/// query silently degrade. The <c>Resolved*</c> computed properties clamp away
/// bad cadence values, so the BASE properties are validated here.
/// </summary>
public class SiteCallAuditOptionsValidatorTests
{
private static ValidateOptionsResult Validate(SiteCallAuditOptions options) =>
new SiteCallAuditOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new SiteCallAuditOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroStuckAgeThreshold_IsRejected()
{
var result = Validate(new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("StuckAgeThreshold", result.FailureMessage);
}
[Fact]
public void ZeroKpiInterval_IsRejected()
{
var result = Validate(new SiteCallAuditOptions { KpiInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("KpiInterval", result.FailureMessage);
}
[Fact]
public void ZeroRelayTimeout_IsRejected()
{
var result = Validate(new SiteCallAuditOptions { RelayTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("RelayTimeout", result.FailureMessage);
}
[Fact]
public void ZeroReconciliationInterval_IsRejected()
{
var result = Validate(new SiteCallAuditOptions { ReconciliationInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("ReconciliationInterval", result.FailureMessage);
}
[Fact]
public void ZeroReconciliationBatchSize_IsRejected()
{
var result = Validate(new SiteCallAuditOptions { ReconciliationBatchSize = 0 });
Assert.True(result.Failed);
Assert.Contains("ReconciliationBatchSize", result.FailureMessage);
}
[Fact]
public void ZeroPurgeInterval_IsRejected()
{
var result = Validate(new SiteCallAuditOptions { PurgeInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("PurgeInterval", result.FailureMessage);
}
[Fact]
public void ZeroRetentionDays_IsRejected()
{
var result = Validate(new SiteCallAuditOptions { RetentionDays = 0 });
Assert.True(result.Failed);
Assert.Contains("RetentionDays", result.FailureMessage);
}
}
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// <see cref="SiteEventLogOptions"/> retention/purge values drive the daily purge
/// PeriodicTimer (zero/negative trips its constructor) and the paging bounds gate
/// query result sizes. These tests assert the
/// <see cref="SiteEventLogOptionsValidator"/> rejects a bad
/// <c>ScadaBridge:SiteEventLog</c> section with a clear, key-naming message.
/// </summary>
public class SiteEventLogOptionsValidatorTests
{
private static ValidateOptionsResult Validate(SiteEventLogOptions options) =>
new SiteEventLogOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new SiteEventLogOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroRetentionDays_IsRejected()
{
var result = Validate(new SiteEventLogOptions { RetentionDays = 0 });
Assert.True(result.Failed);
Assert.Contains("RetentionDays", result.FailureMessage);
}
[Fact]
public void ZeroPurgeInterval_IsRejected()
{
var result = Validate(new SiteEventLogOptions { PurgeInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("PurgeInterval", result.FailureMessage);
}
[Fact]
public void MaxQueryPageSize_BelowQueryPageSize_IsRejected()
{
var result = Validate(new SiteEventLogOptions { QueryPageSize = 500, MaxQueryPageSize = 100 });
Assert.True(result.Failed);
Assert.Contains("MaxQueryPageSize", result.FailureMessage);
}
}
@@ -1,6 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
@@ -123,46 +122,67 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// Upsert should not throw
}
// ── Notification List Storage ──
[Fact]
public async Task StoreNotificationList_DoesNotThrow()
{
await _storage.StoreNotificationListAsync(
"Ops Team", ["ops@example.com", "admin@example.com"]);
}
[Fact]
public async Task StoreNotificationList_Upserts()
{
await _storage.StoreNotificationListAsync("Team1", ["a@b.com"]);
await _storage.StoreNotificationListAsync("Team1", ["x@y.com", "z@w.com"]);
// Upsert should not throw
}
// ── DeploymentManager-025 / SiteRuntime-031: central-only notif/SMTP purge ──
//
// Notification config is central-only. The site-side write paths and
// SiteNotificationRepository were removed 2026-07-10 (arch-review 08 §1.3/#23);
// PurgeCentralOnlyNotificationConfigAsync is retained as the security cleanup for
// DBs written by older builds. These tests seed the (still-present) tables via raw
// SQL — the only way rows can now exist — and assert the purge empties them.
private async Task SeedNotificationRowAsync(string name, string emailsJson)
{
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}");
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText =
"INSERT INTO notification_lists (name, recipient_emails, updated_at) VALUES (@n, @e, @u)";
command.Parameters.AddWithValue("@n", name);
command.Parameters.AddWithValue("@e", emailsJson);
command.Parameters.AddWithValue("@u", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
private async Task SeedSmtpRowAsync(string name, string password)
{
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}");
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText =
@"INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
VALUES (@n, 'smtp.example.com', 587, 'BasicAuth', 'noreply@example.com', 'smtpuser', @p, NULL, @u)";
command.Parameters.AddWithValue("@n", name);
command.Parameters.AddWithValue("@p", password);
command.Parameters.AddWithValue("@u", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
private async Task<long> RowCountAsync(string table)
{
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}");
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"SELECT COUNT(*) FROM {table}";
return (long)(await command.ExecuteScalarAsync())!;
}
[Fact]
public async Task PurgeCentralOnlyNotificationConfig_RemovesPersistedNotificationListsAndSmtpRows()
{
// Simulate a pre-fix build that already shipped a notification list and an
// SMTP config (with a plaintext password) to the site.
await _storage.StoreNotificationListAsync("Ops Team", ["ops@example.com"]);
await _storage.StoreSmtpConfigurationAsync(
"smtp.example.com:587", "smtp.example.com", 587, "BasicAuth",
"noreply@example.com", "smtpuser", "PLAINTEXT-SECRET", null);
await SeedNotificationRowAsync("Ops Team", "[\"ops@example.com\"]");
await SeedSmtpRowAsync("smtp.example.com:587", "PLAINTEXT-SECRET");
var repo = new SiteNotificationRepository(_storage);
Assert.NotEmpty(await repo.GetAllNotificationListsAsync());
Assert.NotEmpty(await repo.GetAllSmtpConfigurationsAsync());
Assert.Equal(1, await RowCountAsync("notification_lists"));
Assert.Equal(1, await RowCountAsync("smtp_configurations"));
// The fix: every artifact apply/deploy purges these central-only rows.
await _storage.PurgeCentralOnlyNotificationConfigAsync();
// Both tables are now empty — the plaintext SMTP credential is gone.
Assert.Empty(await repo.GetAllNotificationListsAsync());
Assert.Empty(await repo.GetAllSmtpConfigurationsAsync());
Assert.Equal(0, await RowCountAsync("notification_lists"));
Assert.Equal(0, await RowCountAsync("smtp_configurations"));
}
[Fact]
@@ -172,9 +192,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
await _storage.PurgeCentralOnlyNotificationConfigAsync();
await _storage.PurgeCentralOnlyNotificationConfigAsync();
var repo = new SiteNotificationRepository(_storage);
Assert.Empty(await repo.GetAllNotificationListsAsync());
Assert.Empty(await repo.GetAllSmtpConfigurationsAsync());
Assert.Equal(0, await RowCountAsync("notification_lists"));
Assert.Equal(0, await RowCountAsync("smtp_configurations"));
}
// ── Schema includes all WP-33 tables ──
@@ -186,7 +205,9 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
await _storage.StoreSharedScriptAsync("s", "code", null, null);
await _storage.StoreExternalSystemAsync("e", "url", "None", null, null);
await _storage.StoreDatabaseConnectionAsync("d", "connstr", 1, TimeSpan.Zero);
await _storage.StoreNotificationListAsync("n", ["email@test.com"]);
// notification_lists / smtp_configurations remain in the schema (kept for the
// security purge) — their presence is exercised by the purge tests above.
// All succeeded without exceptions = tables exist
}
@@ -199,25 +199,8 @@ public class SiteRepositoryTests : IDisposable
Assert.Null(await repo.GetDatabaseConnectionByNameAsync("DoesNotExist"));
}
/// <summary>
/// SiteRuntime-007: the same stability guarantee for notification lists.
/// </summary>
[Fact]
public async Task NotificationRepository_SyntheticId_IsStableAcrossRestart()
{
var storage1 = NewStorage();
await storage1.InitializeAsync();
await storage1.StoreNotificationListAsync(
"OnCall", new[] { "a@example.com", "b@example.com" });
var repo1 = new SiteNotificationRepository(storage1);
var idBeforeRestart = (await repo1.GetAllNotificationListsAsync())[0].Id;
var storage2 = NewStorage();
var repo2 = new SiteNotificationRepository(storage2);
var found = await repo2.GetNotificationListByIdAsync(idBeforeRestart);
Assert.NotNull(found);
Assert.Equal("OnCall", found.Name);
}
// NotificationRepository_SyntheticId_IsStableAcrossRestart was removed 2026-07-10
// (arch-review 08 §1.3/#23) along with the vestigial SiteNotificationRepository —
// notification config is central-only and never lives on a site. The synthetic-ID
// stability guarantee is still exercised by ExternalSystemRepository_SyntheticId_IsStableAcrossRestart.
}
@@ -0,0 +1,60 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests;
/// <summary>
/// <see cref="SiteRuntimeOptions"/> values size the staggered Instance-Actor
/// startup batches, the dedicated script-execution thread scheduler, and the
/// site-wide stream buffer. A zero/negative count crashes actor construction with
/// an opaque <see cref="ArgumentOutOfRangeException"/>; these tests assert the
/// <see cref="SiteRuntimeOptionsValidator"/> rejects a bad
/// <c>ScadaBridge:SiteRuntime</c> section with a clear, key-naming message.
/// </summary>
public class SiteRuntimeOptionsValidatorTests
{
private static ValidateOptionsResult Validate(SiteRuntimeOptions options) =>
new SiteRuntimeOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new SiteRuntimeOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroStartupBatchSize_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { StartupBatchSize = 0 });
Assert.True(result.Failed);
Assert.Contains("StartupBatchSize", result.FailureMessage);
}
[Fact]
public void ZeroScriptExecutionThreadCount_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { ScriptExecutionThreadCount = 0 });
Assert.True(result.Failed);
Assert.Contains("ScriptExecutionThreadCount", result.FailureMessage);
}
[Fact]
public void ZeroStreamBufferSize_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { StreamBufferSize = 0 });
Assert.True(result.Failed);
Assert.Contains("StreamBufferSize", result.FailureMessage);
}
[Fact]
public void NegativeStartupBatchDelayMs_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { StartupBatchDelayMs = -1 });
Assert.True(result.Failed);
Assert.Contains("StartupBatchDelayMs", result.FailureMessage);
}
}
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// <see cref="StoreAndForwardOptions"/> retry intervals feed the background sweep
/// timer (zero/negative trips the timer constructor) and the SQLite path backs the
/// buffer. These tests assert the <see cref="StoreAndForwardOptionsValidator"/>
/// rejects a bad <c>ScadaBridge:StoreAndForward</c> section with a clear,
/// key-naming message rather than crashing later with an opaque exception.
/// </summary>
public class StoreAndForwardOptionsValidatorTests
{
private static ValidateOptionsResult Validate(StoreAndForwardOptions options) =>
new StoreAndForwardOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new StoreAndForwardOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroDefaultRetryInterval_IsRejected()
{
var result = Validate(new StoreAndForwardOptions { DefaultRetryInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("DefaultRetryInterval", result.FailureMessage);
}
[Fact]
public void ZeroRetryTimerInterval_IsRejected()
{
var result = Validate(new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("RetryTimerInterval", result.FailureMessage);
}
[Fact]
public void EmptySqliteDbPath_IsRejected()
{
var result = Validate(new StoreAndForwardOptions { SqliteDbPath = "" });
Assert.True(result.Failed);
Assert.Contains("SqliteDbPath", result.FailureMessage);
}
}
@@ -0,0 +1,123 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests;
/// <summary>
/// Arch-review 08 §1.5: <see cref="TransportOptions"/> feeds bundle size caps,
/// zip-bomb defences, unlock rate limits and — security-critically — the PBKDF2
/// iteration count used to derive the bundle encryption key. A zero/negative or
/// otherwise degenerate value must be rejected at startup with a clear,
/// key-naming message rather than silently weakening key derivation or crashing
/// deep inside the import pipeline.
/// </summary>
public class TransportOptionsValidatorTests
{
private static ValidateOptionsResult Validate(TransportOptions options) =>
new TransportOptionsValidator().Validate(Options.DefaultName, options);
[Fact]
public void DefaultOptions_AreValid()
{
var result = Validate(new TransportOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroBundleSessionTtlMinutes_IsRejected()
{
var result = Validate(new TransportOptions { BundleSessionTtlMinutes = 0 });
Assert.True(result.Failed);
Assert.Contains("BundleSessionTtlMinutes", result.FailureMessage);
}
[Fact]
public void ZeroMaxBundleSizeMb_IsRejected()
{
var result = Validate(new TransportOptions { MaxBundleSizeMb = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxBundleSizeMb", result.FailureMessage);
}
[Fact]
public void ZeroPbkdf2Iterations_IsRejected()
{
var result = Validate(new TransportOptions { Pbkdf2Iterations = 0 });
Assert.True(result.Failed);
Assert.Contains("Pbkdf2Iterations", result.FailureMessage);
}
[Fact]
public void Pbkdf2Iterations_BelowSecurityFloor_IsRejected()
{
// A config typo (e.g. 6_000 instead of 600_000) must not silently weaken
// bundle key derivation: anything under 100_000 is rejected.
var result = Validate(new TransportOptions { Pbkdf2Iterations = 99_999 });
Assert.True(result.Failed);
Assert.Contains("Pbkdf2Iterations", result.FailureMessage);
}
[Fact]
public void Pbkdf2Iterations_AtSecurityFloor_IsValid()
{
var result = Validate(new TransportOptions { Pbkdf2Iterations = 100_000 });
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void ZeroMaxUnlockAttemptsPerSession_IsRejected()
{
var result = Validate(new TransportOptions { MaxUnlockAttemptsPerSession = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxUnlockAttemptsPerSession", result.FailureMessage);
}
[Fact]
public void ZeroMaxBundleEntryDecompressedMb_IsRejected()
{
var result = Validate(new TransportOptions { MaxBundleEntryDecompressedMb = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxBundleEntryDecompressedMb", result.FailureMessage);
}
[Fact]
public void ZeroMaxBundleEntryCount_IsRejected()
{
var result = Validate(new TransportOptions { MaxBundleEntryCount = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxBundleEntryCount", result.FailureMessage);
}
[Fact]
public void ZeroMaxBundleEntryCompressionRatio_IsRejected()
{
var result = Validate(new TransportOptions { MaxBundleEntryCompressionRatio = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxBundleEntryCompressionRatio", result.FailureMessage);
}
[Fact]
public void ZeroMaxUnlockAttemptsPerIpPerHour_IsRejected()
{
var result = Validate(new TransportOptions { MaxUnlockAttemptsPerIpPerHour = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxUnlockAttemptsPerIpPerHour", result.FailureMessage);
}
[Fact]
public void ZeroSchemaVersionMajor_IsRejected()
{
var result = Validate(new TransportOptions { SchemaVersionMajor = 0 });
Assert.True(result.Failed);
Assert.Contains("SchemaVersionMajor", result.FailureMessage);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void BlankSourceEnvironment_IsRejected(string value)
{
var result = Validate(new TransportOptions { SourceEnvironment = value });
Assert.True(result.Failed);
Assert.Contains("SourceEnvironment", result.FailureMessage);
}
}