Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75007b9edd | |||
| d8487eab2d | |||
| d61881ffaf | |||
| 7ff1263ac0 | |||
| e156c7fa8a | |||
| fcc9c7bf8e | |||
| b879e51541 | |||
| 6bf9dfb3c5 | |||
| fc918d4679 | |||
| 8b775f4ae1 | |||
| af2cfde484 |
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
@@ -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");
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+33
@@ -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();
|
||||
|
||||
|
||||
+1
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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>();
|
||||
|
||||
+1
@@ -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.");
|
||||
}
|
||||
}
|
||||
+1
@@ -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&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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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 & 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 <= 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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user