merge(r2): r2-plan05
This commit is contained in:
@@ -95,7 +95,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
21. Notification Outbox — Central component ingesting store-and-forwarded notifications, `Notifications` audit table, dispatcher loop, retry/parking, delivery KPIs.
|
21. Notification Outbox — Central component ingesting store-and-forwarded notifications, `Notifications` audit table, dispatcher loop, retry/parking, delivery KPIs.
|
||||||
22. Site Call Audit — Central component auditing site cached calls (`CachedCall`/`CachedWrite`); `SiteCalls` audit table, telemetry ingest, reconciliation, KPIs, central→site Retry/Discard relay; sites remain the source of truth.
|
22. Site Call Audit — Central component auditing site cached calls (`CachedCall`/`CachedWrite`); `SiteCalls` audit table, telemetry ingest, reconciliation, KPIs, central→site Retry/Discard relay; sites remain the source of truth.
|
||||||
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
|
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
|
||||||
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates (with **all** child collections — attributes/alarms/scripts/compositions/**native-alarm-sources**, field-complete incl. `LockedInDerived` + script cadence/timeout), system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s + real `AreaName` by name) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Runs the **script trust gate** at import review (5th `ScriptTrustValidator` call site — forbidden-API scripts rejected pre-runtime; template-script name-resolution findings are advisory warnings, ApiMethod findings hard-block). Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
|
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates (with **all** child collections — attributes/alarms/scripts/compositions/**native-alarm-sources**, field-complete incl. `LockedInDerived` + script cadence/timeout), system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s + real `AreaName` by name) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Runs the **script trust gate** at import review (5th `ScriptTrustValidator` call site — forbidden-API scripts rejected pre-runtime; covers template/shared/ApiMethod bodies, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** — all hard-block; template-script name-resolution findings are advisory warnings, ApiMethod findings hard-block). Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
|
||||||
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
|
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
|
||||||
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
|
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
|
||||||
27. DelmiaNotifier — Standalone external client tool (NOT a cluster component, NOT in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`), with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract — a drop-in replacement for the legacy `WWNotifier` (see `docs/former-api-specs/dnc/`). Zero-dependency BCL-only, `System.Text.Json` source-gen. Project README: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`; design: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md`.
|
27. DelmiaNotifier — Standalone external client tool (NOT a cluster component, NOT in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`), with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract — a drop-in replacement for the legacy `WWNotifier` (see `docs/former-api-specs/dnc/`). Zero-dependency BCL-only, `System.Text.Json` source-gen. Project README: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`; design: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md`.
|
||||||
@@ -150,7 +150,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
- Last-write-wins for concurrent template editing (no optimistic concurrency on templates).
|
- Last-write-wins for concurrent template editing (no optimistic concurrency on templates).
|
||||||
- Optimistic concurrency on deployment status records.
|
- Optimistic concurrency on deployment status records.
|
||||||
- Naming collisions in composed feature modules are design-time errors.
|
- Naming collisions in composed feature modules are design-time errors.
|
||||||
- Transport (#24, M8): bundles now move site/instance config (`Site`s, site-scoped `DataConnection`s, `Instance`s + override children + `Area` by name), not just central-only config — but still never touch site runtime nodes (imported instances land `NotDeployed`). A `BundleNameMap` name-mapping subsystem (Commons: `SiteMapping`/`ConnectionMapping`/`MappingAction`) reconciles environment-specific identifiers (sites by `SiteIdentifier`, connections by site-qualified `{SiteIdentifier}/{Name}`, instances by `UniqueName`): auto-match → operator override via the import-wizard Map step / CLI `--map-site`/`--map-connection`/`--create-missing-*` → blocker rows for the unresolved. D3 "carry full config (encrypted secrets)": Site addresses travel; `DataConnection` config rides the encrypted `SecretsBlock` (presence-only in diffs). Per-line Myers diff for code fields via the pure `LineDiffer` (`ArtifactDiff` embeds a size-capped structured `lineDiff`; input capped at `MaxInputLines`=4000 → summary-only, so a crafted bundle can't OOM the active node); `ImportResult.StaleInstanceIds` is real via the `IStaleInstanceProbe` seam (Commons; implemented in DeploymentManager). `schemaVersion` 1.0→1.1 (additive); `bundleFormatVersion` stays 1; no new EF tables/columns. **Arch-review 05 (plan 05):** template child fidelity completed (round-trip-guard-verified: `LockedInDerived`, native-alarm-sources, script cadence/timeout, real `AreaName`, ES retry config + methods-on-Add, notification recipients, folder parent edges); import runs the **script trust gate** (template/shared/ApiMethod bodies **and** Expression-trigger bodies) as a hard pre-runtime gate + a name-resolution severity split (template=advisory `ConflictKind.Warning`+`ImportResult.Warnings`, ApiMethod=hard error); `ArtifactDiff` resolves folder/base/composition names (unchanged structured templates classify Identical again); `MaxConcurrentImportSessions`=8 session cap. Rename does NOT rewrite call sites (deploy gate on the target is the real check). Plan-06 handoff: `ScriptArtifactsChanged`/`IScriptArtifactChangeBus` seam + Host in-process bus + Transport post-commit publisher shipped (consumers = plan 06).
|
- Transport (#24, M8): bundles now move site/instance config (`Site`s, site-scoped `DataConnection`s, `Instance`s + override children + `Area` by name), not just central-only config — but still never touch site runtime nodes (imported instances land `NotDeployed`). A `BundleNameMap` name-mapping subsystem (Commons: `SiteMapping`/`ConnectionMapping`/`MappingAction`) reconciles environment-specific identifiers (sites by `SiteIdentifier`, connections by site-qualified `{SiteIdentifier}/{Name}`, instances by `UniqueName`): auto-match → operator override via the import-wizard Map step / CLI `--map-site`/`--map-connection`/`--create-missing-*` → blocker rows for the unresolved. D3 "carry full config (encrypted secrets)": Site addresses travel; `DataConnection` config rides the encrypted `SecretsBlock` (presence-only in diffs). Per-line Myers diff for code fields via the pure `LineDiffer` (`ArtifactDiff` embeds a size-capped structured `lineDiff`; input capped at `MaxInputLines`=4000 → summary-only, so a crafted bundle can't OOM the active node); `ImportResult.StaleInstanceIds` is real via the `IStaleInstanceProbe` seam (Commons; implemented in DeploymentManager). `schemaVersion` 1.0→1.1 (additive); `bundleFormatVersion` stays 1; no new EF tables/columns. **Arch-review 05 (plan 05):** template child fidelity completed (round-trip-guard-verified: `LockedInDerived`, native-alarm-sources, script cadence/timeout, real `AreaName`, ES retry config + methods-on-Add, notification recipients, folder parent edges); import runs the **script trust gate** (template/shared/ApiMethod bodies, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** — R2-05) as a hard pre-runtime gate + a name-resolution severity split (template=advisory `ConflictKind.Warning`+`ImportResult.Warnings`, ApiMethod=hard error); `ArtifactDiff` resolves folder/base/composition names (unchanged structured templates classify Identical again); `MaxConcurrentImportSessions`=8 session cap (startup-validated `>0`, R2-05). **R2-05 also:** the post-commit `ScriptArtifactsChanged` publisher now covers `Add` resolutions (not just Overwrite/Rename), import warns on instance overrides targeting locked template members (advisory, rows still written), and the Expression-trigger syntax check is surface-keyed-cached + skipped on read-only staleness/comparison paths (`validateScriptCompilation:false`). Rename does NOT rewrite call sites (deploy gate on the target is the real check). Plan-06 handoff: `ScriptArtifactsChanged`/`IScriptArtifactChangeBus` seam + Host in-process bus + Transport post-commit publisher shipped (consumers = plan 06).
|
||||||
|
|
||||||
### Store-and-Forward
|
### Store-and-Forward
|
||||||
- Fixed retry interval, no max buffer size. Only transient failures buffered.
|
- Fixed retry interval, no max buffer size. Only transient failures buffered.
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`
|
|||||||
|
|
||||||
Mirrors `TriggerExpressionGlobals` in the same way. Used by `ValidationService.CheckExpressionSyntax` in the Template Engine for conditional and expression trigger validation.
|
Mirrors `TriggerExpressionGlobals` in the same way. Used by `ValidationService.CheckExpressionSyntax` in the Template Engine for conditional and expression trigger validation.
|
||||||
|
|
||||||
|
`CheckExpressionSyntax` memoises its verdict in the Template Engine's process-wide `ScriptCompileVerdictCache`, whose key is the pair **(globals surface, SHA-256 of the code)** — not the code alone. The surface discriminator is load-bearing: a trigger expression vetted against `TriggerCompileSurface` is **not** interchangeable with a `ScriptCompileSurface` script-body verdict (different globals resolve different identifiers), so a code-only key could return a stale "clean" for code never compiled against the caller's surface. Keying by surface makes that cross-surface verdict reuse structurally impossible.
|
||||||
|
|
||||||
#### Parity guard
|
#### Parity guard
|
||||||
|
|
||||||
A reflection-based parity test in `SiteRuntime.Tests` compares the public member names on `ScriptCompileSurface` against `ScriptGlobals` (and `TriggerCompileSurface` against `TriggerExpressionGlobals`). Any drift between the stub and the real globals causes this test to fail, ensuring the stubs cannot silently fall out of sync.
|
A reflection-based parity test in `SiteRuntime.Tests` compares the public member names on `ScriptCompileSurface` against `ScriptGlobals` (and `TriggerCompileSurface` against `TriggerExpressionGlobals`). Any drift between the stub and the real globals causes this test to fail, ensuring the stubs cannot silently fall out of sync.
|
||||||
@@ -170,7 +172,7 @@ All five call sites that previously maintained their own script trust enforcemen
|
|||||||
| **Site Runtime** `ScriptCompilationService.ValidateTrustModel` | Semantic resolver, no reflection-gateway hardening | Delegates to `FindViolations`; retains `CSharpScript.Compile` against real `ScriptGlobals` for execution |
|
| **Site Runtime** `ScriptCompilationService.ValidateTrustModel` | Semantic resolver, no reflection-gateway hardening | Delegates to `FindViolations`; retains `CSharpScript.Compile` against real `ScriptGlobals` for execution |
|
||||||
| **Inbound API** `ForbiddenApiChecker.FindViolations` | Syntactic walker, forbade all `System.Diagnostics` | Thin shim delegating to `ScriptTrustValidator.FindViolations`; `System.Diagnostics` loosened to `.Process`-only |
|
| **Inbound API** `ForbiddenApiChecker.FindViolations` | Syntactic walker, forbade all `System.Diagnostics` | Thin shim delegating to `ScriptTrustValidator.FindViolations`; `System.Diagnostics` loosened to `.Process`-only |
|
||||||
| **Central UI** `ScriptAnalysisService` | Semantic + full compile, lenient threading | Delegates forbidden-API verdict and sources editor-marker deny-list from `ScriptTrustPolicy`; retains Test-Run execution host |
|
| **Central UI** `ScriptAnalysisService` | Semantic + full compile, lenient threading | Delegates forbidden-API verdict and sources editor-marker deny-list from `ScriptTrustPolicy`; retains Test-Run execution host |
|
||||||
| **Transport** `BundleImporter.RunSemanticValidationAsync` / `DetectBlockersAsync` | No trust gate — a bundle's forbidden-API scripts imported clean, deferred to runtime (fifth write path) | Runs `ScriptTrustValidator.FindViolations` over every non-Skip template / shared / ApiMethod body at import review — hard error (apply) / Blocker row (preview) for all kinds |
|
| **Transport** `BundleImporter.RunSemanticValidationAsync` / `DetectBlockersAsync` | No trust gate — a bundle's forbidden-API scripts imported clean, deferred to runtime (fifth write path) | Runs `ScriptTrustValidator.FindViolations` over every non-Skip template / shared / ApiMethod body, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** at import review — hard error (apply) / Blocker row (preview) for all kinds |
|
||||||
|
|
||||||
The static enforcement is **defence-in-depth**, not a true runtime sandbox. Scripts execute in-process; the denied API list prevents obvious escapes at compile time but does not provide the isolation guarantees of an out-of-process sandbox or a restricted `AssemblyLoadContext`. This caveat applies to all consumers.
|
The static enforcement is **defence-in-depth**, not a true runtime sandbox. Scripts execute in-process; the denied API list prevents obvious escapes at compile time but does not provide the isolation guarantees of an out-of-process sandbox or a restricted `AssemblyLoadContext`. This caveat applies to all consumers.
|
||||||
|
|
||||||
@@ -190,4 +192,4 @@ No dependency on Akka.NET, ASP.NET Core, Entity Framework, or any other ScadaBri
|
|||||||
- **Site Runtime (#3)**: `ScriptCompilationService.ValidateTrustModel` delegates the trust verdict to `ScriptTrustValidator.FindViolations`; retains its own `CSharpScript.Compile` against the real `ScriptGlobals` for execution-time compilation.
|
- **Site Runtime (#3)**: `ScriptCompilationService.ValidateTrustModel` delegates the trust verdict to `ScriptTrustValidator.FindViolations`; retains its own `CSharpScript.Compile` against the real `ScriptGlobals` for execution-time compilation.
|
||||||
- **Inbound API (#14)**: `ForbiddenApiChecker.FindViolations` is a thin shim over `ScriptTrustValidator.FindViolations`.
|
- **Inbound API (#14)**: `ForbiddenApiChecker.FindViolations` is a thin shim over `ScriptTrustValidator.FindViolations`.
|
||||||
- **Central UI (#9)**: `ScriptAnalysisService` delegates the run-gate forbidden-API verdict and sources the editor-marker deny-list from `ScriptTrustPolicy`; retains the Test-Run execution host (`SandboxScriptHost`).
|
- **Central UI (#9)**: `ScriptAnalysisService` delegates the run-gate forbidden-API verdict and sources the editor-marker deny-list from `ScriptTrustPolicy`; retains the Test-Run execution host (`SandboxScriptHost`).
|
||||||
- **Transport (#24)**: `BundleImporter` runs `ScriptTrustValidator.FindViolations` over every non-Skip template, shared, and ApiMethod script body during import validation (`RunSemanticValidationAsync` Pass 0) and preview (`DetectBlockersAsync`), rejecting forbidden-API scripts at import review rather than at runtime. Trust violations are hard errors for all script kinds (unlike the false-positive-prone name-resolution heuristic, whose template-script findings are advisory).
|
- **Transport (#24)**: `BundleImporter` runs `ScriptTrustValidator.FindViolations` over every non-Skip template, shared, and ApiMethod script body, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`) during import validation (`RunSemanticValidationAsync` Pass 0) and preview (`DetectBlockersAsync`), rejecting forbidden-API scripts at import review rather than at runtime. Trust violations are hard errors for all script kinds (unlike the false-positive-prone name-resolution heuristic, whose template-script findings are advisory).
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ Before a deployment is sent to a site, the Template Engine performs comprehensiv
|
|||||||
- **Script compilation and trust check**: All instance scripts and alarm on-trigger scripts are compiled via `ScriptCompiler.TryCompile`, which performs a real Roslyn type-checking compile (using `ScriptCompileSurface` from the Script Analysis component as the globals type) and an authoritative forbidden-API check (via `ScriptTrustValidator.FindViolations`). Scripts with compilation errors, type errors, or forbidden-API violations are rejected. This gate is authoritative — not advisory — meaning alias, `using static`, and `global::` bypasses are caught by semantic symbol resolution.
|
- **Script compilation and trust check**: All instance scripts and alarm on-trigger scripts are compiled via `ScriptCompiler.TryCompile`, which performs a real Roslyn type-checking compile (using `ScriptCompileSurface` from the Script Analysis component as the globals type) and an authoritative forbidden-API check (via `ScriptTrustValidator.FindViolations`). Scripts with compilation errors, type errors, or forbidden-API violations are rejected. This gate is authoritative — not advisory — meaning alias, `using static`, and `global::` bypasses are caught by semantic symbol resolution.
|
||||||
- **Alarm trigger references**: Alarm trigger definitions reference attributes that exist in the flattened configuration.
|
- **Alarm trigger references**: Alarm trigger definitions reference attributes that exist in the flattened configuration.
|
||||||
- **Script trigger references**: Script triggers (value change, conditional) reference attributes that exist in the flattened configuration.
|
- **Script trigger references**: Script triggers (value change, conditional) reference attributes that exist in the flattened configuration.
|
||||||
|
- **Expression-trigger syntax/trust check**: Each Expression-triggered script/alarm has its boolean expression run through `ValidationService.CheckExpressionSyntax` — a `ScriptTrustValidator.FindViolations` forbidden-API verdict followed by a real Roslyn compile against `TriggerCompileSurface`. Both stages report **all** findings (every forbidden API, every compile error), not just the first, so an operator fixing a multi-error expression sees the full list in one round-trip. The verdict is memoised in the surface-keyed `ScriptCompileVerdictCache`, so an unchanged expression compiles once per process.
|
||||||
|
- **Read-only skip (`validateScriptCompilation: false`)**: The `Validate` flag that read-only staleness/comparison callers (DeploymentManager's `FlatteningPipeline` on its comparison/probe paths) pass to skip the expensive Roslyn script-body compile now **also** skips the Expression-trigger syntax/compile check — the two compile-bearing stages. The cheap structural checks (blank-expression, attribute-reference scan) still run, and the deploy gate keeps the default (`true`), so it remains the authoritative check for both stages.
|
||||||
- **Data connection binding completeness**: Every attribute with a data source reference has a data connection binding assigned on the instance, and the bound data connection name exists as a defined connection at the instance's site.
|
- **Data connection binding completeness**: Every attribute with a data source reference has a data connection binding assigned on the instance, and the bound data connection name exists as a defined connection at the instance's site.
|
||||||
- **Exception**: Validation does **not** verify that data source relative paths resolve to real tags on physical devices — that is a runtime concern.
|
- **Exception**: Validation does **not** verify that data source relative paths resolve to real tags on physical devices — that is a runtime concern.
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
|||||||
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
||||||
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
||||||
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, a Blocker (import-stopping), or a Warning (advisory, non-blocking).
|
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, a Blocker (import-stopping), or a Warning (advisory, non-blocking).
|
||||||
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running multi-pass semantic validation before committing: a **script trust gate** (`ScriptTrustValidator.FindViolations` over every non-Skip template / shared / ApiMethod body — bundle import is the fifth script-trust call site, so forbidden-API scripts are rejected at import review, not deferred to runtime), then a minimal name-resolution scan over the merged target (ApiMethod findings fail fast on unresolved SharedScript / ExternalSystem identifiers; template-script findings are advisory warnings), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
|
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running multi-pass semantic validation before committing: a **script trust gate** (`ScriptTrustValidator.FindViolations` over every non-Skip executable C# surface a bundle carries — template / shared / ApiMethod script bodies, template script **and** alarm **Expression-trigger** bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`); bundle import is the fifth script-trust call site, so forbidden-API scripts are rejected at import review, not deferred to runtime — all these surfaces hard-block uniformly, since the trust gate's semantic verdict is authoritative with no false-positive channel, and the advisory-vs-hard severity split is a property of the name-resolution heuristic only), then a minimal name-resolution scan over the merged target (ApiMethod findings fail fast on unresolved SharedScript / ExternalSystem identifiers; template-script findings are advisory warnings), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
|
||||||
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, `BundleImportCompositionUnresolved`, and `BundleImportBaseTemplateUnresolved` audit events via `IAuditService`.
|
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, `BundleImportCompositionUnresolved`, and `BundleImportBaseTemplateUnresolved` audit events via `IAuditService`.
|
||||||
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
|
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
|
||||||
- Enforce `RequireDesign` on export and `RequireAdmin` on import both at the Razor page layer and inside the service entrypoints (defense in depth).
|
- Enforce `RequireDesign` on export and `RequireAdmin` on import both at the Razor page layer and inside the service entrypoints (defense in depth).
|
||||||
@@ -133,7 +133,7 @@ flowchart TD
|
|||||||
class T muted
|
class T muted
|
||||||
```
|
```
|
||||||
|
|
||||||
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
|
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
|
||||||
|
|
||||||
**Preview→apply window (recorded decision).** The diff/preview an operator reviews is a point-in-time snapshot; there is **no optimistic concurrency** between preview and apply. If the target config changes in the window between `PreviewAsync` and `ApplyAsync` (another admin edits a template, say), the apply proceeds against the current state — the resolutions are keyed by `(EntityType, Name)`, so they still target the right rows, but the operator may not have seen the latest state in the diff. This is accepted for v1 (single-operator import is the expected workflow, and the terminal Confirm step already gates a typo-resistant environment-name entry). The bundle-format `version` fields are reserved for a future optimistic-concurrency check but are not yet enforced (Underdeveloped 5).
|
**Preview→apply window (recorded decision).** The diff/preview an operator reviews is a point-in-time snapshot; there is **no optimistic concurrency** between preview and apply. If the target config changes in the window between `PreviewAsync` and `ApplyAsync` (another admin edits a template, say), the apply proceeds against the current state — the resolutions are keyed by `(EntityType, Name)`, so they still target the right rows, but the operator may not have seen the latest state in the diff. This is accepted for v1 (single-operator import is the expected workflow, and the terminal Confirm step already gates a typo-resistant environment-name entry). The bundle-format `version` fields are reserved for a future optimistic-concurrency check but are not yet enforced (Underdeveloped 5).
|
||||||
|
|
||||||
@@ -233,7 +233,11 @@ Bundle references that cannot be satisfied in either the bundle or the target DB
|
|||||||
|
|
||||||
**Blocker-scan heuristic boundaries.** The scanner walks `TemplateScript.Code`, `TemplateAttribute.Value`, and `ApiMethod.Script` looking for top-level `Identifier(` or `Identifier.` tokens. To keep the heuristic usable on real script bodies it (a) skips identifiers preceded by `.` (member access — `obj.Method()` does not flag `Method`); (b) does NOT scan `TemplateAttribute.DataSourceReference` (an OPC UA address path, never script source); (c) excludes names **declared locally in the body** — every `LocalFunctionStatement` / `MethodDeclaration` identifier is collected via a Roslyn script-kind parse, so a script's own helper (`decimal ComputeRate(...) => …; return ComputeRate(x);`) is not mistaken for a missing SharedScript; and (d) filters out a `KnownNonReferenceNames` denylist of .NET stdlib types (`Convert`, `DateTimeOffset`, `Regex`, `StringBuilder`, `Parse`, LINQ operators, …), ScadaBridge runtime API roots (`Notify`, `Database`, `ExternalSystem`, `Scripts`, `Instance`, `Parameters`, `Attributes`, `Route`, …), and common SQL keywords that appear inside string literals (`COUNT`, `SELECT`, `FROM`, `HAVING`, `VALUES`, …). Both the diff-step `DetectBlockersAsync` and the Apply-time `RunSemanticValidationAsync` Pass 1 share this filter, so the diff preview and the Apply gate agree.
|
**Blocker-scan heuristic boundaries.** The scanner walks `TemplateScript.Code`, `TemplateAttribute.Value`, and `ApiMethod.Script` looking for top-level `Identifier(` or `Identifier.` tokens. To keep the heuristic usable on real script bodies it (a) skips identifiers preceded by `.` (member access — `obj.Method()` does not flag `Method`); (b) does NOT scan `TemplateAttribute.DataSourceReference` (an OPC UA address path, never script source); (c) excludes names **declared locally in the body** — every `LocalFunctionStatement` / `MethodDeclaration` identifier is collected via a Roslyn script-kind parse, so a script's own helper (`decimal ComputeRate(...) => …; return ComputeRate(x);`) is not mistaken for a missing SharedScript; and (d) filters out a `KnownNonReferenceNames` denylist of .NET stdlib types (`Convert`, `DateTimeOffset`, `Regex`, `StringBuilder`, `Parse`, LINQ operators, …), ScadaBridge runtime API roots (`Notify`, `Database`, `ExternalSystem`, `Scripts`, `Instance`, `Parameters`, `Attributes`, `Route`, …), and common SQL keywords that appear inside string literals (`COUNT`, `SELECT`, `FROM`, `HAVING`, `VALUES`, …). Both the diff-step `DetectBlockersAsync` and the Apply-time `RunSemanticValidationAsync` Pass 1 share this filter, so the diff preview and the Apply gate agree.
|
||||||
|
|
||||||
**Severity split — template-script findings are advisory, ApiMethod findings block.** A residual unresolved identifier (one the heuristic could not filter) is classified by its **origin**. Findings from **template scripts** and **attribute default expressions** are non-blocking **warnings**: they surface as `ConflictKind.Warning` rows in the preview and ride on `ImportResult.Warnings` after a successful apply — the import proceeds because the authoritative design-time **deploy gate re-validates** the script with a real compile (`ScriptCompiler.TryCompile`), so a heuristic false positive here must never stop a valid import. Findings from **ApiMethod scripts** remain **hard errors** (`ConflictKind.Blocker` in preview; `SemanticValidationException` at apply) because inbound API methods have no downstream design-time gate — the import review is their only pre-runtime check. The split is applied identically in `DetectBlockersAsync` and `RunSemanticValidationAsync` Pass 1 so preview and apply agree.
|
**Severity split — template-script findings are advisory, ApiMethod findings block.** A residual unresolved identifier (one the heuristic could not filter) is classified by its **origin**. Findings from **template scripts** and **attribute default expressions** are non-blocking **warnings**: they surface as `ConflictKind.Warning` rows in the preview and ride on `ImportResult.Warnings` after a successful apply — the import proceeds because the authoritative design-time **deploy gate re-validates** the script with a real compile (`ScriptCompiler.TryCompile`), so a heuristic false positive here must never stop a valid import. Findings from **ApiMethod scripts** remain **hard errors** (`ConflictKind.Blocker` in preview; `SemanticValidationException` at apply) because inbound API methods have no downstream design-time gate — the import review is their only pre-runtime check. The split is applied identically in `DetectBlockersAsync` and `RunSemanticValidationAsync` Pass 1 so preview and apply agree. (This advisory-vs-hard split is a property of the name-resolution **heuristic** only — it is false-positive-prone; the **script trust gate** above has no false-positive channel, so its verdict is a hard error for every gated surface, template trigger expressions and instance alarm-override trigger expressions included.)
|
||||||
|
|
||||||
|
**Locked-member override warnings (advisory).** An instance override — attribute (`FlatteningService` drop), alarm, or native-alarm-source — that targets a **locked** template member is silently inert: the flattener ignores it. To stop that being invisible, a best-effort scan (`CollectLockedOverrideWarnings`) surfaces each such override as a `ConflictKind.Warning` preview row and on `ImportResult.Warnings` after apply, matching locked members by **direct name** (own + inherited placeholder rows both carry the `IsLocked` flag). It never blocks the import and never changes what is written — the override row is still persisted verbatim (bundle fidelity); the flattener remains the behavioural authority, and composed path-qualified members or derived-shadow (`LockedInDerived`) locks a placeholder row does not reflect emit no warning (never a false positive — the ManagementActor and flattener stay the enforcement points).
|
||||||
|
|
||||||
|
**Script-artifact change bus (post-commit).** After a successful apply the importer publishes one `ScriptArtifactsChanged` per script-bearing kind (ApiMethod / SharedScript / Template) for **every non-Skip resolution — Overwrite, Rename, AND Add** — because an artifact deleted on the target and re-imported as `Add` under the same name can still leave a stale compiled-handler/`_knownBadMethods` entry on a node; over-approximation is safe (the bus is advisory, at-least-once, and consumers self-heal). The **subscriber** side (Inbound API cache invalidation wiring) lives with the Inbound API component — see PLAN-R2-06.
|
||||||
|
|
||||||
**Step 5 — Confirm.** Final summary plus a "N instances will become stale" warning enumerating affected instances (a real count as of M8 — see "Stale-Instance Signaling"). User types the source environment name to confirm (typo-resistant gate at the prod boundary).
|
**Step 5 — Confirm.** Final summary plus a "N instances will become stale" warning enumerating affected instances (a real count as of M8 — see "Stale-Instance Signaling"). User types the source environment name to confirm (typo-resistant gate at the prod boundary).
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,25 @@ using System.Text;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Process-wide cache of script compile verdicts, keyed on a SHA-256 hash of the
|
/// Process-wide cache of script compile verdicts, keyed on the pair
|
||||||
/// script code. The verdict (clean/compiles, or the name-free error text) is a pure
|
/// (globals surface, SHA-256 hash of the script code). The verdict (clean/compiles,
|
||||||
/// function of the code plus the compile-time-static <c>ScriptTrustPolicy</c> and
|
/// or the name-free error text) is a pure function of the code plus the
|
||||||
/// globals surface, so it is sound to memoise it across the whole process: an
|
/// compile-time-static <c>ScriptTrustPolicy</c> AND the globals surface it was
|
||||||
|
/// compiled against, so it is sound to memoise it across the whole process: an
|
||||||
/// unchanged script runs the forbidden-API analysis and the Roslyn compile exactly
|
/// unchanged script runs the forbidden-API analysis and the Roslyn compile exactly
|
||||||
/// once, removing both the repeat CPU cost and the repeat compiled-assembly load.
|
/// once, removing both the repeat CPU cost and the repeat compiled-assembly load.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// The globals surface is part of the key because a verdict is NOT interchangeable
|
||||||
|
/// across surfaces: a trigger expression that compiles clean against
|
||||||
|
/// <c>TriggerCompileSurface</c> is not vetted for a <c>ScriptCompileSurface</c>
|
||||||
|
/// script body (different globals resolve different identifiers), so a code-only key
|
||||||
|
/// could return a stale "clean" for code never compiled against the caller's surface.
|
||||||
|
/// Keeping the surface in the key makes that cross-surface reuse structurally
|
||||||
|
/// impossible even as more compile surfaces gain cache entries.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
/// The stored error is deliberately <em>name-free</em> — the caller's script name is
|
/// The stored error is deliberately <em>name-free</em> — the caller's script name is
|
||||||
/// formatted into the returned message at read time, so two callers submitting the
|
/// formatted into the returned message at read time, so two callers submitting the
|
||||||
/// same bad code each see their own name in the failure text while sharing one verdict.
|
/// same bad code each see their own name in the failure text while sharing one verdict.
|
||||||
@@ -40,17 +51,22 @@ public static class ScriptCompileVerdictCache
|
|||||||
public static int Count => Cache.Count;
|
public static int Count => Cache.Count;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the cached verdict for <paramref name="code"/>, or computes it via
|
/// Returns the cached verdict for the pair (<paramref name="surface"/>,
|
||||||
/// <paramref name="factory"/> and caches it on a miss. A hit increments
|
/// <paramref name="code"/>), or computes it via <paramref name="factory"/> and
|
||||||
/// <see cref="Hits"/>. The verdict's error text must be name-free — the caller
|
/// caches it on a miss. A hit increments <see cref="Hits"/>. The verdict's error
|
||||||
/// formats the script name in on return.
|
/// text must be name-free — the caller formats the script name in on return.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="surface">
|
||||||
|
/// The globals-surface discriminator (e.g. <c>nameof(ScriptCompileSurface)</c> or
|
||||||
|
/// <c>nameof(TriggerCompileSurface)</c>). Part of the key so a verdict computed
|
||||||
|
/// against one surface is never reused for another.
|
||||||
|
/// </param>
|
||||||
/// <param name="code">The script source code to look up (hashed to form the cache key).</param>
|
/// <param name="code">The script source code to look up (hashed to form the cache key).</param>
|
||||||
/// <param name="factory">Computes the verdict on a cache miss.</param>
|
/// <param name="factory">Computes the verdict on a cache miss.</param>
|
||||||
/// <returns>The cached or newly computed compile verdict.</returns>
|
/// <returns>The cached or newly computed compile verdict.</returns>
|
||||||
public static (bool Ok, string? Error) GetOrAdd(string code, Func<(bool Ok, string? Error)> factory)
|
public static (bool Ok, string? Error) GetOrAdd(string surface, string code, Func<(bool Ok, string? Error)> factory)
|
||||||
{
|
{
|
||||||
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
|
var key = surface + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
|
||||||
|
|
||||||
if (Cache.TryGetValue(key, out var verdict))
|
if (Cache.TryGetValue(key, out var verdict))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class ScriptCompiler
|
|||||||
// globals surface are compile-time static), so memoise it process-wide: an
|
// globals surface are compile-time static), so memoise it process-wide: an
|
||||||
// unchanged script compiles exactly once per process. The cached error is
|
// unchanged script compiles exactly once per process. The cached error is
|
||||||
// name-free — the script name is formatted in below at read time.
|
// name-free — the script name is formatted in below at read time.
|
||||||
var (ok, error) = ScriptCompileVerdictCache.GetOrAdd(code, () =>
|
var (ok, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(ScriptCompileSurface), code, () =>
|
||||||
{
|
{
|
||||||
// Authoritative forbidden-API verdict first — a security violation must
|
// Authoritative forbidden-API verdict first — a security violation must
|
||||||
// gate the script regardless of whether it otherwise compiles. Report
|
// gate the script regardless of whether it otherwise compiles. Report
|
||||||
|
|||||||
@@ -97,11 +97,16 @@ public class ValidationService
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="validateScriptCompilation">
|
/// <param name="validateScriptCompilation">
|
||||||
/// <c>true</c> (default) runs the authoritative Roslyn <see cref="ValidateScriptCompilation"/>
|
/// <c>true</c> (default) runs the authoritative Roslyn <see cref="ValidateScriptCompilation"/>
|
||||||
/// stage — the single most expensive check (a non-collectible assembly load per script).
|
/// stage — the single most expensive check (a non-collectible assembly load per script) —
|
||||||
/// <c>false</c> skips ONLY that stage; every other structural/semantic check still runs.
|
/// AND the Expression-trigger syntax/compile check
|
||||||
/// Read-only staleness/comparison callers (DeploymentManager's FlatteningPipeline on its
|
/// (<see cref="CheckExpressionSyntax"/>, a forbidden-API verdict plus a real compile of every
|
||||||
/// comparison/probe paths) pass <c>false</c> because they need only the flattened config and
|
/// Expression-triggered script/alarm body against <see cref="TriggerCompileSurface"/>).
|
||||||
/// revision hash, not a compile. The deploy gate keeps the default (<c>true</c>).
|
/// <c>false</c> skips ONLY those two compile stages; every other structural/semantic check
|
||||||
|
/// still runs — including, for Expression triggers, the blank-expression check and the
|
||||||
|
/// cheap attribute-reference scan. Read-only staleness/comparison callers (DeploymentManager's
|
||||||
|
/// FlatteningPipeline on its comparison/probe paths) pass <c>false</c> because they need only
|
||||||
|
/// the flattened config and revision hash, not a compile. The deploy gate keeps the default
|
||||||
|
/// (<c>true</c>) — it is the authoritative check for both stages.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
|
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
|
||||||
public ValidationResult Validate(
|
public ValidationResult Validate(
|
||||||
@@ -128,7 +133,7 @@ public class ValidationService
|
|||||||
validateScriptCompilation ? ValidateScriptCompilation(configuration) : ValidationResult.Success(),
|
validateScriptCompilation ? ValidateScriptCompilation(configuration) : ValidationResult.Success(),
|
||||||
ValidateAlarmTriggerReferences(configuration),
|
ValidateAlarmTriggerReferences(configuration),
|
||||||
ValidateScriptTriggerReferences(configuration),
|
ValidateScriptTriggerReferences(configuration),
|
||||||
ValidateExpressionTriggers(configuration),
|
ValidateExpressionTriggers(configuration, validateScriptCompilation),
|
||||||
ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
|
ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
|
||||||
ValidateSchemaReferences(configuration, sharedScripts, resolveSchemaRef),
|
ValidateSchemaReferences(configuration, sharedScripts, resolveSchemaRef),
|
||||||
_semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
|
_semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
|
||||||
@@ -366,8 +371,16 @@ public class ValidationService
|
|||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||||
|
/// <param name="validateExpressionSyntax">
|
||||||
|
/// <c>true</c> (default) runs the authoritative <see cref="CheckExpressionSyntax"/>
|
||||||
|
/// forbidden-API + compile stage for each Expression trigger. <c>false</c> skips ONLY
|
||||||
|
/// that stage — the blank-expression check and the attribute-reference scan still run —
|
||||||
|
/// so read-only staleness/comparison callers do not pay the per-expression compile.
|
||||||
|
/// </param>
|
||||||
/// <returns>A <see cref="ValidationResult"/> with errors and warnings from all expression trigger checks.</returns>
|
/// <returns>A <see cref="ValidationResult"/> with errors and warnings from all expression trigger checks.</returns>
|
||||||
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
|
public static ValidationResult ValidateExpressionTriggers(
|
||||||
|
FlattenedConfiguration configuration,
|
||||||
|
bool validateExpressionSyntax = true)
|
||||||
{
|
{
|
||||||
var errors = new List<ValidationEntry>();
|
var errors = new List<ValidationEntry>();
|
||||||
var warnings = new List<ValidationEntry>();
|
var warnings = new List<ValidationEntry>();
|
||||||
@@ -382,7 +395,7 @@ public class ValidationService
|
|||||||
CheckExpressionTrigger(
|
CheckExpressionTrigger(
|
||||||
ValidationCategory.ScriptTriggerReference, "script",
|
ValidationCategory.ScriptTriggerReference, "script",
|
||||||
script.CanonicalName, script.TriggerConfiguration,
|
script.CanonicalName, script.TriggerConfiguration,
|
||||||
attributeNames, errors, warnings);
|
attributeNames, errors, warnings, validateExpressionSyntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var alarm in configuration.Alarms)
|
foreach (var alarm in configuration.Alarms)
|
||||||
@@ -393,7 +406,7 @@ public class ValidationService
|
|||||||
CheckExpressionTrigger(
|
CheckExpressionTrigger(
|
||||||
ValidationCategory.AlarmTriggerReference, "alarm",
|
ValidationCategory.AlarmTriggerReference, "alarm",
|
||||||
alarm.CanonicalName, alarm.TriggerConfiguration,
|
alarm.CanonicalName, alarm.TriggerConfiguration,
|
||||||
attributeNames, errors, warnings);
|
attributeNames, errors, warnings, validateExpressionSyntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||||
@@ -424,7 +437,8 @@ public class ValidationService
|
|||||||
string? triggerConfigJson,
|
string? triggerConfigJson,
|
||||||
HashSet<string> attributeNames,
|
HashSet<string> attributeNames,
|
||||||
List<ValidationEntry> errors,
|
List<ValidationEntry> errors,
|
||||||
List<ValidationEntry> warnings)
|
List<ValidationEntry> warnings,
|
||||||
|
bool validateExpressionSyntax = true)
|
||||||
{
|
{
|
||||||
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
|
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
|
||||||
var strict = IsStrictAnalysis(triggerConfigJson);
|
var strict = IsStrictAnalysis(triggerConfigJson);
|
||||||
@@ -443,12 +457,20 @@ public class ValidationService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var syntaxError = CheckExpressionSyntax(expression);
|
// The syntax/compile stage is the expensive one (a forbidden-API verdict + a
|
||||||
if (syntaxError != null)
|
// real Roslyn compile of the expression). Read-only staleness/comparison
|
||||||
|
// callers pass validateExpressionSyntax: false to skip ONLY this stage; the
|
||||||
|
// blank-expression check above and the attribute-reference scan below still run.
|
||||||
|
// The deploy gate keeps the default (true) — the authoritative check.
|
||||||
|
if (validateExpressionSyntax)
|
||||||
{
|
{
|
||||||
errors.Add(ValidationEntry.Error(category,
|
var syntaxError = CheckExpressionSyntax(expression);
|
||||||
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
|
if (syntaxError != null)
|
||||||
entityName));
|
{
|
||||||
|
errors.Add(ValidationEntry.Error(category,
|
||||||
|
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
|
||||||
|
entityName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var attrName in ExtractAttributeReferences(expression))
|
foreach (var attrName in ExtractAttributeReferences(expression))
|
||||||
@@ -530,17 +552,27 @@ public class ValidationService
|
|||||||
/// <returns>A human-readable error message if the expression is invalid; <c>null</c> if well-formed.</returns>
|
/// <returns>A human-readable error message if the expression is invalid; <c>null</c> if well-formed.</returns>
|
||||||
internal static string? CheckExpressionSyntax(string expression)
|
internal static string? CheckExpressionSyntax(string expression)
|
||||||
{
|
{
|
||||||
// Authoritative forbidden-API verdict first.
|
// Memoise the verdict under the TRIGGER surface key (see ScriptCompileVerdictCache):
|
||||||
var violations = ScriptTrustValidator.FindViolations(expression);
|
// the verdict is a pure function of expression + policy + TriggerCompileSurface, and
|
||||||
if (violations.Count > 0)
|
// it is the exact hot-path cost N1 flagged — every staleness sweep / import probe of
|
||||||
return $"uses forbidden API: {violations[0]}";
|
// an Expression-triggered config re-ran a full trust compilation + script compile.
|
||||||
|
var (_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () =>
|
||||||
|
{
|
||||||
|
// Authoritative forbidden-API verdict first. Report ALL violations, not
|
||||||
|
// just the first, so an operator fixing one forbidden API isn't surprised
|
||||||
|
// by a second on the next deploy attempt (mirrors ScriptCompiler.TryCompile).
|
||||||
|
var violations = ScriptTrustValidator.FindViolations(expression);
|
||||||
|
if (violations.Count > 0)
|
||||||
|
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
|
||||||
|
|
||||||
// Real compile of the bare boolean expression against the trigger globals.
|
// Real compile of the bare boolean expression against the trigger globals.
|
||||||
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
|
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
return $"is not a valid expression: {errors[0]}";
|
return (false, $"is not a valid expression: {string.Join("; ", errors)}");
|
||||||
|
|
||||||
return null;
|
return (true, (string?)null);
|
||||||
|
});
|
||||||
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -684,8 +684,9 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// its new name, but at preview time no resolution has been chosen yet, so
|
// its new name, but at preview time no resolution has been chosen yet, so
|
||||||
// the in-bundle name is the original DTO name — which is what an instance
|
// the in-bundle name is the original DTO name — which is what an instance
|
||||||
// references. (Explicit rename remap is a D-wave apply-time concern.)
|
// references. (Explicit rename remap is a D-wave apply-time concern.)
|
||||||
|
var targetTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||||||
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
|
foreach (var t in targetTemplates)
|
||||||
{
|
{
|
||||||
targetTemplateNames.Add(t.Name);
|
targetTemplateNames.Add(t.Name);
|
||||||
}
|
}
|
||||||
@@ -883,6 +884,22 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// N4: advisory warning rows for instance overrides that target a LOCKED
|
||||||
|
// template member — the flattener drops them, so surface the inert row in the
|
||||||
|
// wizard (Kind: Warning, non-blocking). resolutionMap is null at preview time,
|
||||||
|
// so every instance is scanned.
|
||||||
|
foreach (var (instance, message) in CollectLockedOverrideWarnings(content, resolutionMap: null, targetTemplates))
|
||||||
|
{
|
||||||
|
blockers.Add(new ImportPreviewItem(
|
||||||
|
EntityType: "Instance",
|
||||||
|
Name: instance,
|
||||||
|
ExistingVersion: null,
|
||||||
|
IncomingVersion: null,
|
||||||
|
Kind: ConflictKind.Warning,
|
||||||
|
FieldDiffJson: null,
|
||||||
|
BlockerReason: message));
|
||||||
|
}
|
||||||
|
|
||||||
return blockers;
|
return blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,8 +963,11 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerates every executable C# surface a bundle carries that the
|
/// Enumerates every executable C# surface a bundle carries that the
|
||||||
/// trust gate must vet: non-Skip template scripts + their Expression-trigger
|
/// trust gate must vet: non-Skip template scripts + their Expression-trigger
|
||||||
/// bodies, template alarm Expression-trigger bodies, shared scripts, and
|
/// bodies, template alarm Expression-trigger bodies, shared scripts,
|
||||||
/// ApiMethod scripts. Expression triggers compile and execute at the site
|
/// ApiMethod scripts, AND instance alarm-override trigger expressions
|
||||||
|
/// (<c>InstanceAlarmOverrideDto.TriggerConfigurationOverride</c>). Expression
|
||||||
|
/// triggers — template alarms AND the instance overrides that replace them in
|
||||||
|
/// the flattened config — compile and execute at the site
|
||||||
/// (`TriggerExpressionGlobals`), so they are a genuine trust surface, not just
|
/// (`TriggerExpressionGlobals`), so they are a genuine trust surface, not just
|
||||||
/// script bodies. A null <paramref name="resolutionMap"/> (preview time, before
|
/// script bodies. A null <paramref name="resolutionMap"/> (preview time, before
|
||||||
/// resolutions are chosen) gates everything. Yields <c>(kind, entityName,
|
/// resolutions are chosen) gates everything. Yields <c>(kind, entityName,
|
||||||
@@ -998,6 +1018,25 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
yield return ("ApiMethod", m.Name, m.Name, m.Script);
|
yield return ("ApiMethod", m.Name, m.Name, m.Script);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
foreach (var i in content.Instances)
|
||||||
|
{
|
||||||
|
if (IsSkipResolution(resolutionMap, "Instance", i.UniqueName)) continue;
|
||||||
|
foreach (var o in i.AlarmOverrides)
|
||||||
|
{
|
||||||
|
// The DTO carries no trigger type (the type lives on the template
|
||||||
|
// alarm), so gate ANY override config carrying an {"expression":...}
|
||||||
|
// string body: if the overridden alarm is not Expression-triggered
|
||||||
|
// the body never executes, but vetting it anyway is fail-safe — the
|
||||||
|
// structured (HiLo/threshold) configs have no "expression" key, so
|
||||||
|
// there is no false-positive channel.
|
||||||
|
var expr = ExtractExpressionBody(o.TriggerConfigurationOverride);
|
||||||
|
if (!string.IsNullOrEmpty(expr))
|
||||||
|
{
|
||||||
|
yield return ("Instance", i.UniqueName,
|
||||||
|
$"{o.AlarmCanonicalName} (alarm trigger override expression)", expr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1009,8 +1048,24 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
private static string? ExtractTriggerExpression(string? triggerType, string? triggerConfigJson)
|
private static string? ExtractTriggerExpression(string? triggerType, string? triggerConfigJson)
|
||||||
{
|
{
|
||||||
if (triggerType is null
|
if (triggerType is null
|
||||||
|| !triggerType.Equals("Expression", StringComparison.OrdinalIgnoreCase)
|
|| !triggerType.Equals("Expression", StringComparison.OrdinalIgnoreCase))
|
||||||
|| string.IsNullOrWhiteSpace(triggerConfigJson))
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ExtractExpressionBody(triggerConfigJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the C# boolean expression from an <c>{"expression":"…"}</c> trigger
|
||||||
|
/// configuration, WITHOUT a trigger-type guard — used where the caller has no
|
||||||
|
/// trigger type to check (an instance alarm override; the type lives on the
|
||||||
|
/// template alarm). A config carrying no <c>"expression"</c> string key (the
|
||||||
|
/// structured HiLo/threshold shapes) or malformed JSON yields null — nothing to
|
||||||
|
/// gate.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ExtractExpressionBody(string? triggerConfigJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(triggerConfigJson))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1092,85 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
=> resolutionMap != null
|
=> resolutionMap != null
|
||||||
&& ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;
|
&& ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort scan for instance overrides that target a LOCKED template member.
|
||||||
|
/// The flattener drops such overrides (an override on a locked attribute / alarm /
|
||||||
|
/// native-alarm-source never takes effect), so the bundle carries an inert row —
|
||||||
|
/// this surfaces an advisory warning so the operator learns the config won't apply,
|
||||||
|
/// WITHOUT blocking the import or changing what gets written (bundle fidelity keeps
|
||||||
|
/// the row; the flattener stays the behavioural authority).
|
||||||
|
/// <para>
|
||||||
|
/// Matches locked members by DIRECT name only — own + inherited placeholder rows
|
||||||
|
/// both carry the <c>IsLocked</c> flag, so a lock on either matches. Overrides
|
||||||
|
/// addressing composed path-qualified members (<c>y1.z.Val</c>) or derived-shadow
|
||||||
|
/// locks (<c>LockedInDerived</c> on a base the placeholder row doesn't reflect) may
|
||||||
|
/// not match a direct row — an unmatched name emits NO warning (never a false
|
||||||
|
/// positive; the ManagementActor and flattener remain the enforcement points). The
|
||||||
|
/// instance's template is resolved from the bundle DTO (the version about to be
|
||||||
|
/// written) first, else the pre-existing target template.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<(string Instance, string Message)> CollectLockedOverrideWarnings(
|
||||||
|
BundleContentDto content,
|
||||||
|
Dictionary<(string, string), ImportResolution>? resolutionMap,
|
||||||
|
IReadOnlyList<Template> targetTemplates)
|
||||||
|
{
|
||||||
|
var warnings = new List<(string, string)>();
|
||||||
|
|
||||||
|
var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
|
||||||
|
foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
|
||||||
|
var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||||
|
foreach (var t in targetTemplates) targetByName[t.Name] = t;
|
||||||
|
|
||||||
|
foreach (var inst in content.Instances)
|
||||||
|
{
|
||||||
|
if (IsSkipResolution(resolutionMap, "Instance", inst.UniqueName)) continue;
|
||||||
|
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
|
||||||
|
|
||||||
|
HashSet<string> lockedAttrs;
|
||||||
|
HashSet<string> lockedAlarms;
|
||||||
|
HashSet<string> lockedSources;
|
||||||
|
if (bundleTemplates.TryGetValue(inst.TemplateName, out var dto))
|
||||||
|
{
|
||||||
|
lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||||
|
lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||||
|
lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
else if (targetByName.TryGetValue(inst.TemplateName, out var tgt))
|
||||||
|
{
|
||||||
|
lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||||
|
lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||||
|
lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Template unresolvable — a separate blocker/validation covers that.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var o in inst.AttributeOverrides)
|
||||||
|
{
|
||||||
|
if (lockedAttrs.Contains(o.AttributeName))
|
||||||
|
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "attribute", o.AttributeName)));
|
||||||
|
}
|
||||||
|
foreach (var o in inst.AlarmOverrides)
|
||||||
|
{
|
||||||
|
if (lockedAlarms.Contains(o.AlarmCanonicalName))
|
||||||
|
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "alarm", o.AlarmCanonicalName)));
|
||||||
|
}
|
||||||
|
foreach (var o in inst.NativeAlarmSourceOverrides)
|
||||||
|
{
|
||||||
|
if (lockedSources.Contains(o.SourceCanonicalName))
|
||||||
|
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "native-alarm-source", o.SourceCanonicalName)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LockedOverrideMessage(string unique, string kind, string name) =>
|
||||||
|
$"Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — " +
|
||||||
|
"the flattener ignores it, so this part of the bundle's instance config will never take effect.";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Names that look like PascalCase references but are never user-defined
|
/// Names that look like PascalCase references but are never user-defined
|
||||||
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
|
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
|
||||||
@@ -1204,7 +1338,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
if (validationWarnings.Count > 0)
|
if (validationWarnings.Count > 0)
|
||||||
{
|
{
|
||||||
_logger?.LogWarning(
|
_logger?.LogWarning(
|
||||||
"Bundle import {BundleImportId}: {Count} advisory template-script reference warning(s): {Warnings}",
|
"Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}",
|
||||||
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
|
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1767,12 +1901,15 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Publishes one <see cref="ScriptArtifactsChanged"/> per script-bearing
|
/// Publishes one <see cref="ScriptArtifactsChanged"/> per script-bearing
|
||||||
/// artifact kind (ApiMethod / SharedScript / Template) whose resolution was an
|
/// artifact kind (ApiMethod / SharedScript / Template) for every non-Skip
|
||||||
/// Overwrite or Rename, using the post-resolution (renamed) names. Adds are
|
/// resolution — Overwrite, Rename, AND Add — using the post-resolution (renamed)
|
||||||
/// excluded — nothing is cached under a brand-new name yet. Over-approximation is
|
/// names. An Add is NOT "nothing cached yet": an artifact deleted on the target and
|
||||||
/// safe (an Overwrite that actually fell through to Add just triggers a harmless
|
/// re-imported as Add under the same name can still have a stale
|
||||||
/// consumer re-check), which matches the bus's advisory, at-least-once contract.
|
/// compiled-handler/<c>_knownBadMethods</c> entry on any node, so it must notify too.
|
||||||
/// Fully guarded: never throws (it runs after the transaction has committed).
|
/// Over-approximation stays safe (the bus is advisory, at-least-once; consumers
|
||||||
|
/// self-heal), which matches its stated contract. Only Skip resolutions are excluded
|
||||||
|
/// (nothing was written). Fully guarded: never throws (it runs after the transaction
|
||||||
|
/// has committed).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
|
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
|
||||||
{
|
{
|
||||||
@@ -1799,7 +1936,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
{
|
{
|
||||||
var names = all
|
var names = all
|
||||||
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
|
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
|
||||||
&& (r.Action is ResolutionAction.Overwrite or ResolutionAction.Rename))
|
&& r.Action is not ResolutionAction.Skip)
|
||||||
.Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
|
.Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -4334,6 +4471,15 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// (Pass 0 emits only errors), so nothing surfacable is dropped.
|
// (Pass 0 emits only errors), so nothing surfacable is dropped.
|
||||||
if (errors.Count > 0) return (errors, warnings);
|
if (errors.Count > 0) return (errors, warnings);
|
||||||
|
|
||||||
|
// N4: advisory warnings for instance overrides that target a LOCKED template
|
||||||
|
// member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
|
||||||
|
// still written (bundle fidelity) — the flattener is the behavioural authority.
|
||||||
|
var lockScanTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||||||
|
foreach (var (_, message) in CollectLockedOverrideWarnings(content, resolutionMap, lockScanTemplates))
|
||||||
|
{
|
||||||
|
warnings.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Pass 1: minimal name-resolution scan ----
|
// ---- Pass 1: minimal name-resolution scan ----
|
||||||
|
|
||||||
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
|
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ public sealed class TransportOptionsValidator : OptionsValidatorBase<TransportOp
|
|||||||
$"ScadaBridge:Transport:MaxUnlockAttemptsPerSession must be positive " +
|
$"ScadaBridge:Transport:MaxUnlockAttemptsPerSession must be positive " +
|
||||||
$"(was {options.MaxUnlockAttemptsPerSession}); it caps failed passphrase attempts before a session locks.");
|
$"(was {options.MaxUnlockAttemptsPerSession}); it caps failed passphrase attempts before a session locks.");
|
||||||
|
|
||||||
|
builder.RequireThat(options.MaxConcurrentImportSessions > 0,
|
||||||
|
$"ScadaBridge:Transport:MaxConcurrentImportSessions must be positive " +
|
||||||
|
$"(was {options.MaxConcurrentImportSessions}); a zero/negative cap rejects every import session forever.");
|
||||||
|
|
||||||
builder.RequireThat(options.MaxUnlockAttemptsPerIpPerHour > 0,
|
builder.RequireThat(options.MaxUnlockAttemptsPerIpPerHour > 0,
|
||||||
$"ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour must be positive " +
|
$"ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour must be positive " +
|
||||||
$"(was {options.MaxUnlockAttemptsPerIpPerHour}); it caps unlock attempts per IP address per hour.");
|
$"(was {options.MaxUnlockAttemptsPerIpPerHour}); it caps unlock attempts per IP address per hour.");
|
||||||
|
|||||||
@@ -149,6 +149,48 @@ public class ScriptCompilerTests
|
|||||||
Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit()
|
||||||
|
{
|
||||||
|
ScriptCompileVerdictCache.Clear();
|
||||||
|
ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
|
||||||
|
var hitsBefore = ScriptCompileVerdictCache.Hits;
|
||||||
|
var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
|
||||||
|
Assert.Null(error);
|
||||||
|
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface()
|
||||||
|
{
|
||||||
|
// "Notify != null" resolves on ScriptCompileSurface (Notify is a script global,
|
||||||
|
// ScriptCompileSurface.cs:53) but NOT on TriggerCompileSurface — a shared
|
||||||
|
// code-only cache entry would wrongly report the trigger expression clean.
|
||||||
|
ScriptCompileVerdictCache.Clear();
|
||||||
|
Assert.True(new ScriptCompiler().TryCompile("Notify != null", "S").IsSuccess); // warms the SCRIPT-surface entry
|
||||||
|
var error = ValidationService.CheckExpressionSyntax("Notify != null");
|
||||||
|
Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll()
|
||||||
|
{
|
||||||
|
var error = ValidationService.CheckExpressionSyntax(
|
||||||
|
"System.IO.File.Exists(\"x\") && System.Diagnostics.Process.GetProcesses().Length > 0");
|
||||||
|
Assert.NotNull(error);
|
||||||
|
Assert.Contains("System.IO", error); // FAILS today: only violations[0] is surfaced
|
||||||
|
Assert.Contains("Process", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckExpressionSyntax_MultipleCompileErrors_ReportsAll()
|
||||||
|
{
|
||||||
|
var error = ValidationService.CheckExpressionSyntax("NoSuchThingA > 1 && NoSuchThingB < 2");
|
||||||
|
Assert.NotNull(error);
|
||||||
|
Assert.Contains("NoSuchThingA", error); // FAILS today: only errors[0] is surfaced
|
||||||
|
Assert.Contains("NoSuchThingB", error);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Compile-verdict cache: unchanged code compiles once per process ---
|
// --- Compile-verdict cache: unchanged code compiles once per process ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -163,6 +205,20 @@ public class ScriptCompilerTests
|
|||||||
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
|
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetOrAdd_SameCodeDifferentSurface_ComputesSeparateVerdicts()
|
||||||
|
{
|
||||||
|
ScriptCompileVerdictCache.Clear();
|
||||||
|
var a = ScriptCompileVerdictCache.GetOrAdd("SurfaceA", "return 1;", () => (true, null));
|
||||||
|
var hitsAfterA = ScriptCompileVerdictCache.Hits;
|
||||||
|
var b = ScriptCompileVerdictCache.GetOrAdd("SurfaceB", "return 1;", () => (false, "err"));
|
||||||
|
|
||||||
|
Assert.True(a.Ok);
|
||||||
|
Assert.False(b.Ok); // code-only key would return SurfaceA's verdict
|
||||||
|
Assert.Equal(hitsAfterA, ScriptCompileVerdictCache.Hits); // no false cross-surface hit
|
||||||
|
Assert.Equal(2, ScriptCompileVerdictCache.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryCompile_CachedFailure_FormatsNameForEachCaller()
|
public void TryCompile_CachedFailure_FormatsNameForEachCaller()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -462,4 +462,43 @@ public class ValidationServiceTests
|
|||||||
Assert.DoesNotContain(strictResult.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
|
Assert.DoesNotContain(strictResult.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
|
||||||
Assert.DoesNotContain(strictResult.Warnings, w => w.Category == ValidationCategory.AlarmTriggerReference);
|
Assert.DoesNotContain(strictResult.Warnings, w => w.Category == ValidationCategory.AlarmTriggerReference);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_SkipCompilation_SkipsExpressionTriggerSyntaxCheck()
|
||||||
|
{
|
||||||
|
var config = ConfigWithExpressionTriggerScript("this is not C# ((");
|
||||||
|
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
|
||||||
|
Assert.DoesNotContain(gated.Errors, e => e.Message.Contains("failed validation")); // FAILS today
|
||||||
|
var full = new ValidationService().Validate(config);
|
||||||
|
Assert.Contains(full.Errors, e => e.Message.Contains("failed validation")); // deploy gate unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_SkipCompilation_StillChecksExpressionAttributeReferences()
|
||||||
|
{
|
||||||
|
// expression "Attributes[\"Ghost\"] != null" referencing a missing attribute:
|
||||||
|
// the reference scan is cheap string work and MUST survive the gate.
|
||||||
|
var config = ConfigWithExpressionTriggerScript("Attributes[\"Ghost\"] != null");
|
||||||
|
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
|
||||||
|
Assert.Contains(gated.Errors, e => e.Message.Contains("Ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlattenedConfiguration ConfigWithExpressionTriggerScript(string expression)
|
||||||
|
{
|
||||||
|
var json = System.Text.Json.JsonSerializer.Serialize(new { expression });
|
||||||
|
return new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Instance1",
|
||||||
|
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
|
||||||
|
Scripts =
|
||||||
|
[
|
||||||
|
new ResolvedScript
|
||||||
|
{
|
||||||
|
CanonicalName = "OnExpr",
|
||||||
|
TriggerType = "Expression",
|
||||||
|
TriggerConfiguration = json
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -1397,6 +1397,61 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
|
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
|
||||||
|
{
|
||||||
|
// ApiMethod imported as Add into a target where the name does not exist.
|
||||||
|
// Delete-then-reimport-as-Add is the N3 edge: a node can still hold a
|
||||||
|
// _knownBadMethods / compiled-handler entry under that very name, so Adds
|
||||||
|
// must notify too (over-approximation is safe per the bus contract).
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.ApiMethods.Add(new ApiMethod("DelmiaRecipeDownload", "return 1;") { TimeoutSeconds = 30 });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid sessionId;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var selection = new ExportSelection(
|
||||||
|
TemplateIds: Array.Empty<int>(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
|
||||||
|
IncludeDependencies: false);
|
||||||
|
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||||
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(ms);
|
||||||
|
ms.Position = 0;
|
||||||
|
|
||||||
|
// Delete the target method so the resolution is a genuine Add.
|
||||||
|
ctx.ApiMethods.RemoveRange(await ctx.ApiMethods.ToListAsync());
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution> { new("ApiMethod", "DelmiaRecipeDownload", ResolutionAction.Add, null) },
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
var notification = Assert.Single(_artifactBus.Received,
|
||||||
|
n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod);
|
||||||
|
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
||||||
{
|
{
|
||||||
|
|||||||
+166
@@ -1222,4 +1222,170 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal(1, result.Added);
|
Assert.Equal(1, result.Added);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// N4 (plan R2-05 T7): warn on overrides of LOCKED template members
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
|
||||||
|
{
|
||||||
|
// Template attribute "SetPoint" IsLocked=true; instance carries an
|
||||||
|
// InstanceAttributeOverride for "SetPoint" — the flattener drops it, so the
|
||||||
|
// import surfaces an advisory warning but the row IS still written.
|
||||||
|
await SeedLockedMemberClosureAsync(lockAttribute: true);
|
||||||
|
|
||||||
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||||
|
await WipeSiteClosureAsync();
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||||
|
Connections: Array.Empty<ConnectionMapping>());
|
||||||
|
|
||||||
|
var result = await ApplyAsync(
|
||||||
|
sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||||
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
|
},
|
||||||
|
nameMap);
|
||||||
|
|
||||||
|
Assert.Contains(result.Warnings, w =>
|
||||||
|
w.Contains("SetPoint", StringComparison.Ordinal)
|
||||||
|
&& w.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// Behaviour unchanged: the override row IS still written (bundle fidelity).
|
||||||
|
await using (var verify = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
Assert.True(await ctx.InstanceAttributeOverrides.AnyAsync(o => o.AttributeName == "SetPoint"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
|
||||||
|
{
|
||||||
|
// Template native-alarm-source "NativeSrc" IsLocked=true; instance carries a
|
||||||
|
// NativeAlarmSourceOverride for it. The wizard shows a non-blocking Warning row.
|
||||||
|
await SeedLockedMemberClosureAsync(lockNativeSource: true);
|
||||||
|
|
||||||
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||||
|
|
||||||
|
ImportPreview preview;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
preview = await importer.PreviewAsync(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains(preview.Items, b =>
|
||||||
|
b.Kind == ConflictKind.Warning && b.EntityType == "Instance"
|
||||||
|
&& b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
|
||||||
|
{
|
||||||
|
// Same shape, but "SetPoint" is NOT locked — no lock warning must appear.
|
||||||
|
await SeedLockedMemberClosureAsync(lockAttribute: false);
|
||||||
|
|
||||||
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||||
|
await WipeSiteClosureAsync();
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||||
|
Connections: Array.Empty<ConnectionMapping>());
|
||||||
|
|
||||||
|
var result = await ApplyAsync(
|
||||||
|
sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||||
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
|
},
|
||||||
|
nameMap);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(result.Warnings, w =>
|
||||||
|
w.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose
|
||||||
|
/// overrides target the given members; <paramref name="lockAttribute"/> /
|
||||||
|
/// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag.
|
||||||
|
/// The attribute override is always present; the native-source override (and its
|
||||||
|
/// connection) are seeded only when a native source is involved.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SeedLockedMemberClosureAsync(bool lockAttribute = false, bool lockNativeSource = false)
|
||||||
|
{
|
||||||
|
await using var scope = _provider.CreateAsyncScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
|
||||||
|
var template = new Template("Pump") { Description = "pump tpl" };
|
||||||
|
template.Attributes.Add(new TemplateAttribute("SetPoint") { Value = "0", IsLocked = lockAttribute });
|
||||||
|
if (lockNativeSource)
|
||||||
|
{
|
||||||
|
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("NativeSrc")
|
||||||
|
{
|
||||||
|
ConnectionName = "OpcUaPrimary",
|
||||||
|
SourceReference = "ns=3;s=Pump.Alarm",
|
||||||
|
IsLocked = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.Templates.Add(template);
|
||||||
|
|
||||||
|
var site = new Site("Plant 1", "plant-1")
|
||||||
|
{
|
||||||
|
NodeAAddress = "akka://site@10.0.0.1:2552",
|
||||||
|
NodeBAddress = "akka://site@10.0.0.2:2552",
|
||||||
|
GrpcNodeAAddress = "10.0.0.1:8083",
|
||||||
|
GrpcNodeBAddress = "10.0.0.2:8083",
|
||||||
|
};
|
||||||
|
ctx.Sites.Add(site);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
DataConnection? conn = null;
|
||||||
|
if (lockNativeSource)
|
||||||
|
{
|
||||||
|
conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
||||||
|
{
|
||||||
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
|
||||||
|
FailoverRetryCount = 5,
|
||||||
|
};
|
||||||
|
ctx.DataConnections.Add(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = new Instance("Pump-01")
|
||||||
|
{
|
||||||
|
TemplateId = template.Id,
|
||||||
|
SiteId = site.Id,
|
||||||
|
State = InstanceState.Enabled,
|
||||||
|
};
|
||||||
|
instance.AttributeOverrides.Add(new InstanceAttributeOverride("SetPoint") { OverrideValue = "42" });
|
||||||
|
if (lockNativeSource)
|
||||||
|
{
|
||||||
|
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
|
||||||
|
{
|
||||||
|
ConnectionNameOverride = "OpcUaPrimary",
|
||||||
|
SourceReferenceOverride = "ns=3;s=Pump.Alarm2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.Instances.Add(instance);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+179
@@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
@@ -122,6 +124,78 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a template + site + instance whose single alarm override carries the
|
||||||
|
/// given <paramref name="triggerConfigJson"/> as its
|
||||||
|
/// <c>TriggerConfigurationOverride</c>, so an export→load carries the instance
|
||||||
|
/// alarm-override trigger expression through the trust gate.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SeedInstanceAlarmOverrideAsync(string triggerConfigJson)
|
||||||
|
{
|
||||||
|
await using var scope = _provider.CreateAsyncScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
|
||||||
|
var template = new Template("Pump") { Description = "pump tpl" };
|
||||||
|
ctx.Templates.Add(template);
|
||||||
|
|
||||||
|
var site = new Site("Plant 1", "plant-1")
|
||||||
|
{
|
||||||
|
NodeAAddress = "akka://site@10.0.0.1:2552",
|
||||||
|
NodeBAddress = "akka://site@10.0.0.2:2552",
|
||||||
|
GrpcNodeAAddress = "10.0.0.1:8083",
|
||||||
|
GrpcNodeBAddress = "10.0.0.2:8083",
|
||||||
|
};
|
||||||
|
ctx.Sites.Add(site);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var instance = new Instance("Pump-01")
|
||||||
|
{
|
||||||
|
TemplateId = template.Id,
|
||||||
|
SiteId = site.Id,
|
||||||
|
State = InstanceState.Enabled,
|
||||||
|
};
|
||||||
|
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
|
||||||
|
{
|
||||||
|
TriggerConfigurationOverride = triggerConfigJson,
|
||||||
|
});
|
||||||
|
ctx.Instances.Add(instance);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exports every seeded site (+ template) closure into a bundle and loads it.</summary>
|
||||||
|
private async Task<Guid> ExportSitesAndLoadAsync()
|
||||||
|
{
|
||||||
|
byte[] bundleBytes;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
||||||
|
var selection = new ExportSelection(
|
||||||
|
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
IncludeDependencies: true,
|
||||||
|
SiteIds: siteIds);
|
||||||
|
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||||
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(ms);
|
||||||
|
bundleBytes = ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
using var input = new MemoryStream(bundleBytes, writable: false);
|
||||||
|
return (await importer.LoadAsync(input, passphrase: null)).SessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import()
|
public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import()
|
||||||
{
|
{
|
||||||
@@ -406,6 +480,111 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks()
|
||||||
|
{
|
||||||
|
// N5 — an instance alarm override's TriggerConfigurationOverride replaces the
|
||||||
|
// template alarm's trigger config in the flattened config and compiles/executes
|
||||||
|
// at the site, so a forbidden API in it must be caught at import review — the
|
||||||
|
// same hard-block severity as every other trust-gate surface.
|
||||||
|
await SeedInstanceAlarmOverrideAsync(
|
||||||
|
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
|
||||||
|
|
||||||
|
var sessionId = await ExportSitesAndLoadAsync();
|
||||||
|
|
||||||
|
SemanticValidationException ex = default!;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||||
|
importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Add, null),
|
||||||
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||||
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
|
},
|
||||||
|
user: "bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotEmpty(ex.Errors);
|
||||||
|
Assert.Contains(ex.Errors, e =>
|
||||||
|
e.Contains("Process", StringComparison.Ordinal)
|
||||||
|
&& e.Contains("trigger override", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
// Rollback contract: nothing from THIS import was written (the seed rows
|
||||||
|
// pre-exist, but no new import ran — the throw is pre-write at Pass 0).
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker()
|
||||||
|
{
|
||||||
|
await SeedInstanceAlarmOverrideAsync(
|
||||||
|
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
|
||||||
|
|
||||||
|
var sessionId = await ExportSitesAndLoadAsync();
|
||||||
|
|
||||||
|
ImportPreview preview;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
preview = await importer.PreviewAsync(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains(preview.Items, b =>
|
||||||
|
b.Kind == ConflictKind.Blocker && b.EntityType == "Instance"
|
||||||
|
&& b.BlockerReason!.Contains("trust violation", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports()
|
||||||
|
{
|
||||||
|
// A clean expression against the trigger surface imports without hard-block;
|
||||||
|
// the override row is persisted verbatim (bundle fidelity).
|
||||||
|
await SeedInstanceAlarmOverrideAsync(
|
||||||
|
"{\"expression\":\"Attributes[\\\"Temp\\\"] != null\"}");
|
||||||
|
|
||||||
|
var sessionId = await ExportSitesAndLoadAsync();
|
||||||
|
|
||||||
|
// Wipe so the apply exercises a create; re-create the template the instance binds to.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
|
||||||
|
ctx.Instances.RemoveRange(ctx.Instances);
|
||||||
|
ctx.Sites.RemoveRange(ctx.Sites);
|
||||||
|
ctx.Templates.RemoveRange(ctx.Templates);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||||
|
Connections: Array.Empty<ConnectionMapping>());
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||||
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
|
},
|
||||||
|
user: "bob", ct: CancellationToken.None, nameMap: nameMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var verify = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var ovr = await ctx.InstanceAlarmOverrides.SingleAsync();
|
||||||
|
Assert.Equal("HiAlarm", ovr.AlarmCanonicalName);
|
||||||
|
Assert.Contains("Attributes", ovr.TriggerConfigurationOverride!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
|
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ public class TransportOptionsValidatorTests
|
|||||||
Assert.Contains("MaxBundleSizeMb", result.FailureMessage);
|
Assert.Contains("MaxBundleSizeMb", result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroMaxConcurrentImportSessions_IsRejected()
|
||||||
|
{
|
||||||
|
var result = Validate(new TransportOptions { MaxConcurrentImportSessions = 0 });
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage); // FAILS today: validates clean
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NegativeMaxConcurrentImportSessions_IsRejected()
|
||||||
|
{
|
||||||
|
var result = Validate(new TransportOptions { MaxConcurrentImportSessions = -1 });
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroPbkdf2Iterations_IsRejected()
|
public void ZeroPbkdf2Iterations_IsRejected()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user