Compare commits
36 Commits
dbd3ed2fc7
...
9b0cf44c02
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0cf44c02 | |||
| ec23db3551 | |||
| 8e5631075d | |||
| e1ecf5d6ee | |||
| 0642d3b988 | |||
| 039cf69cbb | |||
| 92e713f1a1 | |||
| 523e670579 | |||
| c60347c5e7 | |||
| 8e92198078 | |||
| 7c74bbe4b5 | |||
| e498c6e1bd | |||
| 2c839def33 | |||
| 38edfefc42 | |||
| 33cf577718 | |||
| 7b6d6bb805 | |||
| e4136cb920 | |||
| 4df5109742 | |||
| d833219920 | |||
| 0302694f9a | |||
| bf17f60a04 | |||
| ea9c5709ac | |||
| 874e32a93e | |||
| 57f7f65772 | |||
| ab6077708f | |||
| 83294ab44e | |||
| 53ce64ba79 | |||
| ef59c752ba | |||
| f40f52950e | |||
| 721a2e595f | |||
| 5c52b4c80b | |||
| 4d4ae05fc2 | |||
| aae0a2db3e | |||
| 854a6a8c0b | |||
| 730bf19162 | |||
| d19ab93d39 |
@@ -94,7 +94,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.
|
||||
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.
|
||||
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates, system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. 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; 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.
|
||||
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`.
|
||||
@@ -149,7 +149,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).
|
||||
- Optimistic concurrency on deployment status records.
|
||||
- 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`); `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.
|
||||
- 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).
|
||||
|
||||
### Store-and-Forward
|
||||
- Fixed retry interval, no max buffer size. Only transient failures buffered.
|
||||
|
||||
@@ -13,8 +13,8 @@ Every plan follows the TDD bite-sized-task format and ships a co-located `.tasks
|
||||
| [PLAN-01](PLAN-01-cluster-host-failover.md) | Cluster, Host & Failover | 23 | 23 | ✅ Complete (all 23 tasks; merged to `main` `ec47cb56`) | 28 findings: 20 fixed, 3 → other plans, 3 accepted, 2 roadmap-deferred |
|
||||
| [PLAN-02](PLAN-02-communication-store-and-forward.md) | Communication & S&F | 24 | 24 | ✅ Complete (all 24 tasks; merged to `main`) | 28 findings: 25 fixed, 1 → plan 01, 1 deferred (serializer swap), 1 won't-fix |
|
||||
| [PLAN-03](PLAN-03-site-runtime-dcl.md) | Site Runtime & DCL | 26 | 26 | ✅ Complete (all 26 tasks; merged to `main` `c7a0c922`) | 28 actionable: 25 fixed, 3 deferred with rationale |
|
||||
| [PLAN-04](PLAN-04-data-audit-backbone.md) | Data & Audit Backbone | 26 | 26 | ✅ Complete (T26 sweep green; on branch `worktree-archreview-wave2-plan04` — Batches 1–3 merged to `main`, Batches 4–8 + T24 + T26 fix unmerged, pending finishing-a-development-branch) | 27 items: 22 fixed, 4 deferred, 1 won't-fix (incl. NodeB reconcile from report 08) |
|
||||
| [PLAN-05](PLAN-05-templates-deployment-transport.md) | Templates, Deployment & Transport | 25 | 1 | 🟨 In progress (P0: T1) | 33 line-items: 27 fixed, 6 deferred with rationale |
|
||||
| [PLAN-04](PLAN-04-data-audit-backbone.md) | Data & Audit Backbone | 26 | 26 | ✅ Complete (all 26 tasks; merged to `main` `dbd3ed2f` via fast-forward; branch removed) | 27 items: 22 fixed, 4 deferred, 1 won't-fix (incl. NodeB reconcile from report 08) |
|
||||
| [PLAN-05](PLAN-05-templates-deployment-transport.md) | Templates, Deployment & Transport | 25 | 25 | ✅ Complete (all 25 tasks; merged to `main` `ec23db35` via fast-forward; worktree removed, branch deleted) | 33 line-items: 27 fixed, 6 deferred with rationale |
|
||||
| [PLAN-06](PLAN-06-edge-integrations.md) | Edge Integrations | 24 | 0 | ⬜ Not started | 24 findings: 21 fixed, 3 deferred/won't-fix |
|
||||
| [PLAN-07](PLAN-07-ui-management-security.md) | UI, Management & Security | 33 | 3 | 🟨 In progress (P0: T1, T2, T3) | 24 items: 20 fixed, 3 partial (deferrals documented), 1 won't-fix |
|
||||
| [PLAN-08](PLAN-08-conventions-tests.md) | Conventions & Tests | 11 | 1 | 🟨 In progress (P0: T1) | 13 covered, 4 owned by other plans, ~20 triaged into deferred-work register |
|
||||
@@ -31,9 +31,11 @@ Status values: ⬜ Not started · 🟨 In progress · ✅ Complete · ⏸ Blocke
|
||||
|
||||
**Wave 2 — PLAN-04 COMPLETE (2026-07-09):** all 26 tasks delivered on worktree branch `worktree-archreview-wave2-plan04` (Batches 1–3 fast-forwarded to `main`+`origin`; Batches 4–8 + T24 + the T26 fix still on the branch, pending `finishing-a-development-branch`). Site SQLite retention chain + partition-switch hardening + purger role-grants/index migrations (T1–T9), backlog KPI + SiteCalls freshness upsert + EF `EnableRetryOnFailure` (T10–T12), operator-identity audit on Retry/Discard relays + reconciliation composite-keyset cursor/abandoned-row audit/NodeB failover (T13–T18), time-sliced KpiSample purge + single-query NotificationOutbox KPI + SiteCalls `OPTION (RECOMPILE)` (T19–T21), short-first-tick purge timers + data-layer cleanups + bounded parallel notification delivery (T22, T23, T25), the design-doc reconciliation pass (T24). **T26 (final `dotnet build`/`dotnet test` sweep):** build 0 warn/0 err; all 30 unit + integration test assemblies green after fixing one stale enum-count lock-in test (`22d52390` — Task 17 added `AuditKind.ReconciliationAbandoned` but left `AuditEnumTests` asserting 14 members; the per-task filtered runs never hit that Commons guard, so the full sweep caught it). The Playwright E2E residue (8–9 fails) is environmental — a 17h-old docker cluster whose site containers fail `instance deploy` with `SQLite Error 14: unable to open database file`; the failure set is non-deterministic and PLAN-04 changed zero files in that project or the deployment-DB path. Initiative total: **104 of 192** (sum of the Done column).
|
||||
|
||||
**Wave 3 — PLAN-05 COMPLETE (2026-07-10):** all 25 tasks delivered on worktree branch `worktree-archreview-wave3-plan05` and **merged to `main` `ec23db35` via fast-forward** (main was still at PLAN-04's `dbd3ed2f`, zero divergence; worktree removed, branch deleted). Templates/Deployment/Transport hardening across seven report-and-wait waves: import correctness spine (BundleImporter consumes `BaseTemplateName`/composition/inheritance edges + import-time acyclicity, `MinTimeBetweenRuns`/`ExecutionTimeoutSeconds`/`LockedInDerived`/native-alarm-sources/real `AreaName` carried through — T1–T7), a reflection round-trip equivalence guard that surfaced and fixed 5 silent-data-loss holes (ES retry+methods-on-Add, notification recipients, folder parent edges — T8), flattener/collision-detector repeated-composition fixes (T9/T10), revision-hash + DiffService native-source coverage (T11/T12), native-source lock enforcement (T13), the `IScriptArtifactChangeBus` invalidation contract + Host in-process bus + Transport post-commit publisher (T14, plan-06 handoff), compile-verdict cache + read-only-path compile skip + post-success audit isolation + central NotDeployed-delete (T15–T18), and the closing security/quality chain: **import blocker severity split** (template-script findings advisory, ApiMethod hard — T19), **the script trust gate on bundle import** (5th `ScriptTrustValidator` call site, over script bodies *and* Expression-trigger bodies — T20, given a code-reviewer security pass that caught + fixed 2 Important gaps: expression-trigger coverage and per-origin local-declaration scoping), ScriptTrustPolicy deny-list hardening (T21), LineDiffer input cap (T22), ArtifactDiff placeholder-identity resolution (T23), the low-severity batch (session cap / full error lists / leaf-fallback warning — T24), and the design-doc + CLAUDE.md sweep (T25). **Terminal verification:** full slnx build 0 warn/0 err; the entire `dotnet test` sweep green (~5,900 unit + integration tests across every suite) plus a direct `CLI.Tests` run (341). Only residue: the same environmental 8 `CentralUI.PlaywrightTests` Deployment E2E failures (`SQLite Error 14` on `instance deploy` — needs a live docker cluster; PLAN-05 touched zero files in that project's deployment-DB path). Initiative total: **128 of 192** (sum of the Done column).
|
||||
|
||||
## Parallelization Map — Incomplete Tasks (2026-07-09)
|
||||
|
||||
Covers the plans with open tasks: PLAN-05 (24), PLAN-06 (24), PLAN-07 (30), PLAN-08 (10). PLAN-01/02/03/04 are complete — nothing to parallelize there (PLAN-04 finished 2026-07-09; the section below is retained for its cross-plan mutex analysis, which still governs PLAN-05–08). **"Ready"** = every `blockedBy` entry is already `completed` in the plan's `.tasks.json`, so the task can start now. Ready sets are recomputed below from the live manifests; re-derive after each merge.
|
||||
Covers the plans with open tasks: PLAN-06 (24), PLAN-07 (30), PLAN-08 (10). PLAN-01/02/03/04/05 are complete — nothing to parallelize there (PLAN-05 finished 2026-07-10; the section below is retained for its cross-plan mutex analysis, which still governs PLAN-06–08). **"Ready"** = every `blockedBy` entry is already `completed` in the plan's `.tasks.json`, so the task can start now. Ready sets are recomputed below from the live manifests; re-derive after each merge.
|
||||
|
||||
### Cross-plan serialization mutexes (a task holding one must be the ONLY task touching that surface, anywhere)
|
||||
|
||||
@@ -52,8 +54,8 @@ Covers the plans with open tasks: PLAN-05 (24), PLAN-06 (24), PLAN-07 (30), PLAN
|
||||
|
||||
### Ready set + concurrent lanes per plan
|
||||
|
||||
- **PLAN-04** — ✅ COMPLETE (2026-07-09); all 26 tasks done, T26 sweep green. Pending only `finishing-a-development-branch` (merge Batches 4–8 + T24 + T26 fix). No longer a parallelization lane.
|
||||
- **PLAN-05** — ready: **{2, 7, 9, 13, 14, 15, 16, 21, 22}**. Lanes: *(a)* `BundleImporter.cs` mutex — **T2 ↔ T14** (one at a time); *(b)* global ManagementActor lane — **T13** (also edits `FlatteningService.cs`, so not concurrent with T9); *(c)* free-concurrent, file-disjoint — **T7, T9, T15, T16, T21, T22** (Export / FlatteningService / TemplateEngine-Validation / DeploymentManager-pipeline / ScriptAnalysis / LineDiffer). Up to ~6 concurrent implementers once the two mutex lanes are accounted for.
|
||||
- **PLAN-04** — ✅ COMPLETE (2026-07-09); all 26 tasks done, merged to `main` `dbd3ed2f`. No longer a parallelization lane.
|
||||
- **PLAN-05** — ✅ COMPLETE (2026-07-10); all 25 tasks done, merged to `main` `ec23db35` (fast-forward). No longer a parallelization lane. (Historical: the BundleImporter serial chain T1→2→…→6→19→20→23 ran in-worktree; T14's `IScriptArtifactChangeBus` contract is shipped for PLAN-06 to consume.)
|
||||
- **PLAN-06** — ready: **{1, 6, 7, 8, 17, 23}**. Lanes: **T8** is the sole migration in flight; **T7** holds the `Host/Program.cs` mutex (coordinate with P08 T4); **T1, T6, T17, T23** are free-concurrent (InboundScriptExecutor / InboundApiEndpointFilter / SmsAdapter / DelmiaNotifier docs). The ExternalSystem (T9→…), Notification (T18/T19→…) and inbound-contract (T2→T3→T4→T5) chains stay blocked.
|
||||
- **PLAN-07** — ready non-ManagementActor (mutually concurrent, file-disjoint): **{4, 6, 14, 19, 21, 26, 29 | 32}** (ArtifactDeploymentService / ConfigSecretScrubber / SecuredWriteRepository / ManagementEndpoints+options / Commons Browse-Cert message records / Security LoginThrottle / CentralUI PollGate — T29 and T32 share `AlarmSummary.razor`, so serialize those two). Ready **ManagementActor** tasks (→ the ONE global Lane M): **{8, 9, 10, 12, 28, 30, 31}** — **start with T10** (it unblocks T11, T13, T22, T23). **T33 = skip (dedupe).**
|
||||
- **PLAN-08** — ready: **{2, 3, 4, 5, 7, 8, 9, 11}** — each is a new per-component validator file or an isolated test/doc, so **mutually parallel**, with one caveat: **T4 shares `Host/Program.cs` with P06 T7** (sequence them). This is the initiative's single highest-parallelism slice (independent validator files). T6, T10 stay blocked.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
|
||||
# Script-Artifact Invalidation Contract (`ScriptArtifactsChanged`)
|
||||
|
||||
**Status:** Seam + first producer shipped in PLAN-05 (arch-review 05, Task 14). Consumer + additional producers land in **plan 06**.
|
||||
|
||||
**Problem.** When a script-bearing artifact's definition changes at runtime — an Inbound API method's script, a shared script, or a template's scripts — any node that has already **compiled and cached** a handler/delegate for it keeps serving the stale version until a process restart. This is the root of two recorded project-memory gotchas (a DB-direct edit to a broken inbound method keeps returning "Script compilation failed" from `_knownBadMethods`; a non-compiling update leaves the old delegate serving). A bundle import is a third mutation path with the same hazard.
|
||||
|
||||
The fix is a small, explicit contract for "a script artifact changed → invalidate anything compiled from it," decoupled so producers (importer, management edits, failover) and consumers (compiled-handler caches) never reference each other.
|
||||
|
||||
## The seam (Commons)
|
||||
|
||||
```csharp
|
||||
// Commons/Messages/Scripting/ScriptArtifactsChanged.cs
|
||||
public sealed record ScriptArtifactsChanged(
|
||||
string ArtifactKind, // ScriptArtifactKinds.ApiMethod | SharedScript | Template
|
||||
IReadOnlyList<string> Names, // post-resolution (renamed) names
|
||||
string Source, // "BundleImport" | "Management" | "FailoverActivation"
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
public static class ScriptArtifactKinds
|
||||
{
|
||||
public const string ApiMethod = "ApiMethod";
|
||||
public const string SharedScript = "SharedScript";
|
||||
public const string Template = "Template";
|
||||
}
|
||||
|
||||
// Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs
|
||||
public interface IScriptArtifactChangeBus
|
||||
{
|
||||
void Publish(ScriptArtifactsChanged notification);
|
||||
IDisposable Subscribe(Action<ScriptArtifactsChanged> handler);
|
||||
}
|
||||
```
|
||||
|
||||
## Semantics (normative)
|
||||
|
||||
1. **Advisory, at-least-once.** A notification is a hint to invalidate, not a command with delivery guarantees. Duplicates are allowed; over-notification (e.g. an `Overwrite` resolution that actually fell through to `Add`) is allowed and harmless.
|
||||
2. **Published only AFTER commit.** A producer publishes *after* its mutating transaction commits — never inside it. A notification for a rolled-back write is worse than a missed one.
|
||||
3. **Never faults the producer.** The bus swallows-and-logs any subscriber-handler exception; the producer additionally guards its own publish call. A misbehaving consumer can never turn a committed write into a reported failure.
|
||||
4. **In-process, per node.** `InProcessScriptArtifactChangeBus` (Host) is node-local: a lock-free copy-on-write subscriber list. It does **not** cross the cluster. Cross-node and failover freshness are the **consumer's** responsibility — every consumer MUST also self-heal without a notification (e.g. a content-hash / revision check on next use), because a notification published on node A does not reach node B, and a node that was down during the change never hears it.
|
||||
5. **Names are post-resolution.** `Names` carries the artifact's persisted name after import name-resolution (a `Rename` carries `RenameTo`).
|
||||
|
||||
## What plan 05 ships
|
||||
|
||||
- The Commons seam (record + kinds + interface).
|
||||
- `InProcessScriptArtifactChangeBus` in the Host, registered as a central-role singleton.
|
||||
- The **Transport bundle importer as the first producer**: `BundleImporter.ApplyAsync` collects the Overwrite/Rename resolutions for `ApiMethod` / `SharedScript` / `Template` and publishes one `ScriptArtifactsChanged` per kind **after** `tx.CommitAsync`. The importer's dependency on the bus is optional (`IScriptArtifactChangeBus?`) so Transport-only hosts (and unit tests) run without it.
|
||||
|
||||
## Handoff to plan 06
|
||||
|
||||
Plan 06 implements the consumer side and the remaining producers:
|
||||
|
||||
| Item | Responsibility |
|
||||
|---|---|
|
||||
| **(a) Inbound API consumer** | `InboundScriptExecutor` subscribes to the bus and, on an `ApiMethod` notification, evicts the named entries from `_scriptHandlers` **and** `_knownBadMethods` — closing the "DB-direct edit keeps returning compile error" and "non-compiling update keeps old handler" gotchas. |
|
||||
| **(b) Management producers** | Publish `ScriptArtifactsChanged` from the `ManagementActor` `UpdateApiMethod` / shared-script / template-script edit paths (`Source = "Management"`), so a UI/CLI edit invalidates the same caches a bundle import does. |
|
||||
| **(c) Cross-node / failover freshness** | A revision-keyed handler cache (compile keyed by code hash — see PLAN-05 Task 15's `ScriptCompileVerdictCache`) so a node that missed the in-process notification still self-heals on next use, and a newly-active failover node never serves a stale compile. |
|
||||
|
||||
Until (a)–(c) land, plan 05's publisher is a no-op in practice (no subscribers), but the seam is stable and the producer is correct, so plan 06 is purely additive.
|
||||
@@ -91,6 +91,16 @@ All mutating operations on a single instance (deploy, disable, enable, delete) s
|
||||
- Only one mutating operation per instance can be in-flight at a time. A second operation is rejected with an "operation in progress" error.
|
||||
- **Different instances**: Operations on different instances can proceed **in parallel**, even at the same site. Each tracks status independently. This supports the bulk "deploy all out-of-date instances" operation efficiently.
|
||||
|
||||
> **`OperationLockManager` scope invariant (recorded decision, arch-review 05).** The
|
||||
> per-instance locks are **per-node, in-memory**. Mutual exclusion therefore holds only
|
||||
> because management traffic reaches a single node — the **active** central node, via
|
||||
> Traefik's active-node routing. A direct mutation sent to the **standby** central port
|
||||
> would take a *different* in-memory lock and could race the active node. Structurally
|
||||
> rejecting mutating operations on the non-active node (so the standby can never accept
|
||||
> them) is **not** owned here — it belongs to the cluster-routing / management-surface
|
||||
> plans (01/07). The Deployment Manager records the invariant and assumes single-active-node
|
||||
> delivery; it does not itself enforce node identity.
|
||||
|
||||
### Allowed State Transitions
|
||||
|
||||
| Current State | Deploy | Disable | Enable | Delete |
|
||||
@@ -119,6 +129,7 @@ When deploying artifacts (shared scripts, external system definitions, etc.) to
|
||||
|
||||
- Only the **current deployment status** per instance is stored in the configuration database (pending, in-progress, success, failed).
|
||||
- No deployment history table — the audit log (via IAuditService) already captures every deployment action with who, what, when, and result.
|
||||
- **Post-success audit isolation (arch-review 05, Task 17):** the audit write that records a successful deployment runs **outside** the deployment's own success path — an audit-write failure (DB blip, transient error) is logged and swallowed, and NEVER flips a genuinely-successful deployment to failed or triggers a spurious rollback. Audit is best-effort; the deployment's own apply result is authoritative (mirrors the system-wide "audit-write failure never aborts the user-facing action" rule).
|
||||
- The Deployment Manager uses current status to determine staleness (is this instance up-to-date?) and display deployment results in the UI.
|
||||
|
||||
## Deployment Scope
|
||||
@@ -142,6 +153,20 @@ The system maintains two views per instance:
|
||||
|
||||
These are compared to determine staleness and generate diffs.
|
||||
|
||||
The comparison path is **hash-only by design**. When the Template-Derived
|
||||
Configuration is re-flattened for a comparison (the Deployments comparison page)
|
||||
or for the Transport stale-instance probe
|
||||
(`IStaleInstanceProbe.GetCurrentRevisionHashAsync`, used per-instance across a
|
||||
bundle import), the pipeline is invoked with `validateScripts: false`, which
|
||||
skips **only** the expensive Roslyn script-compilation stage
|
||||
(`ValidationService.ValidateScriptCompilation` — a non-collectible assembly load
|
||||
per script). Structural and semantic validation still run. These read-only paths
|
||||
need only the flattened config and its revision hash, not a compile, so a script
|
||||
that does not currently compile still yields a comparison. The **deploy gate
|
||||
remains the authoritative compile**: instance deployment re-flattens with
|
||||
`validateScripts: true` (the default), and a script that fails to compile blocks
|
||||
the deployment (see Site-Side Apply Atomicity).
|
||||
|
||||
## Deployable Artifacts
|
||||
|
||||
A deployment to a site includes the flattened instance configuration plus any system-wide artifacts that have changed:
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
The Script Analysis component is the single authoritative source of truth for the ScadaBridge script trust model. It provides a unified forbidden-API deny-list, a fused semantic and syntactic trust validator, a Roslyn compile wrapper, and compile-only globals stubs used by the design-time deploy gate. All four call sites that enforce the script trust boundary — Template Engine, Site Runtime, Inbound API, and Central UI — delegate to this component rather than maintaining their own divergent implementations.
|
||||
The Script Analysis component is the single authoritative source of truth for the ScadaBridge script trust model. It provides a unified forbidden-API deny-list, a fused semantic and syntactic trust validator, a Roslyn compile wrapper, and compile-only globals stubs used by the design-time deploy gate. All five call sites that enforce the script trust boundary — Template Engine, Site Runtime, Inbound API, Central UI, and Transport bundle import — delegate to this component rather than maintaining their own divergent implementations.
|
||||
|
||||
## Location
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/`
|
||||
|
||||
Referenced by: Template Engine, Site Runtime, Inbound API, Central UI.
|
||||
Referenced by: Template Engine, Site Runtime, Inbound API, Central UI, Transport.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
@@ -38,6 +38,16 @@ The following namespace/type prefixes are forbidden in all scripts:
|
||||
| `System.Net` | Raw network access — forbidden entirely (scripts must use `ExternalSystem.Call`) |
|
||||
| `System.Runtime.InteropServices` | Native interop — forbidden entirely |
|
||||
| `Microsoft.Win32` | Win32 API access — forbidden entirely |
|
||||
| `System.Environment` | Whole type — forbidden entirely; exposes process control (`Exit`/`FailFast`), the host environment, and secrets via environment variables (e.g. `SCADABRIDGE_API_KEY`). Scripts that need a line break use `"\n"` instead of `Environment.NewLine`. |
|
||||
| `System.GC` | Whole type — forbidden entirely; GC control (`Collect`, `KeepAlive`, memory-pressure knobs) has no legitimate script use. |
|
||||
| `Microsoft.Data` | Concrete ADO.NET provider namespace (`Microsoft.Data.SqlClient`, …) — forbidden; closes the arbitrary-host `new SqlConnection("Server=attacker")` channel |
|
||||
| `System.Data.SqlClient` | Concrete ADO.NET SQL Server provider — forbidden (see provider-namespace posture below) |
|
||||
| `System.Data.Odbc` | Concrete ADO.NET ODBC provider — forbidden |
|
||||
| `System.Data.OleDb` | Concrete ADO.NET OLE DB provider — forbidden |
|
||||
|
||||
##### `System.Data` provider-namespace posture
|
||||
|
||||
Only the **concrete ADO.NET provider namespaces** are forbidden — `Microsoft.Data` (which covers `Microsoft.Data.SqlClient`), `System.Data.SqlClient`, `System.Data.Odbc`, and `System.Data.OleDb`. The abstract `System.Data.Common` types (`DbConnection`, `DbCommand`, …) and `System.Data` broadly are **deliberately NOT forbidden**: scripts reach a database only through the sanctioned `Database` helper, whose surface exposes those abstract `System.Data.Common` types. Forbidding the concrete providers blocks a script from constructing its own connection to an arbitrary host (the `new SqlConnection(...)` channel) while leaving the sanctioned `Database` path intact.
|
||||
|
||||
#### Allowed exceptions within forbidden scopes
|
||||
|
||||
@@ -47,13 +57,15 @@ The following types are explicitly allowed despite falling within a forbidden na
|
||||
- `System.Threading.CancellationToken` — cooperative cancellation
|
||||
- `System.Threading.CancellationTokenSource` — cooperative cancellation
|
||||
|
||||
The scoping rationale: `System.Diagnostics.Process` is the dangerous type (spawns processes); `Stopwatch`, `Debug`, and `Activity` are harmless diagnostic utilities. Forbidding the whole `System.Diagnostics` namespace, as some earlier call sites did, was overly broad.
|
||||
The scoping rationale: `System.Diagnostics.Process` is the dangerous type (spawns processes); `Stopwatch`, `Debug`, and `Activity` are harmless diagnostic utilities. Forbidding the whole `System.Diagnostics` namespace, as some earlier call sites did, was overly broad. In contrast, `System.Environment` and `System.GC` are forbidden as **whole types** because — unlike `System.Diagnostics` — they have no harmless members a script legitimately needs.
|
||||
|
||||
#### Reflection-gateway members
|
||||
|
||||
The following member names are blocked regardless of the receiver type, to prevent reflection-based bypasses such as `typeof(x).Assembly.GetType("System.IO.File")`:
|
||||
|
||||
`GetType`, `GetTypeInfo`, `Assembly`, `Module`, `CreateInstance`, `InvokeMember`, `GetMethod`, `GetMethods`, `GetConstructor`, `GetConstructors`, `GetField`, `GetFields`, `GetProperty`, `GetProperties`, `GetMember`, `GetMembers`, `GetRuntimeMethod`, `GetRuntimeMethods`, `MethodHandle`, `TypeHandle`.
|
||||
`GetType`, `GetTypeInfo`, `Assembly`, `Module`, `CreateInstance`, `InvokeMember`, `GetMethod`, `GetMethods`, `GetConstructor`, `GetConstructors`, `GetField`, `GetFields`, `GetProperty`, `GetProperties`, `GetMember`, `GetMembers`, `GetRuntimeMethod`, `GetRuntimeMethods`, `MethodHandle`, `TypeHandle`, `GetTypes`, `EntryPoint`, `DeclaredMethods`, `DeclaredMembers`, `DeclaredConstructors`, `DynamicInvoke`.
|
||||
|
||||
`Invoke` is **deliberately excluded** from this list: the syntactic pass rejects a gateway member regardless of receiver, and `delegate.Invoke()` (`Func<>`/`Action<>` invocation) is a legitimate, common script pattern. The reflection late-bind `MethodInfo.Invoke` is already caught semantically via the `System.Reflection` scope, so excluding `Invoke` here avoids a false-positive blast radius without weakening the reflection deny-list.
|
||||
|
||||
#### Forbidden identifiers
|
||||
|
||||
@@ -77,6 +89,7 @@ The identifiers `dynamic` and `Activator` are forbidden at any scope, as they pr
|
||||
- For each identifier in the syntax tree, resolves the underlying symbol to its fully qualified containing namespace and type name.
|
||||
- Flags any symbol whose containing namespace or type matches a forbidden scope in `ScriptTrustPolicy.ForbiddenScopes`, taking `AllowedExceptions` into account.
|
||||
- Correctly handles aliases (`using X = System.IO.File`), `using static`, and `global::` prefixes — the resolved symbol is checked, not the spelling.
|
||||
- The validation compilation applies the **same default namespace imports** (`ScriptTrustPolicy.DefaultImports` — `using System;`, `System.Collections.Generic`, `System.Linq`, `System.Threading.Tasks`) that `RoslynScriptCompiler` compiles the real script with. Without them the semantic pass under-resolves **bare** identifiers the running script sees fully bound — e.g. `Environment.Exit(0)` or `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden `System.Environment`/`System.GC` types only when `using System;` is in scope. Matching the runtime import set closes that blind spot; it can only make more identifiers resolve to their true (forbidden) namespace, never produce a false allow.
|
||||
- Because the full reference set is loaded, this pass also catches a forbidden type accessed inside an otherwise-allowed namespace (e.g., bare `Process` after `using System.Diagnostics;`).
|
||||
|
||||
##### `AnalysisReferences` vs `DefaultReferences`
|
||||
@@ -148,7 +161,7 @@ There is a **third** hand-maintained mirror of the runtime globals: the Central
|
||||
|
||||
### REQ-SA-5: Consumer Delegation
|
||||
|
||||
All four call sites that previously maintained their own script trust enforcement now delegate to this component. The key behavioral changes per consumer:
|
||||
All five call sites that previously maintained their own script trust enforcement (or, for Transport, had none) now delegate to this component. The key behavioral changes per consumer:
|
||||
|
||||
| Consumer | Before | After |
|
||||
|----------|--------|-------|
|
||||
@@ -157,6 +170,7 @@ All four 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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
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.
|
||||
|
||||
@@ -176,3 +190,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.
|
||||
- **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`).
|
||||
- **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).
|
||||
|
||||
@@ -110,7 +110,7 @@ breadcrumb.
|
||||
Override and lock rules apply per entity type at the following granularity:
|
||||
|
||||
- **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. Lock applies to the entire attribute (when locked, no fields can be overridden).
|
||||
- **Alarms**: Priority Level, Trigger Definition (thresholds/ranges/rates), Description, and On-Trigger Script reference are overridable. Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm.
|
||||
- **Alarms**: Priority Level and Trigger Definition (thresholds/ranges/rates) are instance-overridable. Description and On-Trigger Script reference are **not** instance-overridable — `InstanceAlarmOverride` carries only `TriggerConfigurationOverride` and `PriorityLevelOverride`, so an instance cannot re-point an alarm's on-trigger script or reword its description (those are template-level authoring decisions). Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.)
|
||||
- **Native alarm sources**: An instance overrides a non-locked source via `InstanceNativeAlarmSourceOverride`, keyed by `SourceCanonicalName`. `ConnectionNameOverride`, `SourceReferenceOverride`, and `ConditionFilterOverride` are individually overridable — each is applied only when non-null; a null field **keeps the inherited value**. Name is fixed. Lock applies to the entire source.
|
||||
- **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script.
|
||||
- **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name.
|
||||
@@ -143,7 +143,7 @@ The `FlatteningService` resolves native alarm sources alongside alarms, emitting
|
||||
|
||||
- **Inheritance**: resolution walks the chain base → derived; a derived-level source wins over the base unless the base level locked it.
|
||||
- **Composition**: a composed module's sources are path-qualified to the canonical name `[ModuleInstanceName].[Name]`, subject to the same naming-collision checks as other members. Because `SourceReference` is a raw connection address (not an attribute path), composition performs **no attribute-reference rewriting** on it.
|
||||
- **Instance overrides**: `InstanceNativeAlarmSourceOverride` applies its non-null fields (`ConnectionNameOverride`, `SourceReferenceOverride`, `ConditionFilterOverride`) over the inherited/composed result and sets `Source = Override`.
|
||||
- **Instance overrides**: `InstanceNativeAlarmSourceOverride` applies its non-null fields (`ConnectionNameOverride`, `SourceReferenceOverride`, `ConditionFilterOverride`) over the inherited/composed result and sets `Source = Override`. The resolved `ResolvedNativeAlarmSource` carries an `IsLocked` flag; a **locked** source is left untouched by any instance override (the flattener skips it, exactly mirroring the locked-attribute and locked-alarm rules). The lock is enforced at two points: the `FlatteningService` ignores overrides on a locked source, and the `ManagementActor` `SetInstanceNativeAlarmSourceOverride` handler rejects the command outright (and also rejects an override whose canonical name does not resolve, preventing dangling overrides).
|
||||
|
||||
## Inheritance Resolve — Authoring View
|
||||
|
||||
@@ -214,6 +214,8 @@ Each flattened configuration output includes a **revision hash** (computed from
|
||||
|
||||
The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected.
|
||||
|
||||
The revision hash covers the resolved attributes, alarms, scripts, connections, **and native alarm source bindings** (`ResolvedNativeAlarmSource`: canonical name, connection name, source reference, condition filter, and locked state). Native alarm sources were folded into the hash in migration TemplateEngine-011; the hashable projection is populated null-when-empty and the canonical serializer omits null properties, so every native-source-free configuration keeps its byte-identical pre-migration hash. **Migration note:** native-source-bearing instances will report stale once after upgrade; redeploy clears it — deliberate.
|
||||
|
||||
### On-Demand Validation
|
||||
|
||||
The same validation logic is available to Design users in the Central UI without triggering a deployment. This allows template authors to check their work for errors during authoring.
|
||||
|
||||
@@ -20,13 +20,13 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
||||
- Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, API keys, API methods.
|
||||
- Move **site-scoped configuration** (T18): `Site` definitions, site-scoped `DataConnection`s (protocol connections — distinct from External-System `DatabaseConnection`s), and `Instance`s along with their `InstanceAttributeOverride` / `InstanceAlarmOverride` / `InstanceNativeAlarmSourceOverride` / `InstanceConnectionBinding` children and `Area` membership (carried by name).
|
||||
- Reconcile cross-environment site identifiers and connection names through the **name-mapping subsystem** (`BundleNameMap`): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping").
|
||||
- Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`.
|
||||
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`.
|
||||
- Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`. Two independent caps guard the diff: a 400-line **output** cap on the emitted hunk list, and — as of arch-review 05 (Task 22) — a `MaxInputLines` **input** cap (4000 combined lines) that short-circuits the O((N+M)²) Myers trace to a summary-only result (`Truncated = true`, add/remove totals only) so a bloated or crafted bundle cannot OOM the active central node from the import preview.
|
||||
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`. A template travels with **all** of its child collections — `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, and `TemplateNativeAlarmSource` (including `IsInherited` placeholder rows, which the collision detector depends on) — each field-faithful on Add and reconciled add/update/delete on Overwrite. Field completeness is verified by a reflection round-trip guard over every writable property (Add + Overwrite), and includes the fields earlier revisions dropped: the derived-lock flag `LockedInDerived` on attributes/alarms/scripts, script cadence/timeout (`MinTimeBetweenRuns` / `ExecutionTimeoutSeconds`), and — for instances — the real `AreaName` (the exporter previously emitted `null`, half-shipping the Area-by-name feature). Carrying the native alarm sources is what keeps an imported `InstanceNativeAlarmSourceOverride` from dangling against a source the target template would otherwise lack.
|
||||
- Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given.
|
||||
- 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.
|
||||
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, or a Blocker.
|
||||
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running two-tier semantic validation before committing: a minimal name-resolution scan over the merged target (fails fast on unresolved SharedScript / ExternalSystem identifiers), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
|
||||
- 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`.
|
||||
- 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`.
|
||||
- Enforce `RequireDesign` on export and `RequireAdmin` on import both at the Razor page layer and inside the service entrypoints (defense in depth).
|
||||
@@ -41,13 +41,15 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
||||
bundle.scadabundle
|
||||
├── manifest.json # required, NOT encrypted
|
||||
├── content.json # plaintext artifact data (when no passphrase)
|
||||
├── content.enc # encrypted artifact data (when passphrase set)
|
||||
└── scripts/ # optional: large script bodies as files
|
||||
├── template-{id}-{name}.cs
|
||||
└── shared-{id}-{name}.cs
|
||||
└── content.enc # encrypted artifact data (when passphrase set)
|
||||
```
|
||||
|
||||
Exactly one of `content.json` or `content.enc` is present.
|
||||
Exactly one of `content.json` or `content.enc` is present. Script bodies (template
|
||||
scripts, shared scripts, ApiMethod scripts) travel **inline** inside the `content`
|
||||
blob, not as separate `scripts/` files — a well-formed bundle has exactly two zip
|
||||
entries (`manifest.json` + one content blob). A separate `scripts/` directory is
|
||||
**not implemented**: `TransportOptions.MaxBundleEntryCount` (default 4) is a
|
||||
zip-bomb guard, and the exporter never emits per-script files.
|
||||
|
||||
### `manifest.json` (plaintext)
|
||||
|
||||
@@ -131,7 +133,9 @@ flowchart TD
|
||||
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.
|
||||
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.
|
||||
|
||||
**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).
|
||||
|
||||
## Export Flow
|
||||
|
||||
@@ -227,7 +231,9 @@ Bulk "Apply to all" at the top (Overwrite / Skip / Rename), overridable per row.
|
||||
|
||||
Bundle references that cannot be satisfied in either the bundle or the target DB (e.g., a template references a shared script that is neither in the bundle nor pre-existing) appear as **blocker rows** — Apply is disabled until they are resolved (typically by skipping the dependent artifact or re-exporting with dependencies). Unmapped/unmatched sites and connections that the operator did not set to Create-new also surface as blocker rows here, mirroring the Step-3 Map decisions.
|
||||
|
||||
**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); and (c) filters out a small `KnownNonReferenceNames` denylist of .NET stdlib types (`Convert`, `DateTimeOffset`, `ToString`, `Dispose`, `UtcNow`, …), ScadaBridge runtime API roots (`Notify`, `Database`, `ExternalSystem`, `Scripts`, `Instance`, `Parameters`, `Attributes`, `Route`, …), and common SQL keywords that appear inside string literals (`COUNT`, `SELECT`, `FROM`, …). 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.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -286,6 +292,8 @@ Site identifiers and connection names are environment-specific, so a bundle expo
|
||||
|
||||
**Template second-pass rewires.** Bundles carry template-to-template references by **name**, not id (ids are environment-specific). After the templates are flushed, the importer runs three name-resolution passes over the imported templates so every cross-template FK points at the freshly-persisted target row: the **alarm on-trigger-script** links (`TemplateAlarm.OnTriggerScriptId`), the **composition** edges (`TemplateComposition`), and the **inheritance** edges (`Template.ParentTemplateId`, resolved from `BaseTemplateName` through the same rename/skip resolution map — a `null` base clears a stale edge on Overwrite). Each pass is best-effort: an unresolvable reference leaves the FK null and emits a warning audit row (`BundleImportAlarmScriptUnresolved` / `BundleImportCompositionUnresolved` / `BundleImportBaseTemplateUnresolved`) rather than aborting the import.
|
||||
|
||||
**Rename call-site limitation (load-bearing).** A `Rename` resolution renames the entity being imported (e.g. a SharedScript `Foo` → `Bar`), but it does **not** rewrite call sites inside importing scripts — a script body that calls `CallShared("Foo")` still says `Foo` after import. So the target DB can end up with scripts referencing the old name. This is deliberate: Pass 1 name resolution registers **both** the original and renamed names as resolvable (so the import isn't blocked), and the authoritative check is the target's own **deploy-time validation** (`ScriptCompiler.TryCompile` in the Template Engine deploy gate), which recompiles against the actual persisted catalog. Operators renaming a shared dependency must fix the calling scripts on the target (or re-export with the dependency included under its original name).
|
||||
|
||||
**FK rewiring at apply.** Inside the single EF transaction the importer resolves or creates the target sites and connections **first**, then upserts instances (forced to `NotDeployed`) and rewires their foreign keys against the resolved targets:
|
||||
|
||||
- `InstanceConnectionBinding.DataConnectionId` is resolved via the connection map, with a **Pass-2** that also binds connections which already exist in the target but were not carried in the bundle.
|
||||
@@ -307,7 +315,8 @@ As of M8 the import result enumerates affected instances for real (closing the p
|
||||
| Unlock | Wrong passphrase | Step 2 error; 3rd wrong attempt invalidates session, audit `BundleImportUnlockFailed` |
|
||||
| Map | Source site/connection neither auto-matched nor mapped/created | Listed as a blocker row in the Map step (UI) / Diff step; cannot Apply until mapped or set to Create-new. CLI aborts before apply unless `--create-missing-sites` / `--create-missing-connections` is given |
|
||||
| Map | `CreateNew` selected but the bundle does not carry that site/connection's config | Blocker — Create-new requires the full config to be present in the bundle payload |
|
||||
| Preview | Bundle references shared script not in bundle and not in target DB | Listed as a blocker row in the Diff step; cannot Apply until resolved |
|
||||
| Preview | ApiMethod script references shared script / external system not in bundle and not in target DB | Listed as a **blocker** row in the Diff step; cannot Apply until resolved (no downstream deploy gate for ApiMethods) |
|
||||
| Preview | Template script references shared script / external system not in bundle and not in target DB | Listed as an advisory **warning** row; import proceeds — the design-time deploy gate re-validates the script authoritatively |
|
||||
| Apply | Semantic validation fails (call target type mismatch, etc.) | Modal: "Validation failed — N errors", per-error list, no DB writes |
|
||||
| Apply | DB transaction fails | Rollback; full transactional guarantee — nothing partial lands; `BundleImportFailed` audit row written outside the rolled-back transaction |
|
||||
| Session | TTL expired | Diff step onward, refresh prompts re-upload |
|
||||
|
||||
@@ -275,6 +275,14 @@
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (item.Kind == ConflictKind.Warning && !string.IsNullOrEmpty(item.BlockerReason))
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="alert alert-warning small mb-0">@item.BlockerReason</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -421,6 +429,7 @@
|
||||
ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
|
||||
ConflictKind.New => ("bg-success", "New"),
|
||||
ConflictKind.Blocker => ("bg-danger", "Blocker"),
|
||||
ConflictKind.Warning => ("bg-warning text-dark", "Warning"),
|
||||
_ => ("bg-light text-dark", item.Kind.ToString()),
|
||||
};
|
||||
<span class="badge @cls">@label</span>
|
||||
@@ -444,6 +453,13 @@
|
||||
<span class="text-muted small">—</span>
|
||||
return;
|
||||
}
|
||||
// Warning items are advisory "Reference" rows, not importable entities —
|
||||
// no resolution to choose (the import proceeds regardless).
|
||||
if (item.Kind == ConflictKind.Warning)
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
return;
|
||||
}
|
||||
|
||||
var key = (item.EntityType, item.Name);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// In-process, per-node publish/subscribe seam for
|
||||
/// <see cref="ScriptArtifactsChanged"/>. Producers (e.g. the Transport bundle
|
||||
/// importer, the ManagementActor script-editing paths) publish AFTER their mutating
|
||||
/// transaction commits; consumers (e.g. the Inbound API compiled-handler cache)
|
||||
/// subscribe and evict.
|
||||
/// <para>
|
||||
/// A conforming implementation MUST swallow-and-log any exception thrown by a
|
||||
/// subscriber's handler — a misbehaving consumer must never fault a producer's
|
||||
/// commit path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IScriptArtifactChangeBus
|
||||
{
|
||||
/// <summary>Publishes a change notification to every current subscriber. Never throws on a subscriber fault.</summary>
|
||||
/// <param name="notification">The change notification to deliver.</param>
|
||||
void Publish(ScriptArtifactsChanged notification);
|
||||
|
||||
/// <summary>Subscribes a handler; dispose the returned token to unsubscribe.</summary>
|
||||
/// <param name="handler">The handler invoked for each published notification.</param>
|
||||
/// <returns>A disposable that removes the subscription when disposed.</returns>
|
||||
IDisposable Subscribe(Action<ScriptArtifactsChanged> handler);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory notification that one or more script-bearing artifacts changed on this
|
||||
/// node. It is <b>at-least-once</b> and is published only <b>AFTER</b> the mutating
|
||||
/// transaction commits — never inside it (a notification for a rolled-back write is
|
||||
/// worse than a missed one).
|
||||
/// <para>
|
||||
/// Consumers MUST invalidate any compiled/cached artifact keyed by these names, and
|
||||
/// MUST ALSO be able to self-heal <i>without</i> the notification (e.g. a
|
||||
/// content-hash / revision check on next use) — this bus is <b>in-process, per
|
||||
/// node</b>; cross-node and failover propagation is the consumer's responsibility.
|
||||
/// See <c>docs/plans/2026-07-08-script-artifact-invalidation-contract.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="ArtifactKind">One of <see cref="ScriptArtifactKinds"/> — the kind of artifact that changed.</param>
|
||||
/// <param name="Names">The post-resolution (renamed) artifact names that changed.</param>
|
||||
/// <param name="Source">Origin of the change: <c>"BundleImport"</c>, <c>"Management"</c>, or <c>"FailoverActivation"</c>.</param>
|
||||
/// <param name="OccurredAtUtc">When the committing transaction completed.</param>
|
||||
public sealed record ScriptArtifactsChanged(
|
||||
string ArtifactKind,
|
||||
IReadOnlyList<string> Names,
|
||||
string Source,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The canonical set of <see cref="ScriptArtifactsChanged.ArtifactKind"/> values —
|
||||
/// the artifact classes that carry compiled/cached script and therefore need
|
||||
/// invalidation when their definition changes.
|
||||
/// </summary>
|
||||
public static class ScriptArtifactKinds
|
||||
{
|
||||
/// <summary>An Inbound API method (its script handler is compiled and cached).</summary>
|
||||
public const string ApiMethod = "ApiMethod";
|
||||
|
||||
/// <summary>A shared script (compiled inline at every call site that references it).</summary>
|
||||
public const string SharedScript = "SharedScript";
|
||||
|
||||
/// <summary>A template (its scripts flatten into every instance built from it).</summary>
|
||||
public const string Template = "Template";
|
||||
}
|
||||
@@ -12,8 +12,8 @@ public sealed record ConfigurationDiff
|
||||
public string? OldRevisionHash { get; init; }
|
||||
/// <summary>Revision hash of the new configuration being compared.</summary>
|
||||
public string? NewRevisionHash { get; init; }
|
||||
/// <summary>True when any attribute, alarm, script, or connection changes are present.</summary>
|
||||
public bool HasChanges => AttributeChanges.Count > 0 || AlarmChanges.Count > 0 || ScriptChanges.Count > 0 || ConnectionChanges.Count > 0;
|
||||
/// <summary>True when any attribute, alarm, script, native alarm source, or connection changes are present.</summary>
|
||||
public bool HasChanges => AttributeChanges.Count > 0 || AlarmChanges.Count > 0 || ScriptChanges.Count > 0 || NativeAlarmSourceChanges.Count > 0 || ConnectionChanges.Count > 0;
|
||||
|
||||
/// <summary>Diff entries for resolved attributes.</summary>
|
||||
public IReadOnlyList<DiffEntry<ResolvedAttribute>> AttributeChanges { get; init; } = [];
|
||||
@@ -22,6 +22,13 @@ public sealed record ConfigurationDiff
|
||||
/// <summary>Diff entries for resolved scripts.</summary>
|
||||
public IReadOnlyList<DiffEntry<ResolvedScript>> ScriptChanges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Diff entries for resolved native alarm source bindings, keyed by canonical
|
||||
/// name. Surfaces native-alarm-source edits (connection, source reference,
|
||||
/// condition filter, lock state) in the Deployments diff view.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DiffEntry<ResolvedNativeAlarmSource>> NativeAlarmSourceChanges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Diff entries for connection configurations, keyed by connection name.
|
||||
/// Surfaces standalone endpoint/protocol/failover drift that does not show
|
||||
|
||||
@@ -146,6 +146,12 @@ public sealed record ResolvedNativeAlarmSource
|
||||
public string? ConditionFilter { get; init; }
|
||||
/// <summary>Gets the source of this binding: "Template", "Inherited", "Composed", or "Override".</summary>
|
||||
public string Source { get; init; } = "Template";
|
||||
/// <summary>
|
||||
/// Gets whether this native alarm source is locked at the template level. A locked
|
||||
/// source cannot be repointed by an instance override — mirrors the attribute/alarm
|
||||
/// lock semantics.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public enum ConflictKind { Identical, Modified, New, Blocker }
|
||||
// Warning is a NON-blocking advisory finding (appended after Blocker so the
|
||||
// enum ordinal of the existing members is unchanged). Import may proceed with
|
||||
// Warning items present; only Blocker items stop an apply. Template-script
|
||||
// name-resolution findings surface as Warning because the design-time deploy
|
||||
// gate (ScriptCompiler.TryCompile) re-validates them authoritatively; ApiMethod
|
||||
// findings — which have no downstream gate — stay Blocker.
|
||||
public enum ConflictKind { Identical, Modified, New, Blocker, Warning }
|
||||
|
||||
public sealed record ImportPreviewItem(
|
||||
string EntityType,
|
||||
|
||||
@@ -11,4 +11,15 @@ public sealed record ImportResult(
|
||||
// Number of legacy inbound API keys found in the bundle that were ignored
|
||||
// (keys are not transported; re-create them on this environment).
|
||||
// Defaults to 0 so existing positional construction sites stay source-compatible.
|
||||
int ApiKeysIgnored = 0);
|
||||
int ApiKeysIgnored = 0,
|
||||
// Advisory (non-blocking) import findings surfaced during apply — currently
|
||||
// template-script name-resolution references that didn't resolve to a bundled
|
||||
// or target SharedScript / ExternalSystem. These are warnings, NOT errors:
|
||||
// the deploy-time gate re-validates them authoritatively. ApiMethod-script
|
||||
// findings are hard errors instead and surface via SemanticValidationException.
|
||||
// Defaulted null → normalized to empty so existing positional callers compile.
|
||||
IReadOnlyList<string>? Warnings = null)
|
||||
{
|
||||
/// <summary>Advisory, non-blocking import findings. Never null.</summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Warnings ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
+6
-1
@@ -17,7 +17,12 @@ public class NotificationRepository : INotificationRepository
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().FindAsync(new object[] { id }, cancellationToken);
|
||||
// Eager-load Recipients: single-list selection feeds the Transport exporter,
|
||||
// which serializes the recipient collection — a FindAsync without the Include
|
||||
// exported a recipient-less list (silent data loss).
|
||||
=> await _context.Set<NotificationList>()
|
||||
.Include(n => n.Recipients)
|
||||
.FirstOrDefaultAsync(n => n.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -326,8 +326,11 @@ public class DeploymentService
|
||||
forceEnabledState: true, cancellationToken);
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await _auditService.LogAsync(user, "Deploy", "Instance", instanceId.ToString(),
|
||||
// Audit log. Best-effort and post-terminal: the deployment record's
|
||||
// status is already committed above, so a failed audit write must NOT
|
||||
// fall through to the outer catch and flip a committed Success back to
|
||||
// Failed (audit is best-effort — the action's own result is authoritative).
|
||||
await TryLogAuditAsync(user, "Deploy", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { DeploymentId = deploymentId, Status = record.Status.ToString() },
|
||||
cancellationToken);
|
||||
|
||||
@@ -458,7 +461,7 @@ public class DeploymentService
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await _auditService.LogAsync(user, "Disable", "Instance", instanceId.ToString(),
|
||||
await TryLogAuditAsync(user, "Disable", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, response.Success },
|
||||
cancellationToken);
|
||||
|
||||
@@ -522,7 +525,7 @@ public class DeploymentService
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await _auditService.LogAsync(user, "Enable", "Instance", instanceId.ToString(),
|
||||
await TryLogAuditAsync(user, "Enable", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, response.Success },
|
||||
cancellationToken);
|
||||
|
||||
@@ -560,6 +563,37 @@ public class DeploymentService
|
||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||
|
||||
var commandId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// A NotDeployed instance has no live Instance Actor at any site, so Delete is
|
||||
// a pure central-side record cleanup -- there is nothing to tear down at the
|
||||
// site and no reason to require the site be reachable. Transport-imported
|
||||
// instances always land NotDeployed (often against an uncommissioned or
|
||||
// unreachable site), so a site round-trip here would make them undeletable
|
||||
// until their site comes online. Skip the site entirely and remove the record.
|
||||
if (instance.State == InstanceState.NotDeployed)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to delete NotDeployed instance {Instance} centrally", instance.UniqueName);
|
||||
return Result<InstanceLifecycleResponse>.Failure(
|
||||
$"Delete failed: the central record for '{instance.UniqueName}' could not be removed: {ex.Message}.");
|
||||
}
|
||||
|
||||
await TryLogAuditAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName,
|
||||
new { CommandId = commandId, Success = true, SiteRoundTripSkipped = true },
|
||||
cancellationToken);
|
||||
|
||||
return Result<InstanceLifecycleResponse>.Success(new InstanceLifecycleResponse(
|
||||
commandId, instance.UniqueName, Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||
var command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
||||
|
||||
@@ -609,7 +643,7 @@ public class DeploymentService
|
||||
"removed -- the central record is now orphaned and must be reconciled manually",
|
||||
instance.UniqueName);
|
||||
|
||||
await _auditService.LogAsync(user, "DeleteOrphaned", "Instance", instanceId.ToString(),
|
||||
await TryLogAuditAsync(user, "DeleteOrphaned", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, Error = ex.Message },
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -619,7 +653,7 @@ public class DeploymentService
|
||||
}
|
||||
}
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
await TryLogAuditAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, response.Success },
|
||||
cancellationToken);
|
||||
|
||||
@@ -648,8 +682,12 @@ public class DeploymentService
|
||||
if (snapshot == null)
|
||||
return Result<DeploymentComparisonResult>.Failure("No deployed snapshot found for this instance.");
|
||||
|
||||
// Compute current template-derived config
|
||||
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken);
|
||||
// Compute current template-derived config. This read-only comparison only needs
|
||||
// the revision hash (staleness) + the flattened config (structured diff) — NOT a
|
||||
// script compile — so skip the expensive Roslyn ValidateScriptCompilation stage.
|
||||
// The deploy gate remains the authoritative compile.
|
||||
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
|
||||
instanceId, cancellationToken, validateScripts: false);
|
||||
if (currentResult.IsFailure)
|
||||
return Result<DeploymentComparisonResult>.Failure($"Cannot compute current config: {currentResult.Error}");
|
||||
|
||||
@@ -1031,6 +1069,37 @@ public class DeploymentService
|
||||
/// <param name="instanceUniqueName">The instance unique name used as the audit target name.</param>
|
||||
/// <param name="commandId">The lifecycle command's correlation id, so the audit entry can be matched to logs.</param>
|
||||
/// <param name="timeoutException">The captured <see cref="TimeoutException"/> or <see cref="OperationCanceledException"/>.</param>
|
||||
/// <summary>
|
||||
/// Best-effort audit write for a post-terminal-status action. By the time this
|
||||
/// runs, the deployment/lifecycle action has already committed its authoritative
|
||||
/// result; a failed audit write must be logged for operator reconciliation but
|
||||
/// must NEVER surface to the caller or flip the committed status — "audit-write
|
||||
/// failure NEVER aborts the user-facing action" (Centralized Audit Log design).
|
||||
/// Any exception is swallowed and logged at Warning.
|
||||
/// </summary>
|
||||
private async Task TryLogAuditAsync(
|
||||
string user,
|
||||
string action,
|
||||
string entityType,
|
||||
string entityId,
|
||||
string entityName,
|
||||
object? afterState,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _auditService.LogAsync(
|
||||
user, action, entityType, entityId, entityName, afterState, cancellationToken);
|
||||
}
|
||||
catch (Exception auditEx)
|
||||
{
|
||||
_logger.LogWarning(auditEx,
|
||||
"Failed to write {Action} audit entry for {EntityType} {EntityName} -- the action " +
|
||||
"itself succeeded and its committed status is authoritative; audit is best-effort",
|
||||
action, entityType, entityName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryLogLifecycleTimeoutAsync(
|
||||
string user,
|
||||
string action,
|
||||
|
||||
@@ -54,7 +54,8 @@ public class FlatteningPipeline : IFlatteningPipeline
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
||||
int instanceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
bool validateScripts = true)
|
||||
{
|
||||
// Load instance with full graph
|
||||
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
@@ -158,13 +159,19 @@ public class FlatteningPipeline : IFlatteningPipeline
|
||||
// on the site — blocks the deployment. (The template DESIGN-TIME validate path in
|
||||
// ManagementActor leaves this non-blocking by NOT enforcing, since bindings are
|
||||
// set later at instance/deploy time.)
|
||||
// validateScripts == false is the read-only staleness/comparison path: skip
|
||||
// only the expensive Roslyn ValidateScriptCompilation (which loads a
|
||||
// non-collectible InteractiveAssemblyLoader assembly per script). Structural
|
||||
// and semantic validation still run so a genuinely broken flatten is surfaced;
|
||||
// the deploy path (validateScripts: true) remains the authoritative compile gate.
|
||||
var validation = _validationService.Validate(
|
||||
config,
|
||||
resolvedSharedScripts,
|
||||
alarmCapableConnectionNames,
|
||||
enforceConnectionBindings: true,
|
||||
siteConnectionNames: siteConnectionNames,
|
||||
resolveSchemaRef: resolveSchemaRef);
|
||||
resolveSchemaRef: resolveSchemaRef,
|
||||
validateScriptCompilation: validateScripts);
|
||||
|
||||
// Compute revision hash
|
||||
var hash = _revisionHashService.ComputeHash(config);
|
||||
|
||||
@@ -15,10 +15,19 @@ public interface IFlatteningPipeline
|
||||
/// </summary>
|
||||
/// <param name="instanceId">Id of the instance to flatten and validate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <param name="validateScripts">
|
||||
/// When <c>true</c> (the deploy default), the authoritative Roslyn script-compilation
|
||||
/// gate runs. When <c>false</c>, that compile step is skipped — used by the read-only
|
||||
/// staleness/comparison paths (Deployments comparison page,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport.IStaleInstanceProbe"/>)
|
||||
/// which need only the flattened config + revision hash, not a compile. Structural and
|
||||
/// semantic validation still run in both cases.
|
||||
/// </param>
|
||||
/// <returns>A task that resolves to the flattened configuration, revision hash, and validation result; or a failure result if flattening could not complete.</returns>
|
||||
Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
||||
int instanceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool validateScripts = true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -30,8 +30,11 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
|
||||
// instance as "staleness indeterminate" and skips it. Flattening reuses
|
||||
// the scoped ITemplateEngineRepository, so it observes template rows the
|
||||
// in-flight import has staged on the shared change tracker.
|
||||
// Staleness only needs the revision hash — skip the expensive Roslyn
|
||||
// script-compilation stage (this probe is called per-instance across a whole
|
||||
// bundle import in BundleImporter.ComputeStaleInstanceIdsAsync).
|
||||
var result = await _flatteningPipeline
|
||||
.FlattenAndValidateAsync(instanceId, cancellationToken)
|
||||
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false)
|
||||
.ConfigureAwait(false);
|
||||
return result.IsSuccess ? result.Value.RevisionHash : null;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,13 @@ try
|
||||
// TransportOptions from ScadaBridge:Transport via BindConfiguration; no
|
||||
// explicit Configure needed.
|
||||
builder.Services.AddTransport();
|
||||
// Script-artifact invalidation bus (#05-T14): in-process, per-node pub/sub
|
||||
// for ScriptArtifactsChanged. The Transport bundle importer publishes
|
||||
// post-commit; the Inbound API compiled-handler cache subscribes as the
|
||||
// consumer (wired in plan 06). Central-only — it lives beside the importer.
|
||||
builder.Services.AddSingleton<
|
||||
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting.IScriptArtifactChangeBus,
|
||||
ZB.MOM.WW.ScadaBridge.Host.Services.InProcessScriptArtifactChangeBus>();
|
||||
// Audit Log — central node owns the AuditLogIngestActor singleton +
|
||||
// IAuditLogRepository. The site writer chain is still registered (lazy
|
||||
// singletons) but is never resolved on a central node.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-process, per-node <see cref="IScriptArtifactChangeBus"/>: a lock-free
|
||||
/// copy-on-write subscriber list. <see cref="Publish"/> snapshots the current
|
||||
/// handlers and invokes each, swallowing and logging any handler fault so a
|
||||
/// misbehaving consumer can never fault a producer's post-commit publish.
|
||||
/// <para>
|
||||
/// This is deliberately node-local — cross-node / failover propagation is the
|
||||
/// consumer's responsibility (content-hash / revision self-heal). See
|
||||
/// <c>docs/plans/2026-07-08-script-artifact-invalidation-contract.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InProcessScriptArtifactChangeBus : IScriptArtifactChangeBus
|
||||
{
|
||||
private readonly Lock _writeLock = new();
|
||||
private readonly ILogger<InProcessScriptArtifactChangeBus> _logger;
|
||||
|
||||
// Copy-on-write: readers (Publish) take the reference without locking; writers
|
||||
// (Subscribe/unsubscribe) swap in a new immutable array under _writeLock.
|
||||
private volatile Action<ScriptArtifactsChanged>[] _handlers =
|
||||
Array.Empty<Action<ScriptArtifactsChanged>>();
|
||||
|
||||
/// <summary>Initializes the bus with a logger for swallowed handler faults.</summary>
|
||||
/// <param name="logger">Logger used to record subscriber handler exceptions.</param>
|
||||
public InProcessScriptArtifactChangeBus(ILogger<InProcessScriptArtifactChangeBus> logger) =>
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(ScriptArtifactsChanged notification)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
// Snapshot without locking — the array is never mutated in place.
|
||||
var handlers = _handlers;
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
try
|
||||
{
|
||||
handler(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A bad consumer must NEVER fault the producer's commit path.
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Script-artifact-change subscriber threw handling {Kind} ({Count} names) from {Source}; ignored.",
|
||||
notification.ArtifactKind,
|
||||
notification.Names.Count,
|
||||
notification.Source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
lock (_writeLock)
|
||||
{
|
||||
var updated = new Action<ScriptArtifactsChanged>[_handlers.Length + 1];
|
||||
Array.Copy(_handlers, updated, _handlers.Length);
|
||||
updated[^1] = handler;
|
||||
_handlers = updated;
|
||||
}
|
||||
|
||||
return new Subscription(this, handler);
|
||||
}
|
||||
|
||||
private void Unsubscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
var index = Array.IndexOf(_handlers, handler);
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = new Action<ScriptArtifactsChanged>[_handlers.Length - 1];
|
||||
Array.Copy(_handlers, 0, updated, 0, index);
|
||||
Array.Copy(_handlers, index + 1, updated, index, _handlers.Length - index - 1);
|
||||
_handlers = updated;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private readonly InProcessScriptArtifactChangeBus _bus;
|
||||
private Action<ScriptArtifactsChanged>? _handler;
|
||||
|
||||
public Subscription(InProcessScriptArtifactChangeBus bus, Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
_bus = bus;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Idempotent: only the first Dispose removes the handler.
|
||||
var handler = Interlocked.Exchange(ref _handler, null);
|
||||
if (handler is not null)
|
||||
{
|
||||
_bus.Unsubscribe(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -885,6 +885,27 @@ public class ManagementActor : ReceiveActor
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
|
||||
// Enforce template-level locks (and reject dangling overrides) before persisting.
|
||||
// Flatten the instance (script compilation skipped — a lock check does not need it)
|
||||
// and look up the resolved source by canonical name: a locked source cannot be
|
||||
// overridden, and an unresolved name would create a dead override the flattener
|
||||
// silently drops.
|
||||
var pipeline = sp.GetRequiredService<IFlatteningPipeline>();
|
||||
var flattenResult = await pipeline.FlattenAndValidateAsync(
|
||||
cmd.InstanceId, default, validateScripts: false);
|
||||
if (flattenResult.IsFailure)
|
||||
throw new ManagementCommandException(
|
||||
$"Cannot set native alarm source override: the instance could not be flattened ({flattenResult.Error}).");
|
||||
|
||||
var resolvedSource = flattenResult.Value.Configuration.NativeAlarmSources
|
||||
.FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal));
|
||||
if (resolvedSource == null)
|
||||
throw new ManagementCommandException(
|
||||
$"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden.");
|
||||
if (resolvedSource.IsLocked)
|
||||
throw new ManagementCommandException(
|
||||
$"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden.");
|
||||
|
||||
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
|
||||
if (existing == null)
|
||||
{
|
||||
|
||||
@@ -42,6 +42,23 @@ public static class ScriptTrustPolicy
|
||||
"System.Net",
|
||||
"System.Runtime.InteropServices",
|
||||
"Microsoft.Win32",
|
||||
// Whole type — no legitimate script use. Exposes process control
|
||||
// (Exit/FailFast), the host environment, and secrets via environment
|
||||
// variables (SCADABRIDGE_API_KEY). Callers needing a line break use "\n".
|
||||
"System.Environment",
|
||||
// Whole type — GC control (Collect, KeepAlive, memory pressure knobs) has
|
||||
// no legitimate script use.
|
||||
"System.GC",
|
||||
// Concrete ADO.NET provider namespaces. Scripts reach a DbConnection ONLY
|
||||
// via the Database helper, whose abstract System.Data.Common types stay
|
||||
// allowed; forbidding the concrete providers closes the arbitrary-host
|
||||
// `new SqlConnection("Server=attacker")` channel. Deliberately NOT
|
||||
// forbidding System.Data or System.Data.Common broadly — only these
|
||||
// concrete provider namespaces.
|
||||
"Microsoft.Data",
|
||||
"System.Data.SqlClient",
|
||||
"System.Data.Odbc",
|
||||
"System.Data.OleDb",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -85,6 +102,18 @@ public static class ScriptTrustPolicy
|
||||
"GetRuntimeMethods",
|
||||
"MethodHandle",
|
||||
"TypeHandle",
|
||||
"GetTypes",
|
||||
"EntryPoint",
|
||||
"DeclaredMethods",
|
||||
"DeclaredMembers",
|
||||
"DeclaredConstructors",
|
||||
"DynamicInvoke",
|
||||
// DELIBERATELY EXCLUDED: "Invoke". The syntactic pass rejects a gateway
|
||||
// member regardless of receiver, and delegate.Invoke() is a legitimate,
|
||||
// common script pattern (Func<>/Action<> invocation). MethodInfo.Invoke
|
||||
// (the reflection late-bind) is already caught semantically via the
|
||||
// System.Reflection scope, so excluding "Invoke" here avoids a
|
||||
// false-positive blast radius without weakening the reflection deny-list.
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -115,11 +115,21 @@ public static class ScriptTrustValidator
|
||||
if (extraReferences != null)
|
||||
references.AddRange(extraReferences);
|
||||
|
||||
// Apply the SAME default namespace imports the site runtime compiles the
|
||||
// real script with (ScriptTrustPolicy.DefaultImports — "using System;"
|
||||
// etc.). Without them the semantic pass under-resolves BARE identifiers
|
||||
// that the running script sees fully bound — e.g. `Environment.Exit(0)` or
|
||||
// `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden
|
||||
// System.Environment/System.GC types only when `using System;` is in
|
||||
// scope. Matching the runtime's import set closes that blind spot; it can
|
||||
// only make MORE identifiers resolve to their true (forbidden) namespace,
|
||||
// never produce a false allow.
|
||||
var compilation = CSharpCompilation.CreateScriptCompilation(
|
||||
"TrustValidation",
|
||||
tree,
|
||||
references,
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
|
||||
.WithUsings(ScriptTrustPolicy.DefaultImports));
|
||||
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
|
||||
|
||||
@@ -123,24 +123,37 @@ public static class CollisionDetector
|
||||
List<ResolvedMember> members,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
// `visited` is the current RECURSION PATH, not a global visited-set: an id is
|
||||
// added on entry and REMOVED on exit (finally), so a template legitimately
|
||||
// composed into more than one slot is re-descended for each slot. The early
|
||||
// return fires only on a genuine cycle — an id already on the ACTIVE path.
|
||||
// (A global visited-set would blind the detector to every collision reachable
|
||||
// only through the second-and-later slot of a repeatedly-composed template.)
|
||||
if (!visited.Add(template.Id))
|
||||
return;
|
||||
|
||||
// Add direct members of this composed template with the prefix. Inherited
|
||||
// placeholder rows are KEPT here: the composed-module walk does not climb the
|
||||
// composed template's parent chain, so the placeholders are the only
|
||||
// representation of a derived module's inherited members.
|
||||
CollectDirectMembers(template, prefix, $"module '{prefix}'", members, skipInherited: false);
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composition in template.Compositions)
|
||||
try
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
|
||||
// Add direct members of this composed template with the prefix. Inherited
|
||||
// placeholder rows are KEPT here: the composed-module walk does not climb the
|
||||
// composed template's parent chain, so the placeholders are the only
|
||||
// representation of a derived module's inherited members.
|
||||
CollectDirectMembers(template, prefix, $"module '{prefix}'", members, skipInherited: false);
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
|
||||
CollectComposedMembers(nested, nestedPrefix, lookup, members, visited);
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
|
||||
{
|
||||
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
|
||||
CollectComposedMembers(nested, nestedPrefix, lookup, members, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
visited.Remove(template.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectInheritedMembers(
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two FlattenedConfigurations (deployed vs current) and produces a ConfigurationDiff
|
||||
/// showing Added, Removed, and Changed entries for attributes, alarms, and scripts.
|
||||
/// showing Added, Removed, and Changed entries for attributes, alarms, scripts,
|
||||
/// and native alarm sources.
|
||||
/// </summary>
|
||||
public class DiffService
|
||||
{
|
||||
@@ -42,6 +43,12 @@ public class DiffService
|
||||
s => s.CanonicalName,
|
||||
ScriptsEqual);
|
||||
|
||||
var nativeAlarmSourceChanges = ComputeEntityDiff(
|
||||
oldConfig?.NativeAlarmSources ?? [],
|
||||
newConfig.NativeAlarmSources,
|
||||
n => n.CanonicalName,
|
||||
NativeAlarmSourcesEqual);
|
||||
|
||||
// Surface standalone connection endpoint/protocol/
|
||||
// failover drift. Per-attribute binding changes already show up under
|
||||
// AttributeChanges, but a connection's own ConfigurationJson /
|
||||
@@ -57,6 +64,7 @@ public class DiffService
|
||||
AttributeChanges = attributeChanges,
|
||||
AlarmChanges = alarmChanges,
|
||||
ScriptChanges = scriptChanges,
|
||||
NativeAlarmSourceChanges = nativeAlarmSourceChanges,
|
||||
ConnectionChanges = connectionChanges
|
||||
};
|
||||
}
|
||||
@@ -138,6 +146,13 @@ public class DiffService
|
||||
a.TriggerConfiguration == b.TriggerConfiguration &&
|
||||
a.OnTriggerScriptCanonicalName == b.OnTriggerScriptCanonicalName;
|
||||
|
||||
private static bool NativeAlarmSourcesEqual(ResolvedNativeAlarmSource a, ResolvedNativeAlarmSource b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.ConnectionName == b.ConnectionName &&
|
||||
a.SourceReference == b.SourceReference &&
|
||||
a.ConditionFilter == b.ConditionFilter &&
|
||||
a.IsLocked == b.IsLocked;
|
||||
|
||||
private static bool ScriptsEqual(ResolvedScript a, ResolvedScript b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.Code == b.Code &&
|
||||
|
||||
@@ -262,7 +262,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -283,17 +283,27 @@ public class FlatteningService
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAttributesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, attributes, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAttributesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, attributes, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,7 +635,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAlarm> alarms,
|
||||
Dictionary<string, int> alarmScriptIds,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -646,17 +656,27 @@ public class FlatteningService
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAlarmsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, alarms, alarmScriptIds, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAlarmsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, alarms, alarmScriptIds, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,7 +718,8 @@ public class FlatteningService
|
||||
ConnectionName = binding.ConnectionName,
|
||||
SourceReference = binding.SourceReference,
|
||||
ConditionFilter = binding.ConditionFilter,
|
||||
Source = source
|
||||
Source = source,
|
||||
IsLocked = binding.IsLocked || lockedNames.Contains(binding.Name)
|
||||
};
|
||||
|
||||
if (binding.IsLocked)
|
||||
@@ -739,7 +760,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedNativeAlarmSource> sources,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -758,17 +779,27 @@ public class FlatteningService
|
||||
}
|
||||
}
|
||||
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedNativeAlarmSourcesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, sources, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedNativeAlarmSourcesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, sources, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,6 +812,9 @@ public class FlatteningService
|
||||
if (!sources.TryGetValue(ovr.SourceCanonicalName, out var existing))
|
||||
continue; // Cannot add new bindings via overrides.
|
||||
|
||||
if (existing.IsLocked)
|
||||
continue; // Locked at the template level — instance override is ignored.
|
||||
|
||||
sources[ovr.SourceCanonicalName] = existing with
|
||||
{
|
||||
ConnectionName = ovr.ConnectionNameOverride ?? existing.ConnectionName,
|
||||
@@ -889,7 +923,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedScript> scripts,
|
||||
Dictionary<int, string> scriptCanonicalById,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -910,17 +944,27 @@ public class FlatteningService
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedScriptsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
|
||||
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedScriptsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
|
||||
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
/// by <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
|
||||
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// MIGRATION (TemplateEngine-011): native alarm source bindings
|
||||
/// (<see cref="ResolvedNativeAlarmSource"/>) now participate in the hash so
|
||||
/// that binding edits flag the instance stale. The hashable property is
|
||||
/// populated NULL-WHEN-EMPTY and the serializer omits null properties
|
||||
/// (<see cref="JsonIgnoreCondition.WhenWritingNull"/>), so every
|
||||
/// native-source-free configuration keeps its byte-identical pre-migration
|
||||
/// hash — no global staleness storm. Native-source-bearing instances will
|
||||
/// report stale once after upgrade; redeploy clears it — deliberate.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class RevisionHashService
|
||||
{
|
||||
@@ -90,6 +100,19 @@ public class RevisionHashService
|
||||
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds
|
||||
})
|
||||
.ToList(),
|
||||
NativeAlarmSources = configuration.NativeAlarmSources is { Count: > 0 }
|
||||
? configuration.NativeAlarmSources
|
||||
.OrderBy(n => n.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(n => new HashableNativeAlarmSource
|
||||
{
|
||||
CanonicalName = n.CanonicalName,
|
||||
ConditionFilter = n.ConditionFilter,
|
||||
ConnectionName = n.ConnectionName,
|
||||
IsLocked = n.IsLocked,
|
||||
SourceReference = n.SourceReference
|
||||
})
|
||||
.ToList()
|
||||
: null,
|
||||
Connections = configuration.Connections is { Count: > 0 }
|
||||
? new SortedDictionary<string, HashableConnection>(
|
||||
configuration.Connections.ToDictionary(
|
||||
@@ -138,6 +161,13 @@ public class RevisionHashService
|
||||
/// </summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Collection of native alarm source bindings in the configuration.
|
||||
/// Null when the configuration carries no native alarm sources so that
|
||||
/// native-source-free configs hash identically to before this field was
|
||||
/// added (see the MIGRATION note on the class summary).
|
||||
/// </summary>
|
||||
public List<HashableNativeAlarmSource>? NativeAlarmSources { get; init; }
|
||||
/// <summary>
|
||||
/// Collection of scripts in the configuration.
|
||||
/// </summary>
|
||||
public List<HashableScript> Scripts { get; init; } = [];
|
||||
@@ -247,6 +277,30 @@ public class RevisionHashService
|
||||
public string Protocol { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableNativeAlarmSource
|
||||
{
|
||||
/// <summary>
|
||||
/// The path-qualified canonical name of the native alarm source binding.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Optional condition filter; null = mirror all conditions under the source.
|
||||
/// </summary>
|
||||
public string? ConditionFilter { get; init; }
|
||||
/// <summary>
|
||||
/// The data connection name that owns the alarm feed.
|
||||
/// </summary>
|
||||
public string ConnectionName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Whether the native alarm source binding is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// The source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area).
|
||||
/// </summary>
|
||||
public string SourceReference { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableScript
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cache of script compile verdicts, keyed on a SHA-256 hash of the
|
||||
/// script code. The verdict (clean/compiles, or the name-free error text) is a pure
|
||||
/// function of the code plus the compile-time-static <c>ScriptTrustPolicy</c> and
|
||||
/// globals surface, so it is sound to memoise it across the whole process: an
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// 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
|
||||
/// same bad code each see their own name in the failure text while sharing one verdict.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Bounded at <see cref="MaxEntries"/> entries; on overflow the cache is simply
|
||||
/// cleared (the verdict is cheap to recompute, so a coarse reset is safe and avoids
|
||||
/// eviction bookkeeping). <see cref="Hits"/>/<see cref="Count"/>/<see cref="Clear"/>
|
||||
/// are exposed for tests and diagnostics.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ScriptCompileVerdictCache
|
||||
{
|
||||
/// <summary>Upper bound on cached entries; the cache is cleared wholesale on overflow.</summary>
|
||||
private const int MaxEntries = 4096;
|
||||
|
||||
private static readonly ConcurrentDictionary<string, (bool Ok, string? Error)> Cache = new();
|
||||
private static long _hits;
|
||||
|
||||
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
|
||||
public static long Hits => Interlocked.Read(ref _hits);
|
||||
|
||||
/// <summary>Current number of cached verdicts.</summary>
|
||||
public static int Count => Cache.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached verdict for <paramref name="code"/>, or computes it via
|
||||
/// <paramref name="factory"/> and caches it on a miss. A hit increments
|
||||
/// <see cref="Hits"/>. The verdict's error text must be name-free — the caller
|
||||
/// formats the script name in on return.
|
||||
/// </summary>
|
||||
public static (bool Ok, string? Error) GetOrAdd(string code, Func<(bool Ok, string? Error)> factory)
|
||||
{
|
||||
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
|
||||
|
||||
if (Cache.TryGetValue(key, out var verdict))
|
||||
{
|
||||
Interlocked.Increment(ref _hits);
|
||||
return verdict;
|
||||
}
|
||||
|
||||
verdict = factory();
|
||||
|
||||
// Coarse bound: on overflow drop everything rather than track evictions.
|
||||
if (Cache.Count >= MaxEntries)
|
||||
Cache.Clear();
|
||||
|
||||
Cache[key] = verdict;
|
||||
return verdict;
|
||||
}
|
||||
|
||||
/// <summary>Clears all cached verdicts and resets the hit counter.</summary>
|
||||
public static void Clear()
|
||||
{
|
||||
Cache.Clear();
|
||||
Interlocked.Exchange(ref _hits, 0);
|
||||
}
|
||||
}
|
||||
@@ -35,17 +35,30 @@ public class ScriptCompiler
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
|
||||
|
||||
// Authoritative forbidden-API verdict first — a security violation must
|
||||
// gate the script regardless of whether it otherwise compiles.
|
||||
var violations = ScriptTrustValidator.FindViolations(code);
|
||||
if (violations.Count > 0)
|
||||
return Result<bool>.Failure($"Script '{scriptName}' uses forbidden API: {violations[0]}");
|
||||
// The verdict is a pure function of the code (the forbidden-API policy and
|
||||
// globals surface are compile-time static), so memoise it process-wide: an
|
||||
// unchanged script compiles exactly once per process. The cached error is
|
||||
// name-free — the script name is formatted in below at read time.
|
||||
var (ok, error) = ScriptCompileVerdictCache.GetOrAdd(code, () =>
|
||||
{
|
||||
// Authoritative forbidden-API verdict first — a security violation must
|
||||
// gate the script regardless of whether it otherwise compiles. Report
|
||||
// ALL violations (#05-T24), not just the first, so an operator fixing
|
||||
// one forbidden API isn't surprised by a second on the next attempt.
|
||||
var violations = ScriptTrustValidator.FindViolations(code);
|
||||
if (violations.Count > 0)
|
||||
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
|
||||
|
||||
// Real CSharpScript compile against the runtime-mirroring globals surface.
|
||||
var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
if (errors.Count > 0)
|
||||
return Result<bool>.Failure($"Script '{scriptName}' failed to compile: {errors[0]}");
|
||||
// Real CSharpScript compile against the runtime-mirroring globals surface.
|
||||
var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
if (errors.Count > 0)
|
||||
return (false, $"failed to compile: {string.Join("; ", errors)}");
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
return (true, (string?)null);
|
||||
});
|
||||
|
||||
return ok
|
||||
? Result<bool>.Success(true)
|
||||
: Result<bool>.Failure($"Script '{scriptName}' {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,18 @@ public class SemanticValidator
|
||||
}
|
||||
else
|
||||
{
|
||||
// #05-T24: if the target resolved ONLY via the composed
|
||||
// leaf-name fallback (not a same-scope sibling), the child
|
||||
// path is dynamic and cannot be statically verified — surface
|
||||
// an advisory warning instead of accepting it silently.
|
||||
if (!scriptNames.Contains(call.TargetName)
|
||||
&& composedLeafNames.Contains(call.TargetName))
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.CallTargetNotFound,
|
||||
$"Script '{script.CanonicalName}' call target '{call.TargetName}' matched by composed leaf name only — child path not verified.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
|
||||
ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors);
|
||||
ValidateCallReturnType(script.CanonicalName, call, scriptReturnMap, errors);
|
||||
|
||||
|
||||
@@ -95,6 +95,14 @@ public class ValidationService
|
||||
/// <c>$ref</c> are unaffected (behavior unchanged), and a <c>$ref</c> with no
|
||||
/// resolver is treated as dangling (the safe option).
|
||||
/// </param>
|
||||
/// <param name="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).
|
||||
/// <c>false</c> skips ONLY that stage; every other structural/semantic check still runs.
|
||||
/// 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>).
|
||||
/// </param>
|
||||
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
|
||||
public ValidationResult Validate(
|
||||
FlattenedConfiguration configuration,
|
||||
@@ -102,7 +110,8 @@ public class ValidationService
|
||||
IReadOnlySet<string>? alarmCapableConnectionNames = null,
|
||||
bool enforceConnectionBindings = false,
|
||||
IReadOnlySet<string>? siteConnectionNames = null,
|
||||
Func<string, string?>? resolveSchemaRef = null)
|
||||
Func<string, string?>? resolveSchemaRef = null,
|
||||
bool validateScriptCompilation = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
@@ -110,7 +119,13 @@ public class ValidationService
|
||||
{
|
||||
ValidateFlatteningSuccess(configuration),
|
||||
ValidateNamingCollisions(configuration),
|
||||
ValidateScriptCompilation(configuration),
|
||||
// The Roslyn compile is the single most expensive validation stage (a
|
||||
// non-collectible InteractiveAssemblyLoader assembly load per script).
|
||||
// Read-only staleness/comparison callers pass validateScriptCompilation:
|
||||
// false to skip ONLY this stage; every other structural/semantic check
|
||||
// still runs. The deploy gate keeps the default (true) — it is the
|
||||
// authoritative compile.
|
||||
validateScriptCompilation ? ValidateScriptCompilation(configuration) : ValidationResult.Success(),
|
||||
ValidateAlarmTriggerReferences(configuration),
|
||||
ValidateScriptTriggerReferences(configuration),
|
||||
ValidateExpressionTriggers(configuration),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
@@ -72,6 +73,24 @@ public sealed class BundleExporter : IBundleExporter
|
||||
// folder chain, transitive shared-scripts/external-systems if requested).
|
||||
var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 1b. Areas travel by NAME (the importer resolves-or-creates them under the
|
||||
// target site), so we only need an id→name lookup for the AreaIds the
|
||||
// exported instances actually reference — a single query over Areas keyed
|
||||
// to those FKs. Without this the serializer would emit AreaName: null and
|
||||
// the imported instance would lose its area assignment.
|
||||
var referencedAreaIds = resolved.Instances
|
||||
.Select(i => i.AreaId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var areaNameById = referencedAreaIds.Count == 0
|
||||
? new Dictionary<int, string>()
|
||||
: await _dbContext.Areas
|
||||
.Where(a => referencedAreaIds.Contains(a.Id))
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name).
|
||||
// The resolver's site/dataConnection/instance closure is wired
|
||||
// through via object-initializer — without this the aggregate would
|
||||
@@ -97,6 +116,9 @@ public sealed class BundleExporter : IBundleExporter
|
||||
// bundle ships an empty smsConfigs array even when SMS configs were
|
||||
// selected — mirrors the site/instance fix (review item I3).
|
||||
SmsConfigurations = resolved.SmsConfigs,
|
||||
// Area id→name lookup for the exported instances — feeds the serializer's
|
||||
// AreaName projection so each instance's area travels by name.
|
||||
AreaNameById = areaNameById,
|
||||
};
|
||||
var contentDto = _entitySerializer.ToBundleContent(aggregate);
|
||||
|
||||
|
||||
@@ -50,8 +50,19 @@ public sealed class ArtifactDiff
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming template from the bundle.</param>
|
||||
/// <param name="existing">The existing template in the database, or null if new.</param>
|
||||
/// <param name="folderNameById">Optional folder-id→name map so an existing template's
|
||||
/// <c>FolderId</c> resolves to the same name the bundle carries; without it, folder
|
||||
/// identity falls back to a <c><id:N></c> placeholder that never matches a bundle
|
||||
/// name (spurious Modified on every re-import — #05-T23).</param>
|
||||
/// <param name="templateNameById">Optional template-id→name map resolving the existing
|
||||
/// template's <c>ParentTemplateId</c> (base) and each composition's <c>ComposedTemplateId</c>
|
||||
/// to names, with the same placeholder fallback semantics.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing)
|
||||
public ImportPreviewItem CompareTemplate(
|
||||
TemplateDto incoming,
|
||||
Template? existing,
|
||||
IReadOnlyDictionary<int, string>? folderNameById = null,
|
||||
IReadOnlyDictionary<int, string>? templateNameById = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null)
|
||||
@@ -61,19 +72,26 @@ public sealed class ArtifactDiff
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
|
||||
AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName);
|
||||
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);
|
||||
AddIfDifferent(changes, "FolderName", FolderNameOf(existing, folderNameById), incoming.FolderName);
|
||||
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing, templateNameById), incoming.BaseTemplateName);
|
||||
|
||||
// Bundles reference the alarm on-trigger script by name; resolve the
|
||||
// persisted FK back to a name over this template's own scripts so the
|
||||
// shared equality can compare it. See TemplateChildEquality.
|
||||
var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts);
|
||||
|
||||
// Children: compare each child collection by name. We track which names
|
||||
// were added, which were removed, and which existed on both sides but
|
||||
// diverged in body. We use coarse value equality / line counts for
|
||||
// scripts so the diff JSON stays under a few KB per item.
|
||||
// scripts so the diff JSON stays under a few KB per item. Change
|
||||
// detection is single-sourced through TemplateChildEquality so the diff
|
||||
// can never disagree with what an Overwrite sync writes (#05-T6).
|
||||
DiffChildren(
|
||||
existing.Attributes,
|
||||
incoming.Attributes,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
AttributesEqual,
|
||||
TemplateChildEquality.AttributesEqual,
|
||||
"Attributes",
|
||||
changes);
|
||||
|
||||
@@ -82,7 +100,7 @@ public sealed class ArtifactDiff
|
||||
incoming.Alarms,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
AlarmsEqual,
|
||||
(e, i) => TemplateChildEquality.AlarmsEqual(e, i, scriptNameById),
|
||||
"Alarms",
|
||||
changes);
|
||||
|
||||
@@ -97,10 +115,19 @@ public sealed class ArtifactDiff
|
||||
incoming.Compositions,
|
||||
e => e.InstanceName,
|
||||
i => i.InstanceName,
|
||||
(e, i) => CompositionTargetNameOf(e) == i.ComposedTemplateName,
|
||||
(e, i) => CompositionTargetNameOf(e, templateNameById) == i.ComposedTemplateName,
|
||||
"Compositions",
|
||||
changes);
|
||||
|
||||
DiffChildren(
|
||||
existing.NativeAlarmSources,
|
||||
incoming.NativeAlarmSources,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
TemplateChildEquality.NativeAlarmSourcesEqual,
|
||||
"NativeAlarmSources",
|
||||
changes);
|
||||
|
||||
return BuildItem("Template", incoming.Name, changes);
|
||||
}
|
||||
|
||||
@@ -632,7 +659,7 @@ public sealed class ArtifactDiff
|
||||
foreach (var (name, ex) in existingByName)
|
||||
{
|
||||
if (!incomingByName.TryGetValue(name, out var inc)) continue;
|
||||
if (!ScriptsEqual(ex, inc))
|
||||
if (!TemplateChildEquality.ScriptsEqual(ex, inc))
|
||||
{
|
||||
// A script row can diverge in Code and/or its trigger/param metadata.
|
||||
// The structured line diff covers the Code body; when only the
|
||||
@@ -643,52 +670,34 @@ public sealed class ArtifactDiff
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(TemplateAttribute e, TemplateAttributeDto i) =>
|
||||
e.Value == i.Value
|
||||
&& e.DataType == i.DataType
|
||||
&& e.IsLocked == i.IsLocked
|
||||
&& e.Description == i.Description
|
||||
&& e.DataSourceReference == i.DataSourceReference;
|
||||
|
||||
private static bool AlarmsEqual(TemplateAlarm e, TemplateAlarmDto i) =>
|
||||
e.Description == i.Description
|
||||
&& e.PriorityLevel == i.PriorityLevel
|
||||
&& e.TriggerType == i.TriggerType
|
||||
&& e.TriggerConfiguration == i.TriggerConfiguration
|
||||
&& e.IsLocked == i.IsLocked;
|
||||
|
||||
private static bool ScriptsEqual(TemplateScript e, TemplateScriptDto i) =>
|
||||
string.Equals(e.Code, i.Code, StringComparison.Ordinal)
|
||||
&& e.TriggerType == i.TriggerType
|
||||
&& e.TriggerConfiguration == i.TriggerConfiguration
|
||||
&& e.ParameterDefinitions == i.ParameterDefinitions
|
||||
&& e.ReturnDefinition == i.ReturnDefinition
|
||||
&& e.IsLocked == i.IsLocked;
|
||||
|
||||
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
|
||||
e.HttpMethod == i.HttpMethod
|
||||
&& e.Path == i.Path
|
||||
&& e.ParameterDefinitions == i.ParameterDefinitions
|
||||
&& e.ReturnDefinition == i.ReturnDefinition;
|
||||
|
||||
private static string? FolderNameOf(Template t)
|
||||
private static string? FolderNameOf(Template t, IReadOnlyDictionary<int, string>? folderNameById)
|
||||
{
|
||||
// Templates carry only a FK to the folder; the EntitySerializer projects
|
||||
// it to a name. The diff doesn't have access to a folder lookup at
|
||||
// CompareTemplate scope, so fall back to "<id:N>" when only the id is
|
||||
// known. The PreviewAsync caller can pass a hydrated Template (via
|
||||
// GetTemplateWithChildrenAsync) and the folder name typically isn't on
|
||||
// it — this branch is a deliberate best-effort.
|
||||
return t.FolderId is null ? null : $"<id:{t.FolderId}>";
|
||||
// it to a name. When PreviewAsync supplies a folder-id→name map (#05-T23)
|
||||
// the id resolves to the real name so an unchanged folder assignment reads
|
||||
// Identical; without the map (or on a miss) we fall back to "<id:N>", which
|
||||
// keeps the older unit-test callers valid but never matches a bundle name.
|
||||
if (t.FolderId is null) return null;
|
||||
if (folderNameById != null && folderNameById.TryGetValue(t.FolderId.Value, out var name)) return name;
|
||||
return $"<id:{t.FolderId}>";
|
||||
}
|
||||
|
||||
private static string? BaseTemplateNameOf(Template t)
|
||||
private static string? BaseTemplateNameOf(Template t, IReadOnlyDictionary<int, string>? templateNameById)
|
||||
{
|
||||
return t.ParentTemplateId is null ? null : $"<id:{t.ParentTemplateId}>";
|
||||
if (t.ParentTemplateId is null) return null;
|
||||
if (templateNameById != null && templateNameById.TryGetValue(t.ParentTemplateId.Value, out var name)) return name;
|
||||
return $"<id:{t.ParentTemplateId}>";
|
||||
}
|
||||
|
||||
private static string CompositionTargetNameOf(TemplateComposition comp)
|
||||
private static string CompositionTargetNameOf(TemplateComposition comp, IReadOnlyDictionary<int, string>? templateNameById)
|
||||
{
|
||||
if (templateNameById != null && templateNameById.TryGetValue(comp.ComposedTemplateId, out var name)) return name;
|
||||
return $"<id:{comp.ComposedTemplateId}>";
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,26 @@ public sealed class BundleSessionStore : IBundleSessionStore
|
||||
public BundleSession Open(BundleSession session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
// #05-T24: bound the number of concurrently-open sessions — each pins a
|
||||
// decrypted bundle (up to MaxBundleSizeMb of plaintext) in memory until
|
||||
// it expires or is applied/cancelled. Re-opening an existing id (overwrite)
|
||||
// never grows the count, so only a genuinely-new session is gated. A soft
|
||||
// cap: the count/add pair is not atomic, so a race may briefly exceed it
|
||||
// by one — acceptable for a memory-pressure guard, not a security invariant.
|
||||
if (!_sessions.ContainsKey(session.SessionId))
|
||||
{
|
||||
var cap = _options.Value.MaxConcurrentImportSessions;
|
||||
if (_sessions.Count >= cap)
|
||||
{
|
||||
// A full store may be full only of stale entries — reclaim first.
|
||||
EvictExpired();
|
||||
if (_sessions.Count >= cap)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Too many concurrent import sessions ({cap}). Complete or cancel an in-progress import before starting another.");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Overwrite on collision is defensive: GUIDs are random so practical
|
||||
// collisions don't happen, but if a caller reuses an id we always
|
||||
// honor the latest Open call.
|
||||
|
||||
@@ -51,6 +51,16 @@ public sealed record LineDiffResult(
|
||||
/// </remarks>
|
||||
public static class LineDiffer
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard ceiling on the combined old+new line count fed to the Myers algorithm. Myers
|
||||
/// records a V frontier snapshot per edit distance <c>d</c> (up to <c>n + m</c> rounds),
|
||||
/// each snapshot sized <c>O(n + m)</c>, so a fully-divergent diff is <c>O((n + m)^2)</c>
|
||||
/// transient memory. To keep a bloated or crafted bundle from OOM-ing the active central
|
||||
/// node during an import preview, oversized inputs short-circuit to a summary-only result
|
||||
/// (the same shape the output cap emits) instead of running the trace.
|
||||
/// </summary>
|
||||
private const int MaxInputLines = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a minimal per-line diff between <paramref name="oldText"/> and
|
||||
/// <paramref name="newText"/> (either may be <see langword="null"/>, treated as empty).
|
||||
@@ -68,6 +78,13 @@ public static class LineDiffer
|
||||
string[] oldLines = SplitLines(oldText);
|
||||
string[] newLines = SplitLines(newText);
|
||||
|
||||
// Input-size guard: bound the ALGORITHM INPUT (distinct from the output cap below) so an
|
||||
// oversized diff degrades to a summary instead of allocating O((n + m)^2) Myers snapshots.
|
||||
if (oldLines.Length + newLines.Length > MaxInputLines)
|
||||
{
|
||||
return SummaryOnlyResult(oldLines.Length, newLines.Length);
|
||||
}
|
||||
|
||||
IReadOnlyList<LineDiffLine> full = BuildDiff(oldLines, newLines, out int added, out int removed);
|
||||
|
||||
bool truncated = full.Count > maxLines;
|
||||
@@ -91,6 +108,15 @@ public static class LineDiffer
|
||||
return new LineDiffResult(lines, truncated, added, removed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the truncated summary-only shape returned when the diff cannot be materialized in
|
||||
/// full — no hunks, <see cref="LineDiffResult.Truncated"/> set, and the add/remove totals
|
||||
/// approximated as a full replacement (every new line added, every old line removed). Used
|
||||
/// by the input-size guard so a diff too large to run cheaply still reports its magnitude.
|
||||
/// </summary>
|
||||
private static LineDiffResult SummaryOnlyResult(int oldCount, int newCount)
|
||||
=> new([], Truncated: true, AddedCount: newCount, RemovedCount: oldCount);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes line endings (<c>\r\n</c> and lone <c>\r</c> to <c>\n</c>) and splits on <c>\n</c>.
|
||||
/// A <see langword="null"/> or empty input yields an empty array (no lines), so the diff of two
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// #05-T6 — the single source of truth for "has this template child changed?": the
|
||||
/// field-for-field equality between a persisted template-child entity and its bundle
|
||||
/// DTO. Every writable field is compared.
|
||||
/// <para>
|
||||
/// BOTH the Preview diff (<see cref="ArtifactDiff.CompareTemplate"/>) and the
|
||||
/// Overwrite child-sync (<c>BundleImporter.SyncTemplate*Async</c>) MUST route their
|
||||
/// change detection through these methods so a Preview can never disagree with what
|
||||
/// an Overwrite actually writes. Historically the two drifted — the diff omitted
|
||||
/// <c>ElementDataType</c>, <c>ExecutionTimeoutSeconds</c>, the alarm on-trigger
|
||||
/// script, and <c>LockedInDerived</c> — reporting <c>Identical</c> for artifacts an
|
||||
/// Overwrite would in fact mutate. Adding a writable field to any template-child
|
||||
/// entity means adding it here, once.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TemplateChildEquality
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the persisted attribute equals its bundle DTO across every writable
|
||||
/// field. The single source of truth for "attribute changed"; both ArtifactDiff
|
||||
/// and <c>SyncTemplateAttributesAsync</c> call it.
|
||||
/// <para>
|
||||
/// Callers that normalise the DTO <c>Value</c> before comparing (the importer
|
||||
/// normalises List values) pass a DTO whose <c>Value</c> is already normalised.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool AttributesEqual(TemplateAttribute e, TemplateAttributeDto i) =>
|
||||
e.Value == i.Value
|
||||
&& e.DataType == i.DataType
|
||||
&& e.ElementDataType == i.ElementDataType
|
||||
&& e.IsLocked == i.IsLocked
|
||||
&& e.LockedInDerived == i.LockedInDerived
|
||||
&& e.Description == i.Description
|
||||
&& e.DataSourceReference == i.DataSourceReference;
|
||||
|
||||
/// <summary>
|
||||
/// True when the persisted alarm equals its bundle DTO across every writable
|
||||
/// field, including the on-trigger script binding. Bundles reference the
|
||||
/// on-trigger script by NAME (ids are environment-specific), so the caller
|
||||
/// supplies <paramref name="scriptNameById"/> — a resolver over the alarm's own
|
||||
/// template scripts — to map the persisted <c>OnTriggerScriptId</c> FK back to a
|
||||
/// name for comparison. The single source of truth for "alarm changed"; both
|
||||
/// ArtifactDiff and <c>SyncTemplateAlarmsAsync</c> call it.
|
||||
/// </summary>
|
||||
public static bool AlarmsEqual(TemplateAlarm e, TemplateAlarmDto i, Func<int?, string?> scriptNameById) =>
|
||||
e.Description == i.Description
|
||||
&& e.PriorityLevel == i.PriorityLevel
|
||||
&& e.TriggerType == i.TriggerType
|
||||
&& e.TriggerConfiguration == i.TriggerConfiguration
|
||||
&& e.IsLocked == i.IsLocked
|
||||
&& e.LockedInDerived == i.LockedInDerived
|
||||
&& scriptNameById(e.OnTriggerScriptId) == i.OnTriggerScriptName;
|
||||
|
||||
/// <summary>
|
||||
/// True when the persisted script equals its bundle DTO across every writable
|
||||
/// field. The single source of truth for "script changed"; both ArtifactDiff
|
||||
/// (via <c>DiffScriptChildren</c>) and <c>SyncTemplateScriptsAsync</c> call it.
|
||||
/// </summary>
|
||||
public static bool ScriptsEqual(TemplateScript e, TemplateScriptDto i) =>
|
||||
string.Equals(e.Code, i.Code, StringComparison.Ordinal)
|
||||
&& e.TriggerType == i.TriggerType
|
||||
&& e.TriggerConfiguration == i.TriggerConfiguration
|
||||
&& e.ParameterDefinitions == i.ParameterDefinitions
|
||||
&& e.ReturnDefinition == i.ReturnDefinition
|
||||
&& e.IsLocked == i.IsLocked
|
||||
&& e.LockedInDerived == i.LockedInDerived
|
||||
&& e.MinTimeBetweenRuns == i.MinTimeBetweenRuns
|
||||
&& e.ExecutionTimeoutSeconds == i.ExecutionTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// True when the persisted native alarm source equals its bundle DTO across every
|
||||
/// writable field. The single source of truth for "native alarm source changed";
|
||||
/// both ArtifactDiff and <c>SyncTemplateNativeAlarmSourcesAsync</c> call it.
|
||||
/// </summary>
|
||||
public static bool NativeAlarmSourcesEqual(TemplateNativeAlarmSource e, TemplateNativeAlarmSourceDto i) =>
|
||||
e.Description == i.Description
|
||||
&& e.ConnectionName == i.ConnectionName
|
||||
&& e.SourceReference == i.SourceReference
|
||||
&& e.ConditionFilter == i.ConditionFilter
|
||||
&& e.IsLocked == i.IsLocked
|
||||
&& e.IsInherited == i.IsInherited
|
||||
&& e.LockedInDerived == i.LockedInDerived;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a resolver mapping a template script id → its name over the given
|
||||
/// scripts, for the alarm on-trigger comparison. Returns null for a null id or an
|
||||
/// id absent from the set.
|
||||
/// </summary>
|
||||
public static Func<int?, string?> ScriptNameResolver(IEnumerable<TemplateScript> scripts)
|
||||
{
|
||||
var byId = new Dictionary<int, string>();
|
||||
foreach (var s in scripts)
|
||||
{
|
||||
byId[s.Id] = s.Name;
|
||||
}
|
||||
|
||||
return id => id is { } sid && byId.TryGetValue(sid, out var name) ? name : null;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ public sealed record EntityAggregate(
|
||||
// and never sees null; producers that resolve SMS config opt in via
|
||||
// object-initializer.
|
||||
public IReadOnlyList<SmsConfiguration> SmsConfigurations { get; init; } = Array.Empty<SmsConfiguration>();
|
||||
|
||||
// Area id → name lookup for the exported instances. Areas are not carried as a
|
||||
// first-class bundle collection (they reconcile by NAME under the target site),
|
||||
// so the aggregate only needs enough to resolve each instance's AreaId FK back to
|
||||
// a portable name at serialization time. Init-only with an empty default: producers
|
||||
// that export instances populate it; every other caller keeps compiling and the
|
||||
// serializer emits AreaName: null when an instance's AreaId isn't in the map.
|
||||
public IReadOnlyDictionary<int, string> AreaNameById { get; init; } =
|
||||
new Dictionary<int, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -117,7 +126,39 @@ public sealed record TemplateDto(
|
||||
IReadOnlyList<TemplateAttributeDto> Attributes,
|
||||
IReadOnlyList<TemplateAlarmDto> Alarms,
|
||||
IReadOnlyList<TemplateScriptDto> Scripts,
|
||||
IReadOnlyList<TemplateCompositionDto> Compositions);
|
||||
IReadOnlyList<TemplateCompositionDto> Compositions)
|
||||
{
|
||||
// Template-defined native alarm source bindings. Modeled as an init-only
|
||||
// property with an empty default (NOT a positional ctor param) because an
|
||||
// IReadOnlyList has no valid compile-time default for a positional
|
||||
// parameter, and for the same forward/source-compat reasons as the
|
||||
// site/instance arrays on BundleContentDto:
|
||||
// 1. Forward-compat: a bundle written before native alarm sources were
|
||||
// transported has no NativeAlarmSources field and deserializes this to
|
||||
// Array.Empty<> — consumers always see an empty list, never null.
|
||||
// 2. Source-compat: every existing positional `new TemplateDto(...)` caller
|
||||
// keeps compiling; producers opt in via the object-initializer.
|
||||
// IsInherited placeholder rows ARE carried (the collision detector depends
|
||||
// on them), mirroring how the flattener treats inherited native sources.
|
||||
public IReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSources { get; init; } =
|
||||
Array.Empty<TemplateNativeAlarmSourceDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A template-defined native alarm source binding. Carries every writable field
|
||||
/// of the <c>TemplateNativeAlarmSource</c> entity, including the inheritance/lock
|
||||
/// flags, so an export→import round-trip is field-faithful and instance-level
|
||||
/// overrides of the source no longer dangle in the target environment.
|
||||
/// </summary>
|
||||
public sealed record TemplateNativeAlarmSourceDto(
|
||||
string Name,
|
||||
string? Description,
|
||||
string ConnectionName,
|
||||
string SourceReference,
|
||||
string? ConditionFilter,
|
||||
bool IsLocked,
|
||||
bool IsInherited,
|
||||
bool LockedInDerived);
|
||||
|
||||
public sealed record TemplateAttributeDto(
|
||||
string Name,
|
||||
@@ -126,7 +167,13 @@ public sealed record TemplateAttributeDto(
|
||||
bool IsLocked,
|
||||
string? Description,
|
||||
string? DataSourceReference,
|
||||
DataType? ElementDataType = null);
|
||||
DataType? ElementDataType = null,
|
||||
// Whether this member is locked against override in derived templates.
|
||||
// Additive trailing field; false on bundles written before it existed
|
||||
// (dropped from JSON by WhenWritingNull? — no, bool is a value type, so it
|
||||
// is always emitted; older bundles simply lack the property and deserialize
|
||||
// to the default false).
|
||||
bool LockedInDerived = false);
|
||||
|
||||
public sealed record TemplateAlarmDto(
|
||||
string Name,
|
||||
@@ -135,7 +182,9 @@ public sealed record TemplateAlarmDto(
|
||||
AlarmTriggerType TriggerType,
|
||||
string? TriggerConfiguration,
|
||||
bool IsLocked,
|
||||
string? OnTriggerScriptName);
|
||||
string? OnTriggerScriptName,
|
||||
// Derived-lock flag; additive trailing field (see TemplateAttributeDto).
|
||||
bool LockedInDerived = false);
|
||||
|
||||
public sealed record TemplateScriptDto(
|
||||
string Name,
|
||||
@@ -148,7 +197,9 @@ public sealed record TemplateScriptDto(
|
||||
TimeSpan? MinTimeBetweenRuns,
|
||||
// Per-script execution timeout (seconds). Additive trailing field;
|
||||
// null on bundles written before this field existed.
|
||||
int? ExecutionTimeoutSeconds = null);
|
||||
int? ExecutionTimeoutSeconds = null,
|
||||
// Derived-lock flag; additive trailing field (see TemplateAttributeDto).
|
||||
bool LockedInDerived = false);
|
||||
|
||||
public sealed record TemplateCompositionDto(
|
||||
string InstanceName,
|
||||
|
||||
@@ -61,7 +61,8 @@ public sealed class EntitySerializer
|
||||
IsLocked: a.IsLocked,
|
||||
Description: a.Description,
|
||||
DataSourceReference: a.DataSourceReference,
|
||||
ElementDataType: a.ElementDataType)).ToList(),
|
||||
ElementDataType: a.ElementDataType,
|
||||
LockedInDerived: a.LockedInDerived)).ToList(),
|
||||
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
@@ -75,7 +76,8 @@ public sealed class EntitySerializer
|
||||
// resolve in this aggregate (e.g. corrupt/orphan row), the
|
||||
// name comes through as null and the importer leaves the
|
||||
// FK null on the imported alarm.
|
||||
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null)).ToList(),
|
||||
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
|
||||
LockedInDerived: a.LockedInDerived)).ToList(),
|
||||
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
|
||||
Name: s.Name,
|
||||
Code: s.Code,
|
||||
@@ -85,10 +87,24 @@ public sealed class EntitySerializer
|
||||
ReturnDefinition: s.ReturnDefinition,
|
||||
IsLocked: s.IsLocked,
|
||||
MinTimeBetweenRuns: s.MinTimeBetweenRuns,
|
||||
ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds)).ToList(),
|
||||
ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds,
|
||||
LockedInDerived: s.LockedInDerived)).ToList(),
|
||||
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
|
||||
InstanceName: c.InstanceName,
|
||||
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList());
|
||||
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())
|
||||
{
|
||||
// Native alarm sources carry ALL rows including IsInherited
|
||||
// placeholders — the collision detector depends on them.
|
||||
NativeAlarmSources = t.NativeAlarmSources.Select(n => new TemplateNativeAlarmSourceDto(
|
||||
Name: n.Name,
|
||||
Description: n.Description,
|
||||
ConnectionName: n.ConnectionName,
|
||||
SourceReference: n.SourceReference,
|
||||
ConditionFilter: n.ConditionFilter,
|
||||
IsLocked: n.IsLocked,
|
||||
IsInherited: n.IsInherited,
|
||||
LockedInDerived: n.LockedInDerived)).ToList(),
|
||||
};
|
||||
}).ToList(),
|
||||
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
|
||||
Name: s.Name,
|
||||
@@ -248,10 +264,13 @@ public sealed class EntitySerializer
|
||||
UniqueName: inst.UniqueName,
|
||||
TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
|
||||
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
|
||||
// TODO: Areas don't travel in the bundle yet — EntityAggregate
|
||||
// carries no Areas collection, so there's no id→name lookup. Emit
|
||||
// null until area transport is added; the importer leaves AreaId null.
|
||||
AreaName: null,
|
||||
// Area travels by NAME — the importer resolves-or-creates the
|
||||
// equivalent area under the target site (ResolveOrCreateAreaIdAsync).
|
||||
// Resolve the instance's AreaId FK against the aggregate's id→name
|
||||
// map; if the FK is null (no area) or unresolved (orphan/absent from
|
||||
// the map), the name comes through as null and the importer leaves
|
||||
// AreaId null on the imported instance.
|
||||
AreaName: inst.AreaId is { } aid && aggregate.AreaNameById.TryGetValue(aid, out var an) ? an : null,
|
||||
State: inst.State,
|
||||
AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto(
|
||||
AttributeName: o.AttributeName,
|
||||
@@ -315,6 +334,7 @@ public sealed class EntitySerializer
|
||||
Description = a.Description,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
ElementDataType = a.ElementDataType,
|
||||
LockedInDerived = a.LockedInDerived,
|
||||
});
|
||||
}
|
||||
foreach (var al in dto.Alarms)
|
||||
@@ -327,6 +347,7 @@ public sealed class EntitySerializer
|
||||
TriggerType = al.TriggerType,
|
||||
TriggerConfiguration = al.TriggerConfiguration,
|
||||
IsLocked = al.IsLocked,
|
||||
LockedInDerived = al.LockedInDerived,
|
||||
});
|
||||
}
|
||||
foreach (var s in dto.Scripts)
|
||||
@@ -341,6 +362,21 @@ public sealed class EntitySerializer
|
||||
IsLocked = s.IsLocked,
|
||||
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
||||
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
|
||||
LockedInDerived = s.LockedInDerived,
|
||||
});
|
||||
}
|
||||
foreach (var n in dto.NativeAlarmSources)
|
||||
{
|
||||
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
Description = n.Description,
|
||||
ConnectionName = n.ConnectionName,
|
||||
SourceReference = n.SourceReference,
|
||||
ConditionFilter = n.ConditionFilter,
|
||||
IsLocked = n.IsLocked,
|
||||
IsInherited = n.IsInherited,
|
||||
LockedInDerived = n.LockedInDerived,
|
||||
});
|
||||
}
|
||||
return t;
|
||||
|
||||
@@ -28,6 +28,13 @@ public sealed class TransportOptions
|
||||
/// generous headroom for unusually compressible bundles.
|
||||
/// </summary>
|
||||
public int MaxBundleEntryCompressionRatio { get; set; } = 50;
|
||||
/// <summary>
|
||||
/// #05-T24: maximum number of concurrently-open import sessions. Each open
|
||||
/// session pins a fully-decrypted bundle (up to <see cref="MaxBundleSizeMb"/>
|
||||
/// of plaintext) in memory until it expires or is applied/cancelled, so this
|
||||
/// bounds the N×~200 MB decrypted-content footprint the central node can hold.
|
||||
/// </summary>
|
||||
public int MaxConcurrentImportSessions { get; set; } = 8;
|
||||
/// <summary>Gets or sets the maximum number of failed passphrase unlock attempts before a session is locked.</summary>
|
||||
public int MaxUnlockAttemptsPerSession { get; set; } = 3;
|
||||
/// <summary>Gets or sets the maximum number of unlock attempts allowed per IP address per hour.</summary>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ public class TopologyPageTests : BunitContext
|
||||
}
|
||||
}
|
||||
};
|
||||
_pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>())
|
||||
_pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));
|
||||
|
||||
@@ -410,7 +410,7 @@ public class TopologyPageTests : BunitContext
|
||||
JsonSerializer.Serialize(deployedConfig))));
|
||||
|
||||
var currentConfig = new FlattenedConfiguration { InstanceUniqueName = "Pump-001" };
|
||||
_pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>())
|
||||
_pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
using NSubstitute;
|
||||
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.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Tests for deployed vs template-derived state comparison.
|
||||
///
|
||||
/// PLAN-05 Task 16: adds behavioral coverage proving the read-only
|
||||
/// staleness/comparison path can flatten (and thus hash) an instance whose
|
||||
/// script does NOT compile — because <c>validateScripts: false</c> skips the
|
||||
/// expensive Roslyn <see cref="ValidationService.ValidateScriptCompilation"/>
|
||||
/// step while keeping structural/semantic validation. The default (deploy) path
|
||||
/// still compiles.
|
||||
/// </summary>
|
||||
public class DeploymentComparisonTests
|
||||
{
|
||||
@@ -34,4 +50,81 @@ public class DeploymentComparisonTests
|
||||
|
||||
Assert.Equal(deployedAt, result.DeployedAt);
|
||||
}
|
||||
|
||||
// ── PLAN-05 Task 16: read-only paths skip Roslyn script compilation ──
|
||||
|
||||
private const int InstanceId = 1;
|
||||
private const int TemplateId = 10;
|
||||
private const int SiteId = 100;
|
||||
|
||||
// A deliberately non-compiling script body. The deploy gate
|
||||
// (validateScripts: true) flags this as a ScriptCompilation error; the
|
||||
// read-only staleness/comparison path (validateScripts: false) must skip
|
||||
// the compile entirely and still produce a flattened config + hash.
|
||||
private const string NonCompilingScript = "int x = ;";
|
||||
|
||||
private static FlatteningPipeline BuildPipeline(
|
||||
ITemplateEngineRepository templateRepo,
|
||||
ISiteRepository siteRepo,
|
||||
ISharedSchemaRepository sharedSchemaRepo)
|
||||
{
|
||||
sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
|
||||
return new FlatteningPipeline(
|
||||
templateRepo,
|
||||
siteRepo,
|
||||
new FlatteningService(),
|
||||
new ValidationService(),
|
||||
new RevisionHashService(),
|
||||
sharedSchemaRepo);
|
||||
}
|
||||
|
||||
private static (ITemplateEngineRepository, ISiteRepository, ISharedSchemaRepository) ArrangeNonCompilingScript()
|
||||
{
|
||||
var templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
var sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
|
||||
|
||||
var template = new Template("Tank") { Id = TemplateId };
|
||||
template.Scripts.Add(new TemplateScript("BadScript", NonCompilingScript));
|
||||
|
||||
var instance = new Instance("Tank-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId };
|
||||
|
||||
templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any<CancellationToken>()).Returns(instance);
|
||||
templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>()).Returns(template);
|
||||
templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any<CancellationToken>()).Returns([]);
|
||||
templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns([]);
|
||||
siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns([]);
|
||||
|
||||
return (templateRepo, siteRepo, sharedSchemaRepo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlattenAndValidate_ValidateScriptsFalse_NonCompilingScript_SucceedsWithHashAndNoCompilationError()
|
||||
{
|
||||
var (templateRepo, siteRepo, sharedSchemaRepo) = ArrangeNonCompilingScript();
|
||||
var sut = BuildPipeline(templateRepo, siteRepo, sharedSchemaRepo);
|
||||
|
||||
var result = await sut.FlattenAndValidateAsync(InstanceId, validateScripts: false);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(string.IsNullOrEmpty(result.Value.RevisionHash));
|
||||
// The Roslyn compile step was skipped, so no ScriptCompilation error is raised
|
||||
// even though the script body does not compile.
|
||||
Assert.DoesNotContain(result.Value.Validation.Errors,
|
||||
e => e.Category == ValidationCategory.ScriptCompilation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlattenAndValidate_DefaultValidateScripts_NonCompilingScript_ReportsCompilationError()
|
||||
{
|
||||
var (templateRepo, siteRepo, sharedSchemaRepo) = ArrangeNonCompilingScript();
|
||||
var sut = BuildPipeline(templateRepo, siteRepo, sharedSchemaRepo);
|
||||
|
||||
// Default (deploy) path — compilation is the authoritative gate.
|
||||
var result = await sut.FlattenAndValidateAsync(InstanceId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Contains(result.Value.Validation.Errors,
|
||||
e => e.Category == ValidationCategory.ScriptCompilation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,6 +454,25 @@ public class DeploymentServiceTests : TestKit
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteInstanceAsync_NotDeployed_RemovesRecordWithoutSiteRoundTrip()
|
||||
{
|
||||
// #05-T18: a NotDeployed instance has no live actor at any site (Transport-
|
||||
// imported instances always land NotDeployed, often against an uncommissioned
|
||||
// or unreachable site). Delete must succeed as a central-side record cleanup
|
||||
// WITHOUT contacting the site. The default _service's CommunicationService has
|
||||
// no actor wired, so any attempted site round-trip throws uncaught -- a Success
|
||||
// result therefore proves the site was never contacted (Times.Never, enforced
|
||||
// structurally).
|
||||
var instance = new Instance("ImportedInst") { Id = 70, SiteId = 1, State = InstanceState.NotDeployed };
|
||||
_repo.GetInstanceByIdAsync(70, Arg.Any<CancellationToken>()).Returns(instance);
|
||||
|
||||
var result = await _service.DeleteInstanceAsync(70, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _repo.Received(1).DeleteInstanceAsync(70, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── DeploymentManager-004: site-success but central-delete-failure must not escape uncaught ──
|
||||
|
||||
[Fact]
|
||||
@@ -520,7 +539,7 @@ public class DeploymentServiceTests : TestKit
|
||||
_repo.GetDeployedSnapshotByInstanceIdAsync(1).Returns(snapshot);
|
||||
|
||||
var currentConfig = deployedConfig with { };
|
||||
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>())
|
||||
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, sameHash, ValidationResult.Success())));
|
||||
|
||||
@@ -557,7 +576,7 @@ public class DeploymentServiceTests : TestKit
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = "9", DataType = "Int32" }]
|
||||
};
|
||||
var currentHash = new RevisionHashService().ComputeHash(currentConfig);
|
||||
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>())
|
||||
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, currentHash, ValidationResult.Success())));
|
||||
|
||||
@@ -567,6 +586,72 @@ public class DeploymentServiceTests : TestKit
|
||||
Assert.True(result.Value.IsStale);
|
||||
}
|
||||
|
||||
// ── PLAN-05 Task 16: read-only paths skip Roslyn script compilation ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeploymentComparisonAsync_RequestsValidateScriptsFalse()
|
||||
{
|
||||
// The comparison page only needs the revision hash for staleness — the
|
||||
// expensive Roslyn compile must be skipped (validateScripts: false).
|
||||
var deployedConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "TestInst",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = "5", DataType = "Int32" }]
|
||||
};
|
||||
var hash = new RevisionHashService().ComputeHash(deployedConfig);
|
||||
var snapshot = new DeployedConfigSnapshot(
|
||||
"dep1", hash, System.Text.Json.JsonSerializer.Serialize(deployedConfig))
|
||||
{
|
||||
InstanceId = 1,
|
||||
DeployedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_repo.GetDeployedSnapshotByInstanceIdAsync(1).Returns(snapshot);
|
||||
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(deployedConfig with { }, hash, ValidationResult.Success())));
|
||||
|
||||
var result = await _service.GetDeploymentComparisonAsync(1);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _pipeline.Received(1).FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), false);
|
||||
await _pipeline.DidNotReceive().FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeployInstanceAsync_RequestsValidateScriptsTrue()
|
||||
{
|
||||
// The deploy gate stays the authoritative compile — validateScripts defaults to true.
|
||||
var instance = new Instance("TestInst") { Id = 1, SiteId = 1, State = InstanceState.NotDeployed };
|
||||
_repo.GetInstanceByIdAsync(1).Returns(instance);
|
||||
|
||||
var validationResult = new ValidationResult
|
||||
{
|
||||
Errors = [ValidationEntry.Error(ValidationCategory.ScriptCompilation, "Compile error")]
|
||||
};
|
||||
_pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(new FlattenedConfiguration(), "hash1", validationResult)));
|
||||
|
||||
await _service.DeployInstanceAsync(1, "admin");
|
||||
|
||||
await _pipeline.Received().FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleInstanceProbe_RequestsValidateScriptsFalse()
|
||||
{
|
||||
// The Transport stale-instance probe also only needs the hash — same skip.
|
||||
_pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(new FlattenedConfiguration(), "sha256:probe", ValidationResult.Success())));
|
||||
var probe = new StaleInstanceProbe(_pipeline);
|
||||
|
||||
var hash = await probe.GetCurrentRevisionHashAsync(5);
|
||||
|
||||
Assert.Equal("sha256:probe", hash);
|
||||
await _pipeline.Received(1).FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false);
|
||||
}
|
||||
|
||||
// ── DeploymentManager-007: comparison must produce a structured diff ──
|
||||
|
||||
[Fact]
|
||||
@@ -594,7 +679,7 @@ public class DeploymentServiceTests : TestKit
|
||||
InstanceUniqueName = "DiffInst",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "NewAttr", DataType = "Int" }]
|
||||
};
|
||||
_pipeline.FlattenAndValidateAsync(40, Arg.Any<CancellationToken>())
|
||||
_pipeline.FlattenAndValidateAsync(40, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, "sha256:new", ValidationResult.Success())));
|
||||
|
||||
@@ -668,7 +753,7 @@ public class DeploymentServiceTests : TestKit
|
||||
]
|
||||
};
|
||||
var nativeHash = hasher.ComputeHash(nativeConfig);
|
||||
_pipeline.FlattenAndValidateAsync(50, Arg.Any<CancellationToken>())
|
||||
_pipeline.FlattenAndValidateAsync(50, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(nativeConfig, nativeHash, ValidationResult.Success())));
|
||||
|
||||
@@ -736,7 +821,7 @@ public class DeploymentServiceTests : TestKit
|
||||
]
|
||||
};
|
||||
var currentHash = hasher.ComputeHash(currentConfig);
|
||||
_pipeline.FlattenAndValidateAsync(51, Arg.Any<CancellationToken>())
|
||||
_pipeline.FlattenAndValidateAsync(51, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, currentHash, ValidationResult.Success())));
|
||||
|
||||
@@ -812,6 +897,36 @@ public class DeploymentServiceTests : TestKit
|
||||
Arg.Any<object>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeployInstanceAsync_DeployAuditWriteThrows_KeepsCommittedSuccess()
|
||||
{
|
||||
// #05-T17: the post-success "Deploy" audit write is best-effort. If it
|
||||
// throws, the already-committed Success record must NOT be flipped to
|
||||
// Failed by the outer catch — "audit-write failure NEVER aborts the
|
||||
// user-facing action".
|
||||
var instance = new Instance("AuditFaultInst") { Id = 52, SiteId = 1, State = InstanceState.NotDeployed };
|
||||
_repo.GetInstanceByIdAsync(52, Arg.Any<CancellationToken>()).Returns(instance);
|
||||
SetupValidPipeline(52, "AuditFaultInst", "sha256:target");
|
||||
_repo.GetCurrentDeploymentStatusAsync(52, Arg.Any<CancellationToken>())
|
||||
.Returns((DeploymentRecord?)null);
|
||||
|
||||
// The Deploy audit LogAsync faults.
|
||||
_audit.When(a => a.LogAsync(
|
||||
Arg.Any<string>(), "Deploy", Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>()))
|
||||
.Do(_ => throw new InvalidOperationException("audit pipeline down"));
|
||||
|
||||
var counters = new ReconcileProbeCounters();
|
||||
var commActor = Sys.ActorOf(Props.Create(() =>
|
||||
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
|
||||
var service = CreateServiceWithCommActor(commActor);
|
||||
|
||||
var result = await service.DeployInstanceAsync(52, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(DeploymentStatus.Success, result.Value.Status);
|
||||
}
|
||||
|
||||
// ── Telemetry follow-on: scadabridge.deployments.applied on deploy success ──
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #05-T14: unit tests for the in-process script-artifact change bus — delivery,
|
||||
/// unsubscribe, and the swallow-and-log guard that keeps a faulting subscriber from
|
||||
/// faulting the producer.
|
||||
/// </summary>
|
||||
public sealed class InProcessScriptArtifactChangeBusTests
|
||||
{
|
||||
private static InProcessScriptArtifactChangeBus NewBus() =>
|
||||
new(NullLogger<InProcessScriptArtifactChangeBus>.Instance);
|
||||
|
||||
private static ScriptArtifactsChanged Sample(string kind = ScriptArtifactKinds.ApiMethod) =>
|
||||
new(kind, new[] { "M1" }, "Management", DateTimeOffset.UnixEpoch);
|
||||
|
||||
[Fact]
|
||||
public void Publish_delivers_to_all_current_subscribers()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var a = new List<ScriptArtifactsChanged>();
|
||||
var b = new List<ScriptArtifactsChanged>();
|
||||
bus.Subscribe(a.Add);
|
||||
bus.Subscribe(b.Add);
|
||||
|
||||
var n = Sample();
|
||||
bus.Publish(n);
|
||||
|
||||
Assert.Same(n, Assert.Single(a));
|
||||
Assert.Same(n, Assert.Single(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_subscription_stops_delivery()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var received = new List<ScriptArtifactsChanged>();
|
||||
var token = bus.Subscribe(received.Add);
|
||||
|
||||
bus.Publish(Sample());
|
||||
token.Dispose();
|
||||
bus.Publish(Sample());
|
||||
|
||||
Assert.Single(received); // only the pre-dispose publish landed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_is_idempotent()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var received = new List<ScriptArtifactsChanged>();
|
||||
var token = bus.Subscribe(received.Add);
|
||||
|
||||
token.Dispose();
|
||||
token.Dispose(); // must not throw nor corrupt the handler list.
|
||||
|
||||
bus.Publish(Sample());
|
||||
Assert.Empty(received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_faulting_subscriber_does_not_prevent_delivery_to_others_nor_throw()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var good = new List<ScriptArtifactsChanged>();
|
||||
bus.Subscribe(_ => throw new InvalidOperationException("bad consumer"));
|
||||
bus.Subscribe(good.Add);
|
||||
|
||||
// Publish must not propagate the subscriber fault...
|
||||
var ex = Record.Exception(() => bus.Publish(Sample()));
|
||||
Assert.Null(ex);
|
||||
// ...and the well-behaved subscriber still receives it.
|
||||
Assert.Single(good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_with_no_subscribers_is_a_noop()
|
||||
{
|
||||
var bus = NewBus();
|
||||
Assert.Null(Record.Exception(() => bus.Publish(Sample())));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
@@ -232,6 +234,39 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Contains("Pump1", response.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError()
|
||||
{
|
||||
// #05-T13: the ManagementActor must reject an override targeting a source
|
||||
// that is locked at the template level, before persisting anything.
|
||||
var pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
var flattened = new FlattenedConfiguration
|
||||
{
|
||||
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
|
||||
{
|
||||
new() { CanonicalName = "Pressure", IsLocked = true, Source = "Template" }
|
||||
}
|
||||
};
|
||||
pipeline.FlattenAndValidateAsync(10, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
|
||||
_services.AddScoped(_ => pipeline);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverrideCommand(10, "Pressure", "OtherOpc", null, null),
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
// The override must NOT be persisted.
|
||||
_templateRepo.DidNotReceive().AddInstanceNativeAlarmSourceOverrideAsync(
|
||||
Arg.Any<InstanceNativeAlarmSourceOverride>());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. Error handling test -- service throws exception
|
||||
// ========================================================================
|
||||
@@ -2479,7 +2514,21 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
[Fact]
|
||||
public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
|
||||
{
|
||||
// No prior override → Add path.
|
||||
// No prior override → Add path. The source resolves and is UNLOCKED, so the
|
||||
// lock guard (#05-T13) lets the override through.
|
||||
var pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
var flattened = new FlattenedConfiguration
|
||||
{
|
||||
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
|
||||
{
|
||||
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
|
||||
}
|
||||
};
|
||||
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
|
||||
_services.AddScoped(_ => pipeline);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
|
||||
|
||||
@@ -321,4 +321,29 @@ public class ScriptTrustValidatorTests
|
||||
var code = "var s = \"System.IO.File.ReadAllText\";";
|
||||
Assert.Empty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
// ---- PLAN-05 Task 21: hardened deny-list additions -----------------------
|
||||
|
||||
// Environment (whole type), GC, and the concrete ADO.NET provider namespaces
|
||||
// (Microsoft.Data / System.Data.SqlClient / .Odbc / .OleDb) are now forbidden.
|
||||
[Theory]
|
||||
[InlineData("Environment.Exit(0);")]
|
||||
[InlineData("Environment.FailFast(\"x\");")]
|
||||
[InlineData("var s = Environment.GetEnvironmentVariable(\"SCADABRIDGE_API_KEY\");")]
|
||||
[InlineData("var c = new Microsoft.Data.SqlClient.SqlConnection(\"Server=attacker\");")]
|
||||
public void FindViolations_Flags(string code) => Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
|
||||
// Syntactic gateway closure (matters in TPA-degraded fallback mode): these
|
||||
// reflection-gateway members are rejected regardless of receiver.
|
||||
[Theory]
|
||||
[InlineData("typeof(string).Assembly.GetTypes()")]
|
||||
[InlineData("asm.EntryPoint")]
|
||||
public void SyntacticPass_Flags(string code) => Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
|
||||
// Must NOT regress legitimate scripts: Task.Delay stays allowed and
|
||||
// delegate.Invoke() is deliberately NOT on the reflection-gateway list.
|
||||
[Theory]
|
||||
[InlineData("await Task.Delay(1);")]
|
||||
[InlineData("Func<int,int> f = x => x; f.Invoke(3);")]
|
||||
public void FindViolations_Allows(string code) => Assert.Empty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
@@ -188,4 +188,64 @@ public class CollisionDetectorTests
|
||||
Assert.NotEmpty(collisions);
|
||||
Assert.Contains(collisions, c => c.Contains("dup.Shared"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Task 10: repeated composition blindness. A template composed into TWO
|
||||
// different slots was added to the global visited-set on the first slot, so
|
||||
// the SECOND slot's subtree was never collected — the detector went blind to
|
||||
// any collision reachable only through the second slot. The visited-set must
|
||||
// be a recursion PATH set (removed on the way back out), not a global set, so
|
||||
// siblings/other slots can re-descend the same template.
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DetectCollisions_TemplateComposedTwiceWithinSubtree_SeesCollisionUnderSecondSlot()
|
||||
{
|
||||
// Z carries the leaf member "Val".
|
||||
var z = new Template("Z") { Id = 4 };
|
||||
z.Attributes.Add(new TemplateAttribute("Val") { Id = 40, TemplateId = 4, DataType = DataType.Float });
|
||||
|
||||
// Y composes Z as "z".
|
||||
var y = new Template("Y") { Id = 3 };
|
||||
y.Compositions.Add(new TemplateComposition("z") { Id = 1, TemplateId = 3, ComposedTemplateId = 4 });
|
||||
|
||||
// W composes Y into TWO slots (y1, y2). Because both slots are visited within
|
||||
// the SAME CollectComposedMembers walk, they share one visited set — the
|
||||
// second slot's subtree was previously never collected.
|
||||
var w = new Template("W") { Id = 2 };
|
||||
w.Compositions.Add(new TemplateComposition("y1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
w.Compositions.Add(new TemplateComposition("y2") { Id = 3, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
|
||||
// X composes W as "w" AND declares a direct attribute whose path collides with
|
||||
// a member reachable only via the SECOND slot: "w.y2.z.Val".
|
||||
var x = new Template("X") { Id = 1 };
|
||||
x.Attributes.Add(new TemplateAttribute("w.y2.z.Val") { Id = 10, TemplateId = 1, DataType = DataType.Float });
|
||||
x.Compositions.Add(new TemplateComposition("w") { Id = 4, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var all = new List<Template> { x, w, y, z };
|
||||
var collisions = CollisionDetector.DetectCollisions(x, all);
|
||||
|
||||
// "w.y2.z.Val" exists both as X's direct attribute and via the w→y2→z→Val subtree.
|
||||
Assert.NotEmpty(collisions);
|
||||
Assert.Contains(collisions, c => c.Contains("w.y2.z.Val"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCollisions_CyclicComposition_DoesNotLoopForever()
|
||||
{
|
||||
// Genuine cycle guard: A composes B, B composes A. The recursion-path set
|
||||
// must still short-circuit a template already on the ACTIVE path so the
|
||||
// walk terminates instead of recursing infinitely.
|
||||
var a = new Template("A") { Id = 1 };
|
||||
a.Compositions.Add(new TemplateComposition("b") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var b = new Template("B") { Id = 2 };
|
||||
b.Compositions.Add(new TemplateComposition("a") { Id = 2, TemplateId = 2, ComposedTemplateId = 1 });
|
||||
|
||||
var all = new List<Template> { a, b };
|
||||
|
||||
// Must return (terminate) rather than stack-overflow / hang.
|
||||
var collisions = CollisionDetector.DetectCollisions(a, all);
|
||||
Assert.Empty(collisions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,114 @@ public class DiffServiceTests
|
||||
Assert.Equal(DiffChangeType.Changed, diff.AlarmChanges[0].ChangeType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_NativeAlarmSourceReferenceChange_DetectedAsChanged()
|
||||
{
|
||||
// PLAN-05 Task 12: native alarm source edits must surface in the diff so
|
||||
// the Deployments diff view is not blind to native-alarm binding changes.
|
||||
var oldConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources =
|
||||
[
|
||||
new ResolvedNativeAlarmSource { CanonicalName = "PumpAlarms", ConnectionName = "Opc1", SourceReference = "ns=2;s=PumpA" }
|
||||
]
|
||||
};
|
||||
var newConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources =
|
||||
[
|
||||
new ResolvedNativeAlarmSource { CanonicalName = "PumpAlarms", ConnectionName = "Opc1", SourceReference = "ns=2;s=PumpB" }
|
||||
]
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Single(diff.NativeAlarmSourceChanges);
|
||||
Assert.Equal(DiffChangeType.Changed, diff.NativeAlarmSourceChanges[0].ChangeType);
|
||||
Assert.Equal("PumpAlarms", diff.NativeAlarmSourceChanges[0].CanonicalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_NativeAlarmSourceAdded_DetectedAsAdded()
|
||||
{
|
||||
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
|
||||
var newConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources =
|
||||
[
|
||||
new ResolvedNativeAlarmSource { CanonicalName = "PumpAlarms", ConnectionName = "Opc1", SourceReference = "ns=2;s=PumpA" }
|
||||
]
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Single(diff.NativeAlarmSourceChanges);
|
||||
Assert.Equal(DiffChangeType.Added, diff.NativeAlarmSourceChanges[0].ChangeType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_NativeAlarmSourceRemoved_DetectedAsRemoved()
|
||||
{
|
||||
var oldConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources =
|
||||
[
|
||||
new ResolvedNativeAlarmSource { CanonicalName = "PumpAlarms", ConnectionName = "Opc1", SourceReference = "ns=2;s=PumpA" }
|
||||
]
|
||||
};
|
||||
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Single(diff.NativeAlarmSourceChanges);
|
||||
Assert.Equal(DiffChangeType.Removed, diff.NativeAlarmSourceChanges[0].ChangeType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_NativeAlarmSourceLockChange_DetectedAsChanged()
|
||||
{
|
||||
var oldConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources =
|
||||
[
|
||||
new ResolvedNativeAlarmSource { CanonicalName = "PumpAlarms", ConnectionName = "Opc1", SourceReference = "ns=2;s=PumpA", IsLocked = false }
|
||||
]
|
||||
};
|
||||
var newConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources =
|
||||
[
|
||||
new ResolvedNativeAlarmSource { CanonicalName = "PumpAlarms", ConnectionName = "Opc1", SourceReference = "ns=2;s=PumpA", IsLocked = true }
|
||||
]
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.Single(diff.NativeAlarmSourceChanges);
|
||||
Assert.Equal(DiffChangeType.Changed, diff.NativeAlarmSourceChanges[0].ChangeType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_NativeAlarmSourceUnchanged_NoDiff()
|
||||
{
|
||||
var source = new ResolvedNativeAlarmSource { CanonicalName = "PumpAlarms", ConnectionName = "Opc1", SourceReference = "ns=2;s=PumpA", ConditionFilter = "HighHigh" };
|
||||
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", NativeAlarmSources = [source] };
|
||||
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", NativeAlarmSources = [source with { }] };
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.Empty(diff.NativeAlarmSourceChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionsEqual_IdenticalConfigs_ReturnsTrue()
|
||||
{
|
||||
|
||||
+125
@@ -789,6 +789,32 @@ public class FlatteningServiceTests
|
||||
Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride()
|
||||
{
|
||||
// #05-T13: a native alarm source locked at the template level must not be
|
||||
// repointed by an instance override — the resolved source keeps the template
|
||||
// values and its Source stays "Template" (mirrors attribute/alarm lock rules).
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure")
|
||||
{ ConnectionName = "Opc", SourceReference = "ns=2;s=DEFAULT", IsLocked = true });
|
||||
var instance = CreateInstance();
|
||||
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Pressure")
|
||||
{ ConnectionNameOverride = "OtherOpc", SourceReferenceOverride = "ns=2;s=Tank07" });
|
||||
|
||||
var result = _sut.Flatten(instance, [template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var src = result.Value.NativeAlarmSources[0];
|
||||
Assert.Equal("Opc", src.ConnectionName); // override ignored
|
||||
Assert.Equal("ns=2;s=DEFAULT", src.SourceReference); // override ignored
|
||||
Assert.Equal("Template", src.Source);
|
||||
Assert.True(src.IsLocked);
|
||||
}
|
||||
|
||||
// ── MV-4: ElementDataType carried through flatten/override ────────────
|
||||
|
||||
[Fact]
|
||||
@@ -851,4 +877,103 @@ public class FlatteningServiceTests
|
||||
Assert.Equal("List", attr.DataType); // type unchanged
|
||||
Assert.Equal("String", attr.ElementDataType); // element type preserved
|
||||
}
|
||||
|
||||
// ── Repeated composition: same template under multiple slots ──────────
|
||||
|
||||
[Fact]
|
||||
public void Flatten_SameTemplateComposedTwice_ResolvesNestedMembersUnderBothSlots()
|
||||
{
|
||||
// Root(1) composes X(2) as "x"; X composes Y(3) as "y1" AND "y2";
|
||||
// Y composes Z(4) as "z"; Z carries one member of each kind.
|
||||
// Because y1 and y2 share one recursion subtree, a globally-keyed cycle
|
||||
// guard would descend into Z under y1 but skip it under y2, dropping the
|
||||
// whole x.y2.z.* subtree. The path-keyed guard resolves both.
|
||||
var z = CreateTemplate(4, "Z");
|
||||
z.Attributes.Add(new TemplateAttribute("Val") { DataType = DataType.Double, Value = "1" });
|
||||
z.Alarms.Add(new TemplateAlarm("Alm")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Val\",\"high\":100}",
|
||||
PriorityLevel = 1
|
||||
});
|
||||
z.Scripts.Add(new TemplateScript("Run", "// run") { Id = 40 });
|
||||
z.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
|
||||
{ ConnectionName = "Opc", SourceReference = "ns=2;s=Z" });
|
||||
|
||||
var y = CreateTemplate(3, "Y");
|
||||
var x = CreateTemplate(2, "X");
|
||||
var root = CreateTemplate(1, "Root");
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition> { new("x") { ComposedTemplateId = 2 } },
|
||||
[2] = new List<TemplateComposition>
|
||||
{
|
||||
new("y1") { ComposedTemplateId = 3 },
|
||||
new("y2") { ComposedTemplateId = 3 }
|
||||
},
|
||||
[3] = new List<TemplateComposition> { new("z") { ComposedTemplateId = 4 } }
|
||||
};
|
||||
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[2] = [x],
|
||||
[3] = [y],
|
||||
[4] = [z]
|
||||
};
|
||||
|
||||
var result = _sut.Flatten(
|
||||
CreateInstance(),
|
||||
[root],
|
||||
compositions,
|
||||
composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "x.y1.z.Val");
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "x.y2.z.Val");
|
||||
|
||||
Assert.Contains(result.Value.Alarms, a => a.CanonicalName == "x.y1.z.Alm");
|
||||
Assert.Contains(result.Value.Alarms, a => a.CanonicalName == "x.y2.z.Alm");
|
||||
|
||||
Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "x.y1.z.Run");
|
||||
Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "x.y2.z.Run");
|
||||
|
||||
Assert.Contains(result.Value.NativeAlarmSources, s => s.CanonicalName == "x.y1.z.Src");
|
||||
Assert.Contains(result.Value.NativeAlarmSources, s => s.CanonicalName == "x.y2.z.Src");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_CompositionCycle_StillTerminates()
|
||||
{
|
||||
// X(1) composes Y(2) as "y"; Y composes X(1) as "x" — a composition
|
||||
// cycle. The path-keyed guard must still break the cycle (a template
|
||||
// already on the CURRENT path) so flattening terminates.
|
||||
var x = CreateTemplate(1, "X");
|
||||
x.Attributes.Add(new TemplateAttribute("Root") { DataType = DataType.String, Value = "r" });
|
||||
var y = CreateTemplate(2, "Y");
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition> { new("y") { ComposedTemplateId = 2 } },
|
||||
[2] = new List<TemplateComposition> { new("x") { ComposedTemplateId = 1 } }
|
||||
};
|
||||
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[1] = [x],
|
||||
[2] = [y]
|
||||
};
|
||||
|
||||
var result = _sut.Flatten(
|
||||
CreateInstance(),
|
||||
[x],
|
||||
compositions,
|
||||
composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "Root");
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -367,6 +367,103 @@ public class RevisionHashServiceTests
|
||||
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_NativeAlarmSourceChange_ChangesHash()
|
||||
{
|
||||
// TemplateEngine-011: native alarm source bindings are part of the
|
||||
// deployment package, so an edit to a source's binding (here the
|
||||
// SourceReference) MUST change the revision hash and flag the instance
|
||||
// stale. Before this migration native sources were absent from the hash
|
||||
// and this edit went undetected.
|
||||
var baseSource = new ResolvedNativeAlarmSource
|
||||
{
|
||||
CanonicalName = "Boiler.Alarms",
|
||||
ConnectionName = "plc1",
|
||||
SourceReference = "ns=2;s=Boiler",
|
||||
ConditionFilter = null,
|
||||
IsLocked = false
|
||||
};
|
||||
var editedSource = baseSource with { SourceReference = "ns=2;s=Boiler2" };
|
||||
|
||||
var configBefore = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
NativeAlarmSources = [baseSource]
|
||||
};
|
||||
var configAfter = configBefore with { NativeAlarmSources = [editedSource] };
|
||||
|
||||
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_NativeAlarmSourceIsLockedChange_ChangesHash()
|
||||
{
|
||||
// TemplateEngine-011: IsLocked participates in the hash too.
|
||||
var baseSource = new ResolvedNativeAlarmSource
|
||||
{
|
||||
CanonicalName = "Boiler.Alarms",
|
||||
ConnectionName = "plc1",
|
||||
SourceReference = "ns=2;s=Boiler",
|
||||
IsLocked = false
|
||||
};
|
||||
var editedSource = baseSource with { IsLocked = true };
|
||||
|
||||
var configBefore = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
NativeAlarmSources = [baseSource]
|
||||
};
|
||||
var configAfter = configBefore with { NativeAlarmSources = [editedSource] };
|
||||
|
||||
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_NativeAlarmSourceOrder_DoesNotAffectHash()
|
||||
{
|
||||
// TemplateEngine-011: sources are sorted by CanonicalName before hashing,
|
||||
// mirroring attributes/alarms/scripts — authoring order is irrelevant.
|
||||
var a = new ResolvedNativeAlarmSource
|
||||
{
|
||||
CanonicalName = "A", ConnectionName = "plc1", SourceReference = "ns=2;s=A"
|
||||
};
|
||||
var b = new ResolvedNativeAlarmSource
|
||||
{
|
||||
CanonicalName = "B", ConnectionName = "plc1", SourceReference = "ns=2;s=B"
|
||||
};
|
||||
|
||||
var config1 = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
NativeAlarmSources = [a, b]
|
||||
};
|
||||
var config2 = config1 with { NativeAlarmSources = [b, a] };
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline()
|
||||
{
|
||||
// TemplateEngine-011 migration surgicality: adding the (null-when-empty)
|
||||
// NativeAlarmSources property to the hashable projection must NOT change
|
||||
// the hash of any native-source-free configuration. The literal below is
|
||||
// the pre-migration hash of CreateConfig("Instance1", "25.0"); pinning it
|
||||
// proves that only instances that HAVE native sources flip stale — there
|
||||
// is no global staleness storm.
|
||||
var config = CreateConfig("Instance1", "25.0");
|
||||
|
||||
Assert.Equal(
|
||||
"sha256:e937bcfa34a146ca6bdb9bda27bdceb204e440a4a6110ca646f7750f37b166cb",
|
||||
_sut.ComputeHash(config));
|
||||
}
|
||||
|
||||
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
|
||||
{
|
||||
return new FlattenedConfiguration
|
||||
|
||||
@@ -40,6 +40,19 @@ public class ScriptCompilerTests
|
||||
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_MultipleForbiddenApis_ReportsAll()
|
||||
{
|
||||
// #05-T24: the failure message must enumerate EVERY forbidden API, not
|
||||
// just the first, so an operator fixing one isn't surprised by the next.
|
||||
var result = _sut.TryCompile(
|
||||
"System.IO.File.ReadAllText(\"x\"); System.Diagnostics.Process.Start(\"y\");",
|
||||
"Test");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("System.IO", result.Error, StringComparison.Ordinal);
|
||||
Assert.Contains("Process", result.Error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Namespace alias — the old substring scan never saw "System.IO." spelled out.
|
||||
[InlineData("using IO = System.IO; IO.File.ReadAllText(\"x\");")]
|
||||
@@ -135,4 +148,35 @@ public class ScriptCompilerTests
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// --- Compile-verdict cache: unchanged code compiles once per process ---
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_SameCodeTwice_SecondCallIsCacheHit()
|
||||
{
|
||||
ScriptCompileVerdictCache.Clear();
|
||||
var c = new ScriptCompiler();
|
||||
c.TryCompile("return 1;", "A");
|
||||
var hitsBefore = ScriptCompileVerdictCache.Hits;
|
||||
var r = c.TryCompile("return 1;", "B"); // same code, different name
|
||||
Assert.True(r.IsSuccess);
|
||||
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_CachedFailure_FormatsNameForEachCaller()
|
||||
{
|
||||
// The stored verdict is name-free; the script name is formatted in at read
|
||||
// time, so two callers of the same bad code get their own name in the message.
|
||||
ScriptCompileVerdictCache.Clear();
|
||||
var c = new ScriptCompiler();
|
||||
var first = c.TryCompile("var x = NoSuchThing.Foo();", "Alpha");
|
||||
var second = c.TryCompile("var x = NoSuchThing.Foo();", "Beta"); // cache hit
|
||||
|
||||
Assert.True(first.IsFailure);
|
||||
Assert.True(second.IsFailure);
|
||||
Assert.Contains("'Alpha'", first.Error);
|
||||
Assert.Contains("'Beta'", second.Error);
|
||||
Assert.Contains("compile", second.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,50 @@ public class SemanticValidatorTests
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallScriptCompositionDelegatedByLeafName_EmitsAdvisoryWarning()
|
||||
{
|
||||
// #05-T24: a call target accepted ONLY by the composed leaf-name fallback
|
||||
// (dynamic child path, not statically verifiable) must surface an advisory
|
||||
// warning rather than being silently accepted.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "LeftMESReceiver.MoveIn", Code = "var x = 1;" },
|
||||
new ResolvedScript { CanonicalName = "MesMoveIn", Code = "await Children[side + \"MESReceiver\"].CallScript(\"MoveIn\", Parameters);" }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.Contains(result.Warnings, w =>
|
||||
w.Category == ValidationCategory.CallTargetNotFound
|
||||
&& w.Message.Contains("leaf name only", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallScriptSameScopeSibling_EmitsNoLeafWarning()
|
||||
{
|
||||
// A same-scope sibling resolves directly (not the leaf fallback), so no
|
||||
// advisory warning is emitted.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;" },
|
||||
new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\");" }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
|
||||
Assert.DoesNotContain(result.Warnings, w =>
|
||||
w.Message.Contains("leaf name only", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallScriptUnknownLeafName_StillReturnsError()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
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.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Area-by-name transport (Task 7): a source instance
|
||||
/// assigned to an <see cref="Area"/> under its site must carry that area's NAME
|
||||
/// through the exported bundle so the importer's <c>ResolveOrCreateAreaIdAsync</c>
|
||||
/// machinery can resolve-or-create the equivalent area under the target site. This
|
||||
/// closes the half-shipped Area transport — the importer's GetOrCreate path was
|
||||
/// already built + tested, but the exporter hardcoded <c>AreaName: null</c>.
|
||||
/// <para>
|
||||
/// Reuses the in-memory host pattern from <c>SiteInstanceImportTests</c>: real
|
||||
/// repositories, real EF in-memory provider, real Transport pipeline.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AreaTransportTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public AreaTransportTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"AreaTransportTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task Instance_Area_travels_by_name_and_resolves_under_target_site()
|
||||
{
|
||||
// ---- Seed a site + instance assigned to an Area "Line-1" ----
|
||||
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");
|
||||
ctx.Sites.Add(site);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var area = new Area("Line-1") { SiteId = site.Id };
|
||||
ctx.Areas.Add(area);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("Pump-01")
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
SiteId = site.Id,
|
||||
AreaId = area.Id,
|
||||
State = InstanceState.Enabled,
|
||||
};
|
||||
ctx.Instances.Add(instance);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- Export the whole site closure ----
|
||||
Stream bundleStream;
|
||||
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: Array.Empty<int>(),
|
||||
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);
|
||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await bundleStream.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
// ---- Wipe the source rows so the import creates a fresh target ----
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Instances.RemoveRange(ctx.Instances);
|
||||
ctx.Areas.RemoveRange(ctx.Areas);
|
||||
ctx.Sites.RemoveRange(ctx.Sites);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
// The template must exist for the instance to resolve its TemplateId.
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- Import with a CreateNew site mapping ----
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- Assert: the imported instance's AreaId resolves to an Area named
|
||||
// "Line-1" under the target site (NOT null). ----
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var site = await ctx.Sites.SingleAsync(s => s.SiteIdentifier == "plant-1");
|
||||
var inst = await ctx.Instances.SingleAsync(i => i.UniqueName == "Pump-01");
|
||||
|
||||
Assert.NotNull(inst.AreaId);
|
||||
var area = await ctx.Areas.SingleAsync(a => a.Id == inst.AreaId);
|
||||
Assert.Equal("Line-1", area.Name);
|
||||
Assert.Equal(site.Id, area.SiteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
-13
@@ -5,12 +5,15 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
@@ -42,6 +45,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
public sealed class BundleImporterApplyTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly RecordingScriptArtifactChangeBus _artifactBus = new();
|
||||
|
||||
public BundleImporterApplyTests()
|
||||
{
|
||||
@@ -89,6 +93,10 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
// #05-T14: a recording bus so the post-commit ScriptArtifactsChanged
|
||||
// publish is observable. BundleImporter takes IScriptArtifactChangeBus?
|
||||
// optionally, so this registration flows in via DI activation.
|
||||
services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus);
|
||||
// #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
|
||||
// and the flatten/hash primitives in TemplateEngine — register both so
|
||||
// BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
|
||||
@@ -121,6 +129,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
|
||||
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync();
|
||||
var apiMethodIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
SharedScriptIds: sharedScriptIds,
|
||||
@@ -128,7 +137,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
ApiMethodIds: apiMethodIds,
|
||||
IncludeDependencies: false);
|
||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
@@ -151,6 +160,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -312,15 +322,15 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
|
||||
{
|
||||
// Arrange: seed a template whose script body calls MissingHelper().
|
||||
// Arrange: seed an ApiMethod whose script body calls MissingHelper().
|
||||
// No SharedScript by that name exists in source or (after wipe) in the
|
||||
// target, so semantic validation must reject the apply.
|
||||
// target. ApiMethod name-resolution findings are HARD errors (unlike
|
||||
// template-script findings, which are advisory as of #05-T19), so this
|
||||
// must reject the apply and roll everything back.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("BrokenPump") { Description = "broken" };
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
@@ -332,16 +342,16 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
||||
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert — target still wiped (template not committed), AND a
|
||||
// Assert — target still wiped (ApiMethod not committed), AND a
|
||||
// BundleImportFailed row exists.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.Equal(0, await ctx.Templates.CountAsync());
|
||||
Assert.Equal(0, await ctx.ApiMethods.CountAsync());
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
|
||||
}
|
||||
|
||||
@@ -480,9 +490,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("BrokenPump") { Description = "broken" };
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
@@ -494,7 +502,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
||||
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
@@ -1324,4 +1332,127 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
|
||||
Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds);
|
||||
}
|
||||
|
||||
// ============ #05-T14: post-commit ScriptArtifactsChanged publish ============
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit()
|
||||
{
|
||||
// Seed a Template, a SharedScript and an ApiMethod; Overwrite all three.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "tpl" };
|
||||
t.Scripts.Add(new TemplateScript("init", "return 1;"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
||||
ctx.ApiMethods.Add(new ApiMethod("DoThing", "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: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
||||
SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(),
|
||||
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;
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
|
||||
// Act — Overwrite all three (they still exist in the target).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Overwrite, null),
|
||||
new("SharedScript", "HelperFn", ResolutionAction.Overwrite, null),
|
||||
new("ApiMethod", "DoThing", ResolutionAction.Overwrite, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — exactly one notification per kind, post-commit, with the names.
|
||||
var byKind = _artifactBus.Received.ToDictionary(n => n.ArtifactKind);
|
||||
Assert.Equal(3, _artifactBus.Received.Count);
|
||||
Assert.Equal(new[] { "DoThing" }, byKind[ScriptArtifactKinds.ApiMethod].Names);
|
||||
Assert.Equal(new[] { "HelperFn" }, byKind[ScriptArtifactKinds.SharedScript].Names);
|
||||
Assert.Equal(new[] { "Pump" }, byKind[ScriptArtifactKinds.Template].Names);
|
||||
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
||||
{
|
||||
// An ApiMethod whose script calls an unresolved helper fails semantic
|
||||
// validation BEFORE commit (ApiMethod findings are hard errors), so
|
||||
// nothing is published.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ApiMethods.Add(new ApiMethod("Bad", "var x = UnknownHelper();"));
|
||||
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;
|
||||
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 Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ApiMethod", "Bad", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
Assert.Empty(_artifactBus.Received);
|
||||
}
|
||||
|
||||
/// <summary>Records every published notification for assertion.</summary>
|
||||
private sealed class RecordingScriptArtifactChangeBus : IScriptArtifactChangeBus
|
||||
{
|
||||
public List<ScriptArtifactsChanged> Received { get; } = new();
|
||||
|
||||
public void Publish(ScriptArtifactsChanged notification) => Received.Add(notification);
|
||||
|
||||
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler) =>
|
||||
throw new NotSupportedException("The recording bus does not support subscriptions.");
|
||||
}
|
||||
}
|
||||
|
||||
+135
-22
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
@@ -330,7 +331,52 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing()
|
||||
public async Task PreviewAsync_unchanged_structured_template_classifies_Identical()
|
||||
{
|
||||
// #05-T23 end-to-end: a template with a base (ParentTemplateId) and a
|
||||
// composition, re-imported against an UNCHANGED target, must classify
|
||||
// Identical. Before T23, PreviewAsync compared "<id:N>" placeholders for
|
||||
// BaseTemplateName / composition target against the bundle's real names,
|
||||
// so it always read Modified — defeating the wizard's auto-skip contract.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("BasePump") { Description = "base" });
|
||||
ctx.Templates.Add(new Template("FeatureX") { Description = "feature" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var basePump = await ctx.Templates.SingleAsync(t => t.Name == "BasePump");
|
||||
var feature = await ctx.Templates.SingleAsync(t => t.Name == "FeatureX");
|
||||
|
||||
var pump = new Template("Pump") { Description = "derived", ParentTemplateId = basePump.Id };
|
||||
ctx.Templates.Add(pump);
|
||||
await ctx.SaveChangesAsync();
|
||||
pump.Compositions.Add(new TemplateComposition("mod1")
|
||||
{
|
||||
TemplateId = pump.Id,
|
||||
ComposedTemplateId = feature.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Export everything; do NOT wipe — the target is unchanged.
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
var pumpItem = Assert.Single(preview.Items,
|
||||
i => i.EntityType == "Template" && i.Name == "Pump");
|
||||
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_emits_Warning_when_template_script_dependency_missing()
|
||||
{
|
||||
// Arrange: seed a template whose script body calls MissingHelper(), and
|
||||
// an unrelated HelperFn() shared script that *is* defined but isn't the
|
||||
@@ -338,10 +384,9 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
||||
// selection that only pulls the template — the bundle won't carry
|
||||
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
|
||||
//
|
||||
// To get MissingHelper into the bundle script body without the export
|
||||
// resolver pulling it in (it can't — it doesn't exist), we just seed
|
||||
// the template with a script that mentions it; the resolver scan only
|
||||
// matters for entity discovery, the body text is preserved verbatim.
|
||||
// Under #05-T19 a TEMPLATE-script name-resolution finding is ADVISORY
|
||||
// (the design-time deploy gate re-validates it), so the preview surfaces
|
||||
// it as ConflictKind.Warning — NOT a Blocker.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
@@ -357,18 +402,6 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
// Wipe the SharedScripts table so MissingHelper has no chance of being
|
||||
// resolved in the target either. (HelperFn is intentionally seeded so
|
||||
// we can verify the blocker check is specific — it should NOT flag
|
||||
// HelperFn since it's in the target.)
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
// Keep HelperFn + ErpSystem so they're in the target's resolved set.
|
||||
// Just confirm via assertion that MissingHelper is the blocker name.
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
@@ -378,16 +411,96 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Assert: there's at least one Blocker, and the MissingHelper one is in there.
|
||||
Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker);
|
||||
// Assert: MissingHelper surfaces as a Warning (advisory), not a Blocker.
|
||||
Assert.Contains(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Warning
|
||||
&& i.Name == "MissingHelper"
|
||||
&& i.BlockerReason is not null
|
||||
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(preview.Items, i =>
|
||||
i.Name == "MissingHelper" && i.Kind == ConflictKind.Blocker);
|
||||
// Conversely, HelperFn must NOT be flagged at all — it's seeded in the target.
|
||||
Assert.DoesNotContain(preview.Items, i => i.Name == "HelperFn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_emits_Blocker_when_ApiMethod_dependency_missing()
|
||||
{
|
||||
// #05-T19 severity split: an ApiMethod script referencing a missing
|
||||
// dependency has no downstream deploy gate, so the preview must surface
|
||||
// it as a hard Blocker (parity with the apply-time SemanticValidationException).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ApiMethods.Add(new ApiMethod("Ingest", "var x = MissingHelper();"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var apiIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
||||
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: apiIds,
|
||||
IncludeDependencies: false);
|
||||
var stream = await exporter.ExportAsync(selection, user: "alice",
|
||||
sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None);
|
||||
bytes = await StreamToBytes(stream);
|
||||
}
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
Assert.Contains(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Blocker
|
||||
&& i.Name == "MissingHelper"
|
||||
&& i.BlockerReason is not null
|
||||
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
|
||||
// Conversely, HelperFn must NOT be a blocker — it's seeded in the target.
|
||||
Assert.DoesNotContain(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Blocker && i.Name == "HelperFn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_emits_Blocker_when_template_script_has_forbidden_api()
|
||||
{
|
||||
// #05-T20 — the script trust gate runs in preview too, so a forbidden-API
|
||||
// script surfaces as a Blocker row (parity with the apply-time
|
||||
// SemanticValidationException). Trust violations block regardless of kind.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Malicious") { Description = "forbidden" };
|
||||
t.Scripts.Add(new TemplateScript("init", "System.Diagnostics.Process.Start(\"cmd\");"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
Assert.Contains(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Blocker
|
||||
&& i.BlockerReason is not null
|
||||
&& i.BlockerReason.Contains("Process", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+11
-10
@@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
@@ -108,17 +109,15 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws()
|
||||
{
|
||||
// Arrange: seed a template whose script body references MissingHelper()
|
||||
// so semantic validation will reject the apply (same broken-bundle shape
|
||||
// as BundleImporterApplyTests.ApplyAsync_rolls_back_all_changes_…). Then
|
||||
// arm the interceptor to throw on rollback so the catch path has to
|
||||
// survive a rollback failure.
|
||||
// Arrange: seed an ApiMethod whose script body references MissingHelper()
|
||||
// so semantic validation will reject the apply (ApiMethod findings are
|
||||
// hard errors; same broken-bundle shape as BundleImporterApplyTests.
|
||||
// ApplyAsync_rolls_back_all_changes_…). Then arm the interceptor to throw
|
||||
// on rollback so the catch path has to survive a rollback failure.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("BrokenPump") { Description = "broken" };
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -136,7 +135,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
thrown = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
||||
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
Assert.NotNull(thrown);
|
||||
@@ -177,6 +176,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var apiMethodIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
@@ -184,7 +184,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
ApiMethodIds: apiMethodIds,
|
||||
IncludeDependencies: false);
|
||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
@@ -207,6 +207,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
+120
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
@@ -80,6 +81,7 @@ public sealed class InheritanceImportTests : IDisposable
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
|
||||
await ctx.SaveChangesAsync();
|
||||
@@ -201,4 +203,122 @@ public sealed class InheritanceImportTests : IDisposable
|
||||
Assert.Equal("WaterPump", row.EntityName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Seed base "root" + derived "leaf" (leaf ParentTemplateId -> root).</summary>
|
||||
private async Task SeedParentChildAsync(string rootName, string leafName)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var root = new Template(rootName) { Description = "root" };
|
||||
ctx.Templates.Add(root);
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.Templates.Add(new Template(leafName) { Description = "leaf", ParentTemplateId = root.Id });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Overwrite_ThatClosesInheritanceCycle_Throws_AndRollsBack()
|
||||
{
|
||||
// Source: base "Alpha" (root) + derived "Beta" (parent Alpha), so the
|
||||
// exported Beta DTO carries BaseTemplateName = "Alpha".
|
||||
await SeedParentChildAsync("Alpha", "Beta");
|
||||
var bundle = await ExportAllTemplatesAsync();
|
||||
await WipeTemplatesAsync();
|
||||
|
||||
// Target: the MIRROR — "Beta" (root) + "Alpha" (parent Beta). Overwriting
|
||||
// Beta so it inherits from Alpha closes Alpha -> Beta -> Alpha.
|
||||
await SeedParentChildAsync("Beta", "Alpha");
|
||||
|
||||
var sessionId = await LoadAsync(bundle);
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var resolutions = new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Alpha", ResolutionAction.Skip, null),
|
||||
new("Template", "Beta", ResolutionAction.Overwrite, null),
|
||||
};
|
||||
var ex = await Assert.ThrowsAsync<SemanticValidationException>(
|
||||
() => importer.ApplyAsync(sessionId, resolutions, user: "bob"));
|
||||
Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Rolled back: target Beta's parent edge is untouched (still a root).
|
||||
await using (var assert = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assert.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var beta = await ctx.Templates.SingleAsync(t => t.Name == "Beta");
|
||||
Assert.Null(beta.ParentTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Overwrite_ThatClosesCompositionCycle_Throws_AndRollsBack()
|
||||
{
|
||||
// Source: leaf "Gamma" + "Delta" that composes Gamma, so the exported
|
||||
// Delta DTO carries a composition edge to "Gamma".
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Gamma") { Description = "leaf" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var gamma = await ctx.Templates.SingleAsync(t => t.Name == "Gamma");
|
||||
var delta = new Template("Delta") { Description = "composer" };
|
||||
ctx.Templates.Add(delta);
|
||||
await ctx.SaveChangesAsync();
|
||||
delta.Compositions.Add(new TemplateComposition("g1")
|
||||
{
|
||||
TemplateId = delta.Id,
|
||||
ComposedTemplateId = gamma.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundle = await ExportAllTemplatesAsync();
|
||||
await WipeTemplatesAsync();
|
||||
|
||||
// Target: the MIRROR — "Gamma" composes "Delta". Overwriting Delta so it
|
||||
// composes Gamma closes the composition loop Gamma -> Delta -> Gamma.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Delta") { Description = "target-leaf" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var delta = await ctx.Templates.SingleAsync(t => t.Name == "Delta");
|
||||
var gamma = new Template("Gamma") { Description = "target-composer" };
|
||||
ctx.Templates.Add(gamma);
|
||||
await ctx.SaveChangesAsync();
|
||||
gamma.Compositions.Add(new TemplateComposition("d1")
|
||||
{
|
||||
TemplateId = gamma.Id,
|
||||
ComposedTemplateId = delta.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await LoadAsync(bundle);
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var resolutions = new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Gamma", ResolutionAction.Skip, null),
|
||||
new("Template", "Delta", ResolutionAction.Overwrite, null),
|
||||
};
|
||||
var ex = await Assert.ThrowsAsync<SemanticValidationException>(
|
||||
() => importer.ApplyAsync(sessionId, resolutions, user: "bob"));
|
||||
Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Rolled back: target Delta still has NO composition rows (Overwrite did
|
||||
// not persist the loop-closing edge).
|
||||
await using (var assert = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assert.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var delta = await ctx.Templates
|
||||
.Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Name == "Delta");
|
||||
Assert.Empty(delta.Compositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
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.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// #05-T5: a template's <see cref="TemplateNativeAlarmSource"/> collection must
|
||||
/// survive an export→import round-trip (Add + Overwrite sync), diff in Preview,
|
||||
/// and — crucially — carry across environments so an instance-level override of a
|
||||
/// native alarm source no longer dangles in the target. Before this fix the
|
||||
/// importer amputated the template's native sources entirely, so an imported
|
||||
/// instance override referenced a source that did not exist. Same in-memory host
|
||||
/// pattern as <see cref="TemplateScriptFidelityTests"/> / <see cref="BundleImporterApplyTests"/>.
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmSourceImportTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public NativeAlarmSourceImportTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"NativeAlarmSourceImportTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
||||
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
services.AddTemplateEngine();
|
||||
services.AddDeploymentManager();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
private async Task<Guid> ExportAllTemplatesAndLoadAsync()
|
||||
{
|
||||
Stream bundleStream;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
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: false);
|
||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await bundleStream.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
await using var loadScope = _provider.CreateAsyncScope();
|
||||
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
return session.SessionId;
|
||||
}
|
||||
|
||||
private static TemplateNativeAlarmSource MakeSource(
|
||||
string name = "Boiler",
|
||||
string connection = "OpcUaPrimary",
|
||||
string sourceRef = "ns=3;s=Boiler.Alarm",
|
||||
string? filter = "severity>500",
|
||||
bool locked = false,
|
||||
bool inherited = false,
|
||||
bool lockedInDerived = false) => new(name)
|
||||
{
|
||||
Description = "boiler native alarms",
|
||||
ConnectionName = connection,
|
||||
SourceReference = sourceRef,
|
||||
ConditionFilter = filter,
|
||||
IsLocked = locked,
|
||||
IsInherited = inherited,
|
||||
LockedInDerived = lockedInDerived,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Add_CarriesTemplateNativeAlarmSources()
|
||||
{
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "tpl" };
|
||||
// Set every flag non-default so BuildTemplate's fidelity is exercised.
|
||||
t.NativeAlarmSources.Add(MakeSource(locked: true, inherited: true, lockedInDerived: true));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var src = await ctx.Templates
|
||||
.Where(t => t.Name == "Pump")
|
||||
.SelectMany(t => t.NativeAlarmSources)
|
||||
.SingleAsync();
|
||||
Assert.Equal("Boiler", src.Name);
|
||||
Assert.Equal("boiler native alarms", src.Description);
|
||||
Assert.Equal("OpcUaPrimary", src.ConnectionName);
|
||||
Assert.Equal("ns=3;s=Boiler.Alarm", src.SourceReference);
|
||||
Assert.Equal("severity>500", src.ConditionFilter);
|
||||
Assert.True(src.IsLocked);
|
||||
Assert.True(src.IsInherited);
|
||||
Assert.True(src.LockedInDerived);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Overwrite_NoChange_EmitsNoNativeSourceAudit()
|
||||
{
|
||||
// An idempotent Overwrite (bundle == target) must produce ZERO
|
||||
// TemplateNativeAlarmSource* audit rows — the "no audit noise" invariant.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "same" };
|
||||
t.NativeAlarmSources.Add(MakeSource("Boiler", locked: true, lockedInDerived: true));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.False(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||
a.Action.StartsWith("TemplateNativeAlarmSource")));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Overwrite_SyncsNativeAlarmSources()
|
||||
{
|
||||
// Source has Keep (changed ConditionFilter later) + Add ("Chiller"); target
|
||||
// will hold Keep (different filter) + Drop ("Legacy"). Import Overwrite must
|
||||
// update Keep, add Chiller, delete Legacy — with one audit row each.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "same" };
|
||||
t.NativeAlarmSources.Add(MakeSource("Keep", filter: "severity>500"));
|
||||
t.NativeAlarmSources.Add(MakeSource("Chiller", connection: "OpcUaChiller", sourceRef: "ns=4;s=Chiller"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||
|
||||
// Mutate target: change Keep's filter, drop Chiller, add Legacy.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump");
|
||||
t.NativeAlarmSources.Single(s => s.Name == "Keep").ConditionFilter = "severity>900";
|
||||
t.NativeAlarmSources.Remove(t.NativeAlarmSources.Single(s => s.Name == "Chiller"));
|
||||
t.NativeAlarmSources.Add(MakeSource("Legacy", connection: "OpcUaLegacy", sourceRef: "ns=9;s=Legacy"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sources = await ctx.Templates
|
||||
.Where(t => t.Name == "Pump")
|
||||
.SelectMany(t => t.NativeAlarmSources)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, sources.Count);
|
||||
Assert.Equal("severity>500", sources.Single(s => s.Name == "Keep").ConditionFilter); // updated back
|
||||
Assert.Contains(sources, s => s.Name == "Chiller"); // added
|
||||
Assert.DoesNotContain(sources, s => s.Name == "Legacy"); // deleted
|
||||
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||
a.Action == "TemplateNativeAlarmSourceUpdated" && a.EntityName == "Pump.Keep"));
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||
a.Action == "TemplateNativeAlarmSourceAdded" && a.EntityName == "Pump.Chiller"));
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||
a.Action == "TemplateNativeAlarmSourceDeleted" && a.EntityName == "Pump.Legacy"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_DiffsNativeAlarmSources()
|
||||
{
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "tpl" };
|
||||
t.NativeAlarmSources.Add(MakeSource("Boiler", filter: "severity>500"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||
|
||||
// Mutate the target source so the diff has something to report.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump");
|
||||
t.NativeAlarmSources.Single().ConditionFilter = "severity>900";
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var preview = await importer.PreviewAsync(sessionId);
|
||||
var pump = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
||||
Assert.Equal(ConflictKind.Modified, pump.Kind);
|
||||
Assert.NotNull(pump.FieldDiffJson);
|
||||
Assert.Contains("NativeAlarmSources", pump.FieldDiffJson!, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_InstanceOverride_NoLongerDangles()
|
||||
{
|
||||
// Target holds a template + site + instance whose override repoints the
|
||||
// "Boiler" native source. We export the template WITH the source, delete
|
||||
// the target's copy of the source (so the override dangles), then import
|
||||
// Overwrite — which re-adds the source — and flatten: the resolved config
|
||||
// must now carry "Boiler" with the instance override applied.
|
||||
int instanceId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "tpl" };
|
||||
t.NativeAlarmSources.Add(MakeSource("Boiler", connection: "OpcUaPrimary", locked: false));
|
||||
ctx.Templates.Add(t);
|
||||
|
||||
var site = new Site("Plant 1", "plant-1")
|
||||
{
|
||||
NodeAAddress = "akka://s@10.0.0.1:2552",
|
||||
NodeBAddress = "akka://s@10.0.0.2:2552",
|
||||
};
|
||||
ctx.Sites.Add(site);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
ctx.DataConnections.Add(new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
||||
{
|
||||
PrimaryConfiguration = "{}",
|
||||
});
|
||||
ctx.DataConnections.Add(new DataConnection("OpcUaBackup", "OpcUa", site.Id)
|
||||
{
|
||||
PrimaryConfiguration = "{}",
|
||||
});
|
||||
|
||||
var instance = new Instance("Pump-01")
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
SiteId = site.Id,
|
||||
State = InstanceState.NotDeployed,
|
||||
};
|
||||
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Boiler")
|
||||
{
|
||||
ConnectionNameOverride = "OpcUaBackup",
|
||||
});
|
||||
ctx.Instances.Add(instance);
|
||||
await ctx.SaveChangesAsync();
|
||||
instanceId = instance.Id;
|
||||
}
|
||||
|
||||
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||
|
||||
// Delete the target template's native source → the override now dangles.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump");
|
||||
t.NativeAlarmSources.Clear();
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Pre-check: with the source gone, flatten resolves no "Boiler".
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
|
||||
var pre = await pipeline.FlattenAndValidateAsync(instanceId, CancellationToken.None, validateScripts: false);
|
||||
Assert.True(pre.IsSuccess);
|
||||
Assert.DoesNotContain(pre.Value!.Configuration.NativeAlarmSources, s => s.CanonicalName == "Boiler");
|
||||
}
|
||||
|
||||
// Import Overwrite re-adds the template source.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Now the override resolves against the re-imported source.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
|
||||
var post = await pipeline.FlattenAndValidateAsync(instanceId, CancellationToken.None, validateScripts: false);
|
||||
Assert.True(post.IsSuccess);
|
||||
var boiler = Assert.Single(post.Value!.Configuration.NativeAlarmSources, s => s.CanonicalName == "Boiler");
|
||||
Assert.Equal("OpcUaBackup", boiler.ConnectionName); // instance override applied
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// #05-T3: a template script's <see cref="TemplateScript.MinTimeBetweenRuns"/> and
|
||||
/// <see cref="TemplateScript.ExecutionTimeoutSeconds"/> must survive an export→import
|
||||
/// round-trip. The exporter serializes both fields (EntitySerializer) and the DTO
|
||||
/// carries them; before this fix the importer's <c>BuildTemplate</c> (Add) and
|
||||
/// <c>SyncTemplateScriptsAsync</c> (Overwrite) rebuild dropped them, silently resetting
|
||||
/// the persisted script to schema defaults. Same in-memory host pattern as
|
||||
/// <see cref="BundleImporterApplyTests"/>.
|
||||
/// </summary>
|
||||
public sealed class TemplateScriptFidelityTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public TemplateScriptFidelityTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"TemplateScriptFidelityTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
||||
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
services.AddTemplateEngine();
|
||||
services.AddDeploymentManager();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
private async Task<Guid> ExportAndLoadAsync()
|
||||
{
|
||||
Stream bundleStream;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
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: false);
|
||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await bundleStream.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
await using var loadScope = _provider.CreateAsyncScope();
|
||||
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
return session.SessionId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_preserves_MinTimeBetweenRuns_and_ExecutionTimeoutSeconds()
|
||||
{
|
||||
// Arrange: seed a template whose script carries non-default cadence + timeout.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "fresh" };
|
||||
t.Scripts.Add(new TemplateScript("cadence", "return 1;")
|
||||
{
|
||||
MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
|
||||
ExecutionTimeoutSeconds = 42,
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
// Wipe so the import exercises the Add path.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert: both fields survived the rebuild.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var script = await ctx.Templates
|
||||
.Where(t => t.Name == "Pump")
|
||||
.SelectMany(t => t.Scripts)
|
||||
.SingleAsync(s => s.Name == "cadence");
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), script.MinTimeBetweenRuns);
|
||||
Assert.Equal(42, script.ExecutionTimeoutSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overwrite_updates_ExecutionTimeoutSeconds_only_diff_and_emits_audit()
|
||||
{
|
||||
// Arrange: seed script with ExecutionTimeoutSeconds=42; export; then mutate the
|
||||
// target script so it differs from the bundle ONLY in ExecutionTimeoutSeconds.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "same" };
|
||||
t.Scripts.Add(new TemplateScript("cadence", "return 1;")
|
||||
{
|
||||
MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
|
||||
ExecutionTimeoutSeconds = 42,
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var script = await ctx.Templates
|
||||
.Where(t => t.Name == "Pump")
|
||||
.SelectMany(t => t.Scripts)
|
||||
.SingleAsync(s => s.Name == "cadence");
|
||||
script.ExecutionTimeoutSeconds = 10; // now the only field that differs from the bundle
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert: field restored to the bundle value AND a TemplateScriptUpdated audit row emitted.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var script = await ctx.Templates
|
||||
.Where(t => t.Name == "Pump")
|
||||
.SelectMany(t => t.Scripts)
|
||||
.SingleAsync(s => s.Name == "cadence");
|
||||
Assert.Equal(42, script.ExecutionTimeoutSeconds);
|
||||
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||
a.Action == "TemplateScriptUpdated" && a.EntityName == "Pump.cadence"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_preserves_LockedInDerived_on_attribute_alarm_script()
|
||||
{
|
||||
// #05-T4: LockedInDerived must survive export→import on all three child kinds.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "fresh" };
|
||||
t.Attributes.Add(new TemplateAttribute("Pressure")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
t.Alarms.Add(new TemplateAlarm("High")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
t.Scripts.Add(new TemplateScript("cadence", "return 1;")
|
||||
{
|
||||
LockedInDerived = true,
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = await ctx.Templates
|
||||
.Where(t => t.Name == "Pump")
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.SingleAsync();
|
||||
Assert.True(t.Attributes.Single(a => a.Name == "Pressure").LockedInDerived);
|
||||
Assert.True(t.Alarms.Single(a => a.Name == "High").LockedInDerived);
|
||||
Assert.True(t.Scripts.Single(s => s.Name == "cadence").LockedInDerived);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// #05-T8 — per-entity <b>structural</b> round-trip guard.
|
||||
/// <para>
|
||||
/// For every transported entity type, seed a MAXIMALLY populated entity (every
|
||||
/// writable data property set to a non-default value), export it through the real
|
||||
/// <see cref="IBundleExporter"/>, import it into a fresh target through the real
|
||||
/// <see cref="IBundleImporter"/> (<c>LoadAsync</c>→<c>ApplyAsync</c>, Add path;
|
||||
/// site/instance types via a <see cref="BundleNameMap"/> with
|
||||
/// <see cref="MappingAction.CreateNew"/>), reload, and compare BY REFLECTION.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The reflection sweep (<see cref="AssertRoundTripEqual{T}"/>) is the point: it
|
||||
/// walks every public writable scalar/string property and fails on the first one
|
||||
/// that did not survive export→import, EXCEPT an explicit per-type exclusion list.
|
||||
/// A future entity property that no DTO carries will therefore fail this test
|
||||
/// instead of silently vanishing — exactly the class of regression that lost
|
||||
/// <c>LockedInDerived</c>, script cadence/timeout and native-alarm sources before
|
||||
/// Tasks 3–5. Excluded properties are limited to: surrogate <c>Id</c>s, FK id
|
||||
/// columns that are legitimately remapped to the target's own surrogate keys,
|
||||
/// inheritance-placeholder flags that are re-derived at import (never carried), and
|
||||
/// the one documented design reset (imported <see cref="Instance.State"/> →
|
||||
/// <see cref="InstanceState.NotDeployed"/>). Each exclusion is annotated inline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both the "source" and "imported" entities are re-read from the DB (AsNoTracking)
|
||||
/// so both pass through identical persistence normalization — the comparison
|
||||
/// isolates the export→import boundary, not save-time value conversion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// GUARD STATE (2026-07-09): five residual fidelity holes are currently exposed by
|
||||
/// this suite and reported to the orchestrator rather than masked (per the T8
|
||||
/// discipline — a hole is a real regression, never a test to weaken). Each has a
|
||||
/// GREEN serializer-level unit test, proving the DTO carries the data and the loss
|
||||
/// is in the full import/export pipeline — exactly the class this integration guard
|
||||
/// exists to catch:
|
||||
/// (1) <c>ExternalSystemDefinition.MaxRetries</c> — dropped on import Add.
|
||||
/// (2) <c>ExternalSystemDefinition.RetryDelay</c> — dropped on import Add.
|
||||
/// (3) <c>ExternalSystemMethod</c> (all rows) — dropped on import Add
|
||||
/// (BuildExternalSystem carries no methods; only the Overwrite path syncs them).
|
||||
/// (4) <c>NotificationRecipient</c> (all rows) — dropped on EXPORT
|
||||
/// (GetNotificationListByIdAsync uses FindAsync with no Include(Recipients)).
|
||||
/// (5) <c>TemplateFolder.ParentFolderId</c> — hierarchy flattened on import Add
|
||||
/// (the folder Add ignores the DTO's ParentName).
|
||||
/// The three tests that surface these ((RED) below) are intentionally failing until a
|
||||
/// separate production task closes the gaps.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RoundTripEquivalenceTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public RoundTripEquivalenceTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"RoundTripEquivalenceTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
||||
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
services.AddTemplateEngine();
|
||||
services.AddDeploymentManager();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Reflection sweep
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that every public writable scalar/string property of <paramref name="source"/>
|
||||
/// equals the same property on <paramref name="imported"/>, except those in
|
||||
/// <paramref name="excluded"/>. Navigation collections and FK-id props excluded by
|
||||
/// name are ignored — they are asserted separately by each test.
|
||||
/// </summary>
|
||||
private static void AssertRoundTripEqual<T>(T source, T imported, params string[] excluded)
|
||||
{
|
||||
foreach (var p in typeof(T).GetProperties().Where(p => p.CanWrite
|
||||
&& !excluded.Contains(p.Name)
|
||||
&& (p.PropertyType.IsValueType || p.PropertyType == typeof(string))))
|
||||
{
|
||||
Assert.True(Equals(p.GetValue(source), p.GetValue(imported)),
|
||||
$"{typeof(T).Name}.{p.Name} did not survive export→import " +
|
||||
$"(source={p.GetValue(source)}, imported={p.GetValue(imported)})");
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Pipeline helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly int[] None = [];
|
||||
|
||||
/// <summary>An all-empty export selection; layer the relevant ids on with a <c>with</c> expression.</summary>
|
||||
private static ExportSelection EmptySelection(bool includeDependencies = false) =>
|
||||
new(None, None, None, None, None, None, None, includeDependencies);
|
||||
|
||||
private async Task RunAsync(Func<ScadaBridgeDbContext, Task> action)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
await action(ctx);
|
||||
}
|
||||
|
||||
private async Task<Guid> ExportAndLoadAsync(Func<ScadaBridgeDbContext, Task<ExportSelection>> selectionFactory)
|
||||
{
|
||||
Stream bundleStream;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var selection = await selectionFactory(ctx);
|
||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await bundleStream.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
await using var loadScope = _provider.CreateAsyncScope();
|
||||
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
return session.SessionId;
|
||||
}
|
||||
|
||||
/// <summary>Applies the bundle with the given resolutions (unlisted entities default to Add).</summary>
|
||||
private async Task<ImportResult> ApplyAsync(
|
||||
Guid sessionId, IReadOnlyList<ImportResolution> resolutions, BundleNameMap? nameMap = null)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
return await importer.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None, nameMap: nameMap);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Template + its five child kinds + folders
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_template_closure_preserves_every_writable_property()
|
||||
{
|
||||
// A folder hierarchy (root → child), a "Composed" base template that
|
||||
// "MaxTemplate" composes, and a maximally-populated "MaxTemplate" carrying one
|
||||
// of each child kind (attribute/alarm/script/native-alarm-source/composition).
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var root = new TemplateFolder("Root") { SortOrder = 3 };
|
||||
ctx.TemplateFolders.Add(root);
|
||||
await ctx.SaveChangesAsync();
|
||||
var child = new TemplateFolder("Pumps") { ParentFolderId = root.Id, SortOrder = 7 };
|
||||
ctx.TemplateFolders.Add(child);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var composed = new Template("Composed") { Description = "composed base", FolderId = child.Id };
|
||||
ctx.Templates.Add(composed);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var tpl = new Template("MaxTemplate")
|
||||
{
|
||||
Description = "maximally populated template",
|
||||
FolderId = child.Id,
|
||||
};
|
||||
tpl.Attributes.Add(new TemplateAttribute("Tags")
|
||||
{
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.String,
|
||||
Value = "[\"alpha\",\"beta\"]",
|
||||
IsLocked = true,
|
||||
Description = "attr description",
|
||||
DataSourceReference = "ns=1;s=Tags",
|
||||
LockedInDerived = true,
|
||||
});
|
||||
tpl.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
|
||||
{
|
||||
TriggerType = "Periodic",
|
||||
TriggerConfiguration = "{\"interval\":5}",
|
||||
ParameterDefinitions = "[{\"name\":\"x\"}]",
|
||||
ReturnDefinition = "void",
|
||||
IsLocked = true,
|
||||
MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
|
||||
ExecutionTimeoutSeconds = 42,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
tpl.Alarms.Add(new TemplateAlarm("High")
|
||||
{
|
||||
Description = "high alarm",
|
||||
PriorityLevel = 4,
|
||||
IsLocked = true,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"threshold\":100}",
|
||||
LockedInDerived = true,
|
||||
});
|
||||
tpl.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
|
||||
{
|
||||
Description = "boiler native alarms",
|
||||
ConnectionName = "OpcUaPrimary",
|
||||
SourceReference = "ns=3;s=Boiler.Alarm",
|
||||
ConditionFilter = "severity>500",
|
||||
IsLocked = true,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
tpl.Compositions.Add(new TemplateComposition("MotorA") { ComposedTemplateId = composed.Id });
|
||||
ctx.Templates.Add(tpl);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
// Wire the alarm's on-trigger script FK now that ids are assigned.
|
||||
var script = tpl.Scripts.Single();
|
||||
tpl.Alarms.Single().OnTriggerScriptId = script.Id;
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
// Ground truth (persisted state), re-read detached.
|
||||
Template srcTemplate = null!;
|
||||
await RunAsync(async ctx => srcTemplate = await LoadTemplateAsync(ctx, "MaxTemplate"));
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
return EmptySelection() with { TemplateIds = ids };
|
||||
});
|
||||
|
||||
// Wipe so the import exercises the Add path against a fresh target.
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var imported = await LoadTemplateAsync(ctx, "MaxTemplate");
|
||||
var impChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");
|
||||
|
||||
// Template: Id + FKs remapped by name; IsDerived is engine-managed.
|
||||
AssertRoundTripEqual(srcTemplate, imported,
|
||||
nameof(Template.Id), nameof(Template.ParentTemplateId), nameof(Template.FolderId),
|
||||
nameof(Template.OwnerCompositionId), nameof(Template.IsDerived));
|
||||
|
||||
// Template→folder linkage survives by name (folder-to-parent linkage is
|
||||
// covered — and currently RED — by RoundTrip_template_folders_*).
|
||||
Assert.Equal(impChild.Id, imported.FolderId);
|
||||
|
||||
// TemplateAttribute. IsInherited is an inheritance placeholder re-derived at
|
||||
// import (not carried on a base-template row).
|
||||
AssertRoundTripEqual(srcTemplate.Attributes.Single(), imported.Attributes.Single(),
|
||||
nameof(TemplateAttribute.Id), nameof(TemplateAttribute.TemplateId),
|
||||
nameof(TemplateAttribute.IsInherited));
|
||||
|
||||
// TemplateScript.
|
||||
AssertRoundTripEqual(srcTemplate.Scripts.Single(), imported.Scripts.Single(),
|
||||
nameof(TemplateScript.Id), nameof(TemplateScript.TemplateId),
|
||||
nameof(TemplateScript.IsInherited));
|
||||
|
||||
// TemplateAlarm. OnTriggerScriptId is an in-aggregate FK re-resolved by name.
|
||||
var srcAlarm = srcTemplate.Alarms.Single();
|
||||
var impAlarm = imported.Alarms.Single();
|
||||
AssertRoundTripEqual(srcAlarm, impAlarm,
|
||||
nameof(TemplateAlarm.Id), nameof(TemplateAlarm.TemplateId),
|
||||
nameof(TemplateAlarm.OnTriggerScriptId), nameof(TemplateAlarm.IsInherited));
|
||||
// On-trigger script linkage survives by name (re-points at the new script id).
|
||||
Assert.Equal(imported.Scripts.Single().Id, impAlarm.OnTriggerScriptId);
|
||||
|
||||
// TemplateNativeAlarmSource — DTO carries IsInherited, so it IS swept.
|
||||
AssertRoundTripEqual(srcTemplate.NativeAlarmSources.Single(), imported.NativeAlarmSources.Single(),
|
||||
nameof(TemplateNativeAlarmSource.Id), nameof(TemplateNativeAlarmSource.TemplateId));
|
||||
|
||||
// TemplateComposition. TemplateId + ComposedTemplateId remapped by name.
|
||||
AssertRoundTripEqual(srcTemplate.Compositions.Single(), imported.Compositions.Single(),
|
||||
nameof(TemplateComposition.Id), nameof(TemplateComposition.TemplateId),
|
||||
nameof(TemplateComposition.ComposedTemplateId));
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Template> LoadTemplateAsync(ScadaBridgeDbContext ctx, string name) =>
|
||||
await ctx.Templates
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.NativeAlarmSources)
|
||||
.Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Name == name);
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_template_folders_preserve_hierarchy_and_sort_order()
|
||||
{
|
||||
// Folders travel as the ancestor chain of an exported template, so seed a
|
||||
// root→child hierarchy with a template in the child folder.
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var root = new TemplateFolder("Root") { SortOrder = 3 };
|
||||
ctx.TemplateFolders.Add(root);
|
||||
await ctx.SaveChangesAsync();
|
||||
var child = new TemplateFolder("Pumps") { ParentFolderId = root.Id, SortOrder = 7 };
|
||||
ctx.TemplateFolders.Add(child);
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.Templates.Add(new Template("Pump") { FolderId = child.Id });
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
TemplateFolder srcRoot = null!, srcChild = null!;
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
srcRoot = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Root");
|
||||
srcChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");
|
||||
});
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
return EmptySelection() with { TemplateIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var impRoot = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Root");
|
||||
var impChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");
|
||||
|
||||
// Name + SortOrder round-trip (GREEN). ParentFolderId remapped by name.
|
||||
AssertRoundTripEqual(srcRoot, impRoot,
|
||||
nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId));
|
||||
AssertRoundTripEqual(srcChild, impChild,
|
||||
nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId));
|
||||
|
||||
// (RED) HOLE #5 — folder hierarchy is flattened on import Add: the folder
|
||||
// Add pass builds `new TemplateFolder(dto.Name) { SortOrder }` and never
|
||||
// consults the DTO's ParentName, so the child lands parent-less. The
|
||||
// serializer-level Roundtrip_template_folder_preserves_hierarchy test is
|
||||
// GREEN — the loss is purely in BundleImporter's folder Add pass.
|
||||
Assert.Equal(impRoot.Id, impChild.ParentFolderId);
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// SharedScript
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_shared_script_preserves_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.SharedScripts.Add(new SharedScript("Helpers", "int Add(int a,int b)=>a+b;")
|
||||
{
|
||||
ParameterDefinitions = "[{\"name\":\"a\"}]",
|
||||
ReturnDefinition = "int",
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
SharedScript src = null!;
|
||||
await RunAsync(async ctx => src = await ctx.SharedScripts.AsNoTracking().SingleAsync());
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
|
||||
return EmptySelection() with { SharedScriptIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var imported = await ctx.SharedScripts.AsNoTracking().SingleAsync();
|
||||
AssertRoundTripEqual(src, imported, nameof(SharedScript.Id));
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// ExternalSystemDefinition + ExternalSystemMethod
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_external_system_and_method_preserve_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
|
||||
{
|
||||
AuthConfiguration = "{\"apiKey\":\"super-secret\"}", // rides SecretsBlock
|
||||
MaxRetries = 6,
|
||||
RetryDelay = TimeSpan.FromSeconds(17),
|
||||
};
|
||||
ctx.ExternalSystemDefinitions.Add(sys);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostOrder", "POST", "/orders")
|
||||
{
|
||||
ExternalSystemDefinitionId = sys.Id,
|
||||
ParameterDefinitions = "[{\"name\":\"body\"}]",
|
||||
ReturnDefinition = "{\"id\":\"int\"}",
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
ExternalSystemDefinition srcSys = null!;
|
||||
ExternalSystemMethod srcMethod = null!;
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
srcSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync();
|
||||
srcMethod = await ctx.ExternalSystemMethods.AsNoTracking().SingleAsync();
|
||||
});
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.ExternalSystemDefinitions.Select(s => s.Id).ToListAsync();
|
||||
return EmptySelection() with { ExternalSystemIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.ExternalSystemMethods.RemoveRange(ctx.ExternalSystemMethods);
|
||||
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var impSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync();
|
||||
|
||||
// (RED) HOLES #1/#2 — MaxRetries + RetryDelay are dropped on import Add:
|
||||
// BuildExternalSystem builds `new ExternalSystemDefinition(name, url, authType)
|
||||
// { AuthConfiguration }` and never copies the DTO's retry config. The
|
||||
// serializer-level Roundtrip_external_system_preserves_retry_config test is
|
||||
// GREEN — the loss is purely in BundleImporter's Add path.
|
||||
AssertRoundTripEqual(srcSys, impSys, nameof(ExternalSystemDefinition.Id));
|
||||
|
||||
// (RED) HOLE #3 — ExternalSystemMethod rows are dropped on import Add:
|
||||
// only the Overwrite path calls SyncExternalSystemMethodsAsync; the Add path
|
||||
// carries no methods (the definition entity has no Methods navigation to
|
||||
// cascade). The serializer-level round-trip carries them; the pipeline loses
|
||||
// them into a fresh target.
|
||||
var methodCount = await ctx.ExternalSystemMethods.CountAsync();
|
||||
Assert.True(methodCount == 1,
|
||||
$"FIDELITY HOLE: ExternalSystemMethod dropped on import Add (expected 1, got {methodCount}).");
|
||||
var impMethod = await ctx.ExternalSystemMethods.AsNoTracking().SingleAsync();
|
||||
AssertRoundTripEqual(srcMethod, impMethod,
|
||||
nameof(ExternalSystemMethod.Id), nameof(ExternalSystemMethod.ExternalSystemDefinitionId));
|
||||
Assert.Equal(impSys.Id, impMethod.ExternalSystemDefinitionId);
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// DatabaseConnectionDefinition (External-System DB connection)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_database_connection_preserves_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.DatabaseConnectionDefinitions.Add(
|
||||
new DatabaseConnectionDefinition("erp-db", "Server=db;Database=erp;User=sa;Password=p@ss")
|
||||
{
|
||||
MaxRetries = 8,
|
||||
RetryDelay = TimeSpan.FromSeconds(21),
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
DatabaseConnectionDefinition src = null!;
|
||||
await RunAsync(async ctx => src = await ctx.DatabaseConnectionDefinitions.AsNoTracking().SingleAsync());
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.DatabaseConnectionDefinitions.Select(d => d.Id).ToListAsync();
|
||||
return EmptySelection() with { DatabaseConnectionIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.DatabaseConnectionDefinitions.RemoveRange(ctx.DatabaseConnectionDefinitions);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var imported = await ctx.DatabaseConnectionDefinitions.AsNoTracking().SingleAsync();
|
||||
// ConnectionString rides the SecretsBlock; the sweep verifies it survives.
|
||||
AssertRoundTripEqual(src, imported, nameof(DatabaseConnectionDefinition.Id));
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// NotificationList + NotificationRecipient
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_notification_list_and_recipient_preserve_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var list = new NotificationList("on-call") { Type = NotificationType.Sms };
|
||||
// A recipient carrying BOTH contacts so every writable field is non-default.
|
||||
list.Recipients.Add(new NotificationRecipient("Pager", "pager@example.com")
|
||||
{
|
||||
PhoneNumber = "+15551234567",
|
||||
});
|
||||
ctx.NotificationLists.Add(list);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
NotificationList srcList = null!;
|
||||
NotificationRecipient srcRecipient = null!;
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
srcList = await ctx.NotificationLists.AsNoTracking().SingleAsync();
|
||||
srcRecipient = await ctx.NotificationRecipients.AsNoTracking().SingleAsync();
|
||||
});
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.NotificationLists.Select(n => n.Id).ToListAsync();
|
||||
return EmptySelection() with { NotificationListIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients);
|
||||
ctx.NotificationLists.RemoveRange(ctx.NotificationLists);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var impList = await ctx.NotificationLists.AsNoTracking().SingleAsync();
|
||||
|
||||
// List scalars (Name, Type) round-trip (GREEN).
|
||||
AssertRoundTripEqual(srcList, impList, nameof(NotificationList.Id));
|
||||
|
||||
// (RED) HOLE #4 — recipients are dropped on EXPORT: the dependency
|
||||
// resolver loads each selected list via GetNotificationListByIdAsync, which
|
||||
// uses FindAsync(id) with NO Include(Recipients), so the exported DTO carries
|
||||
// an empty Recipients collection and the import adds a recipient-less list.
|
||||
// The serializer-level Roundtrip_*_recipient_* tests are GREEN — the loss is
|
||||
// in the export repository query, not the DTO.
|
||||
var recipientCount = await ctx.NotificationRecipients.CountAsync();
|
||||
Assert.True(recipientCount == 1,
|
||||
$"FIDELITY HOLE: NotificationRecipient dropped through export→import (expected 1, got {recipientCount}).");
|
||||
var impRecipient = await ctx.NotificationRecipients.AsNoTracking().SingleAsync();
|
||||
AssertRoundTripEqual(srcRecipient, impRecipient,
|
||||
nameof(NotificationRecipient.Id), nameof(NotificationRecipient.NotificationListId));
|
||||
Assert.Equal(impList.Id, impRecipient.NotificationListId);
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// SmtpConfiguration
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_smtp_configuration_preserves_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.SmtpConfigurations.Add(new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
|
||||
{
|
||||
Port = 587,
|
||||
Credentials = "user:p@ssw0rd", // rides SecretsBlock
|
||||
TlsMode = "StartTls",
|
||||
ConnectionTimeoutSeconds = 25,
|
||||
MaxConcurrentConnections = 4,
|
||||
MaxRetries = 9,
|
||||
RetryDelay = TimeSpan.FromSeconds(33),
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
SmtpConfiguration src = null!;
|
||||
await RunAsync(async ctx => src = await ctx.SmtpConfigurations.AsNoTracking().SingleAsync());
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.SmtpConfigurations.Select(s => s.Id).ToListAsync();
|
||||
return EmptySelection() with { SmtpConfigurationIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.SmtpConfigurations.RemoveRange(ctx.SmtpConfigurations);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var imported = await ctx.SmtpConfigurations.AsNoTracking().SingleAsync();
|
||||
AssertRoundTripEqual(src, imported, nameof(SmtpConfiguration.Id));
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// SmsConfiguration
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_sms_configuration_preserves_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.SmsConfigurations.Add(new SmsConfiguration("AC123", "+15550001111")
|
||||
{
|
||||
AuthToken = "twilio-super-secret", // rides SecretsBlock
|
||||
MessagingServiceSid = "MG999",
|
||||
ApiBaseUrl = "https://api.twilio.com",
|
||||
ConnectionTimeoutSeconds = 20,
|
||||
MaxRetries = 7,
|
||||
RetryDelay = TimeSpan.FromSeconds(45),
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
SmsConfiguration src = null!;
|
||||
await RunAsync(async ctx => src = await ctx.SmsConfigurations.AsNoTracking().SingleAsync());
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.SmsConfigurations.Select(s => s.Id).ToListAsync();
|
||||
return EmptySelection() with { SmsConfigurationIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.SmsConfigurations.RemoveRange(ctx.SmsConfigurations);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var imported = await ctx.SmsConfigurations.AsNoTracking().SingleAsync();
|
||||
AssertRoundTripEqual(src, imported, nameof(SmsConfiguration.Id));
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// ApiMethod
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_api_method_preserves_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
// Script must not name-mention an identifier the importer's semantic scan
|
||||
// would treat as a referenced SharedScript/ExternalSystem — keep it literal.
|
||||
ctx.ApiMethods.Add(new ApiMethod("Ingest", "return 42;")
|
||||
{
|
||||
ParameterDefinitions = "[{\"name\":\"x\",\"type\":\"Int32\"}]",
|
||||
ReturnDefinition = "{\"ok\":\"Boolean\"}",
|
||||
TimeoutSeconds = 55,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
ApiMethod src = null!;
|
||||
await RunAsync(async ctx => src = await ctx.ApiMethods.AsNoTracking().SingleAsync());
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var ids = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
||||
return EmptySelection() with { ApiMethodIds = ids };
|
||||
});
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
await ApplyAsync(sessionId, []);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var imported = await ctx.ApiMethods.AsNoTracking().SingleAsync();
|
||||
AssertRoundTripEqual(src, imported, nameof(ApiMethod.Id));
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Site + DataConnection + Instance + all four instance-child kinds
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_site_closure_preserves_every_writable_property()
|
||||
{
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var template = new Template("Pump") { Description = "pump tpl" };
|
||||
ctx.Templates.Add(template);
|
||||
|
||||
var site = new Site("Plant 1", "plant-1")
|
||||
{
|
||||
Description = "primary plant",
|
||||
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 conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
||||
{
|
||||
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}", // rides SecretsBlock
|
||||
BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}", // rides SecretsBlock
|
||||
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("Flow")
|
||||
{
|
||||
OverrideValue = "[42,43]",
|
||||
ElementDataType = DataType.Int32,
|
||||
});
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
|
||||
{
|
||||
TriggerConfigurationOverride = "{\"hi\":90}",
|
||||
PriorityLevelOverride = 7,
|
||||
});
|
||||
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
|
||||
{
|
||||
ConnectionNameOverride = "OpcUaPrimary",
|
||||
SourceReferenceOverride = "ns=3;s=Pump.Alarm",
|
||||
ConditionFilterOverride = "severity>200",
|
||||
});
|
||||
ctx.Instances.Add(instance);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow")
|
||||
{
|
||||
DataConnectionId = conn.Id,
|
||||
DataSourceReferenceOverride = "ns=3;s=Pump.Flow",
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
});
|
||||
|
||||
Site srcSite = null!;
|
||||
DataConnection srcConn = null!;
|
||||
Instance srcInstance = null!;
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
srcSite = await ctx.Sites.AsNoTracking().SingleAsync(s => s.SiteIdentifier == "plant-1");
|
||||
srcConn = await ctx.DataConnections.AsNoTracking().SingleAsync(c => c.Name == "OpcUaPrimary");
|
||||
srcInstance = await LoadInstanceAsync(ctx, "Pump-01");
|
||||
});
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx =>
|
||||
{
|
||||
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
||||
return EmptySelection(includeDependencies: true) with { SiteIds = siteIds };
|
||||
});
|
||||
|
||||
// Wipe the site closure; keep a fresh "Pump" template so the instance resolves.
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
ctx.InstanceConnectionBindings.RemoveRange(ctx.InstanceConnectionBindings);
|
||||
ctx.InstanceAttributeOverrides.RemoveRange(ctx.InstanceAttributeOverrides);
|
||||
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
|
||||
ctx.InstanceNativeAlarmSourceOverrides.RemoveRange(ctx.InstanceNativeAlarmSourceOverrides);
|
||||
ctx.Instances.RemoveRange(ctx.Instances);
|
||||
ctx.DataConnections.RemoveRange(ctx.DataConnections);
|
||||
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 SiteMapping("plant-1", MappingAction.CreateNew, null)],
|
||||
Connections: [new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null)]);
|
||||
|
||||
await ApplyAsync(
|
||||
sessionId,
|
||||
[
|
||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
|
||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||
],
|
||||
nameMap);
|
||||
|
||||
await RunAsync(async ctx =>
|
||||
{
|
||||
var impSite = await ctx.Sites.AsNoTracking().SingleAsync(s => s.SiteIdentifier == "plant-1");
|
||||
var impConn = await ctx.DataConnections.AsNoTracking().SingleAsync(c => c.Name == "OpcUaPrimary");
|
||||
var impInstance = await LoadInstanceAsync(ctx, "Pump-01");
|
||||
|
||||
// Site.
|
||||
AssertRoundTripEqual(srcSite, impSite, nameof(Site.Id));
|
||||
|
||||
// DataConnection. SiteId remapped; Primary/Backup config ride the SecretsBlock.
|
||||
AssertRoundTripEqual(srcConn, impConn, nameof(DataConnection.Id), nameof(DataConnection.SiteId));
|
||||
Assert.Equal(impSite.Id, impConn.SiteId);
|
||||
|
||||
// Instance. TemplateId/SiteId/AreaId remapped; State is a documented design
|
||||
// reset (imported instances always land NotDeployed).
|
||||
AssertRoundTripEqual(srcInstance, impInstance,
|
||||
nameof(Instance.Id), nameof(Instance.TemplateId), nameof(Instance.SiteId),
|
||||
nameof(Instance.AreaId), nameof(Instance.State));
|
||||
Assert.Equal(InstanceState.NotDeployed, impInstance.State);
|
||||
Assert.Equal(impSite.Id, impInstance.SiteId);
|
||||
|
||||
// InstanceAttributeOverride.
|
||||
AssertRoundTripEqual(srcInstance.AttributeOverrides.Single(), impInstance.AttributeOverrides.Single(),
|
||||
nameof(InstanceAttributeOverride.Id), nameof(InstanceAttributeOverride.InstanceId));
|
||||
|
||||
// InstanceAlarmOverride.
|
||||
AssertRoundTripEqual(srcInstance.AlarmOverrides.Single(), impInstance.AlarmOverrides.Single(),
|
||||
nameof(InstanceAlarmOverride.Id), nameof(InstanceAlarmOverride.InstanceId));
|
||||
|
||||
// InstanceNativeAlarmSourceOverride.
|
||||
AssertRoundTripEqual(srcInstance.NativeAlarmSourceOverrides.Single(), impInstance.NativeAlarmSourceOverrides.Single(),
|
||||
nameof(InstanceNativeAlarmSourceOverride.Id), nameof(InstanceNativeAlarmSourceOverride.InstanceId));
|
||||
|
||||
// InstanceConnectionBinding. DataConnectionId remapped to the created conn.
|
||||
var impBinding = impInstance.ConnectionBindings.Single();
|
||||
AssertRoundTripEqual(srcInstance.ConnectionBindings.Single(), impBinding,
|
||||
nameof(InstanceConnectionBinding.Id), nameof(InstanceConnectionBinding.InstanceId),
|
||||
nameof(InstanceConnectionBinding.DataConnectionId));
|
||||
Assert.Equal(impConn.Id, impBinding.DataConnectionId);
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Instance> LoadInstanceAsync(ScadaBridgeDbContext ctx, string uniqueName) =>
|
||||
await ctx.Instances
|
||||
.AsNoTracking()
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.NativeAlarmSourceOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.SingleAsync(i => i.UniqueName == uniqueName);
|
||||
}
|
||||
+294
-15
@@ -77,6 +77,10 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
// #05-T19: also carry any seeded ApiMethods so the severity-split
|
||||
// tests (template-script = warning, ApiMethod = hard error) exercise
|
||||
// both source kinds through the same export→load→apply path.
|
||||
var apiIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: ids,
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
@@ -84,7 +88,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
ApiMethodIds: apiIds,
|
||||
IncludeDependencies: false);
|
||||
|
||||
var stream = await exporter.ExportAsync(selection,
|
||||
@@ -103,6 +107,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -118,15 +123,14 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SemanticValidator_catches_invalid_call_target_at_import()
|
||||
public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import()
|
||||
{
|
||||
// Arrange — template whose script body calls UnknownHelper(): a
|
||||
// #05-T19 — a template whose script body calls UnknownHelper(): a
|
||||
// PascalCase identifier that doesn't resolve to any SharedScript or
|
||||
// ExternalSystem in the bundle or the target. This is the operator-
|
||||
// facing "invalid call target" surface — the full SemanticValidator's
|
||||
// CallScript/CallShared signature checks live downstream of name
|
||||
// resolution (you can't check arg count against a function that
|
||||
// doesn't exist). Pass 1 catches it first and fails fast.
|
||||
// ExternalSystem in the bundle or the target. Under the severity split,
|
||||
// template-script name-resolution findings are ADVISORY (the design-time
|
||||
// deploy gate re-validates authoritatively), so the import SUCCEEDS with
|
||||
// the finding surfaced on ImportResult.Warnings — it does NOT hard-block.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
@@ -140,8 +144,129 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
// Act — apply must throw SemanticValidationException carrying the bad
|
||||
// call target by name.
|
||||
// Act — apply succeeds (no throw); the finding rides on Warnings.
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "ScriptCallsUnknown", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — the template landed AND the advisory warning names the target.
|
||||
Assert.Equal(1, result.Added);
|
||||
Assert.Contains(result.Warnings,
|
||||
w => w.Contains("UnknownHelper", StringComparison.Ordinal));
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "ScriptCallsUnknown"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_TemplateScriptWithLocalPascalCaseMethod_DoesNotHardBlock()
|
||||
{
|
||||
// #05-T19 — a template script that DECLARES and calls its own local
|
||||
// PascalCase function. The identifier scan would otherwise flag
|
||||
// ComputeRate as a missing SharedScript reference; the local-declaration
|
||||
// exclusion (Roslyn-parsed local functions/methods) removes it, so the
|
||||
// import produces neither an error nor a warning.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("SelfContainedCalc");
|
||||
t.Attributes.Add(new TemplateAttribute("A") { DataType = DataType.Double, Value = "1" });
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"init",
|
||||
"decimal ComputeRate(decimal x) => x * 2; return ComputeRate(2m);"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "SelfContainedCalc", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
Assert.Equal(1, result.Added);
|
||||
// The locally-declared function must NOT surface as a warning.
|
||||
Assert.DoesNotContain(result.Warnings,
|
||||
w => w.Contains("ComputeRate", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_TemplateScriptWithRegexAndStringBuilder_DoesNotHardBlock()
|
||||
{
|
||||
// #05-T19 — a template script using stdlib helpers that the extended
|
||||
// denylist now recognizes (Regex, StringBuilder, …). None resolve to a
|
||||
// user SharedScript/ExternalSystem, and none should be flagged.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("UsesStdlib");
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"init",
|
||||
"""
|
||||
var ok = Regex.IsMatch("abc", "a.c");
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("x");
|
||||
return sb.ToString();
|
||||
"""));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "UsesStdlib", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
Assert.Equal(1, result.Added);
|
||||
Assert.DoesNotContain(result.Warnings,
|
||||
w => w.Contains("Regex", StringComparison.Ordinal)
|
||||
|| w.Contains("StringBuilder", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_ApiMethodWithUnresolvedReference_StillHardBlocks()
|
||||
{
|
||||
// #05-T19 — an ApiMethod script referencing a genuinely-missing
|
||||
// ExternalSystem. ApiMethod findings have no downstream deploy gate, so
|
||||
// they remain HARD errors: the apply throws SemanticValidationException.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ApiMethods.Add(new Commons.Entities.InboundApi.ApiMethod(
|
||||
"Ingest",
|
||||
"var x = ErpMissing(); return x;"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
SemanticValidationException ex = default!;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
@@ -150,24 +275,178 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "ScriptCallsUnknown", ResolutionAction.Add, null),
|
||||
new("ApiMethod", "Ingest", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert — error message names the bad target.
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors,
|
||||
err => err.Contains("UnknownHelper", StringComparison.Ordinal));
|
||||
err => err.Contains("ErpMissing", StringComparison.Ordinal));
|
||||
|
||||
// Rollback — no template row landed.
|
||||
// Rollback — no ApiMethod row landed.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "ScriptCallsUnknown"));
|
||||
Assert.False(await ctx.ApiMethods.AnyAsync(m => m.Name == "Ingest"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_TemplateScriptWithForbiddenApi_HardBlocks()
|
||||
{
|
||||
// #05-T20 — bundle import is the fifth script-trust call site. A template
|
||||
// script that reaches a forbidden API (Process.Start) must be rejected at
|
||||
// import review, NOT left to fail at runtime. Unlike the name-resolution
|
||||
// heuristic, a trust verdict is authoritative → hard error for ALL kinds.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Malicious");
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"init",
|
||||
"System.Diagnostics.Process.Start(\"cmd\");"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
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", "Malicious", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "Malicious"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_ApiMethodWithForbiddenApi_HardBlocks()
|
||||
{
|
||||
// #05-T20 — same trust gate over ApiMethod script bodies.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ApiMethods.Add(new Commons.Entities.InboundApi.ApiMethod(
|
||||
"Ingest",
|
||||
"System.Diagnostics.Process.Start(\"cmd\"); return 1;"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
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("ApiMethod", "Ingest", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_AlarmExpressionTriggerWithForbiddenApi_HardBlocks()
|
||||
{
|
||||
// #05-T20 (code-review fix): an alarm's Expression trigger compiles and
|
||||
// executes at the site, so a forbidden API in the trigger expression must
|
||||
// be caught at import — not just in template/shared/ApiMethod script bodies.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("AlarmedTank");
|
||||
t.Alarms.Add(new TemplateAlarm("Bad")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.Expression,
|
||||
TriggerConfiguration = "{\"expression\":\"System.Diagnostics.Process.GetCurrentProcess() != null\"}",
|
||||
PriorityLevel = 1,
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
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", "AlarmedTank", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
|
||||
{
|
||||
// #05-T19 (code-review fix): a local function declared in a TEMPLATE script
|
||||
// must not suppress a genuinely-missing reference of the same name in an
|
||||
// ApiMethod — the ApiMethod finding stays a HARD error. A pre-fix global
|
||||
// locallyDeclared set silently defeated this.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Helper");
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"init",
|
||||
"decimal SharedHelper(decimal x) => x * 2; return SharedHelper(1m);"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.ApiMethods.Add(new Commons.Entities.InboundApi.ApiMethod(
|
||||
"Ingest",
|
||||
"var x = SharedHelper(); return x;"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
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", "Helper", ResolutionAction.Add, null),
|
||||
new("ApiMethod", "Ingest", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors, err => err.Contains("SharedHelper", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
@@ -57,37 +58,38 @@ public sealed class ValidationFailureTests : IDisposable
|
||||
public async Task Semantic_validation_failure_rolls_back_all_writes()
|
||||
{
|
||||
// Arrange:
|
||||
// - Seed a template whose script body references MissingHelper(),
|
||||
// - Seed an ApiMethod whose script body references MissingHelper(),
|
||||
// a SharedScript that is NOT in the bundle (we don't seed one) and
|
||||
// also NOT in the target after the wipe.
|
||||
// - Export the template only (no helper alongside).
|
||||
// also NOT in the target after the wipe. ApiMethod name-resolution
|
||||
// findings are HARD errors (no downstream deploy gate re-validates
|
||||
// them — unlike template-script findings, which are advisory as of
|
||||
// #05-T19), so this is the vehicle that still forces a rollback.
|
||||
// - Export the ApiMethod only (no helper alongside).
|
||||
// - Wipe the target so the apply runs through Add-paths.
|
||||
// - Snapshot row counts so the rollback assertion is unambiguous.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("BrokenPump") { Description = "calls a missing helper" };
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Export only the broken template. IncludeDependencies=false guarantees
|
||||
// Export only the broken ApiMethod. IncludeDependencies=false guarantees
|
||||
// MissingHelper is NOT pulled in even if one happened to exist.
|
||||
byte[] bundleBytes;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var ids = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: ids,
|
||||
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: Array.Empty<int>(),
|
||||
ApiMethodIds: ids,
|
||||
IncludeDependencies: false);
|
||||
|
||||
var stream = await exporter.ExportAsync(selection,
|
||||
@@ -103,21 +105,20 @@ public sealed class ValidationFailureTests : IDisposable
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
int templatesBefore;
|
||||
int apiMethodsBefore;
|
||||
int sharedScriptsBefore;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
templatesBefore = await ctx.Templates.CountAsync();
|
||||
apiMethodsBefore = await ctx.ApiMethods.CountAsync();
|
||||
sharedScriptsBefore = await ctx.SharedScripts.CountAsync();
|
||||
}
|
||||
Assert.Equal(0, templatesBefore);
|
||||
Assert.Equal(0, apiMethodsBefore);
|
||||
Assert.Equal(0, sharedScriptsBefore);
|
||||
|
||||
// Load the bundle into a session.
|
||||
@@ -139,7 +140,7 @@ public sealed class ValidationFailureTests : IDisposable
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "BrokenPump", ResolutionAction.Add, null),
|
||||
new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
@@ -150,11 +151,11 @@ public sealed class ValidationFailureTests : IDisposable
|
||||
Assert.Contains(ex.Errors,
|
||||
err => err.Contains("MissingHelper", StringComparison.Ordinal));
|
||||
|
||||
// 2. No new Template or SharedScript row was committed.
|
||||
// 2. No new ApiMethod or SharedScript row was committed.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.Equal(templatesBefore, await ctx.Templates.CountAsync());
|
||||
Assert.Equal(apiMethodsBefore, await ctx.ApiMethods.CountAsync());
|
||||
Assert.Equal(sharedScriptsBefore, await ctx.SharedScripts.CountAsync());
|
||||
|
||||
// 3. A BundleImportFailed audit row exists. The BundleImporter
|
||||
|
||||
@@ -150,6 +150,137 @@ public sealed class ArtifactDiffTests
|
||||
Assert.Contains(("add", "new2"), hunks);
|
||||
}
|
||||
|
||||
// ============ #05-T23: placeholder-identity resolution via name maps ============
|
||||
|
||||
[Fact]
|
||||
public void CompareTemplate_MatchingFolderBaseAndComposition_IsIdentical()
|
||||
{
|
||||
// A structured template (FolderId, ParentTemplateId, one composition) that
|
||||
// the incoming bundle matches by NAME must classify Identical. Before T23,
|
||||
// FolderNameOf/BaseTemplateNameOf/CompositionTargetNameOf returned "<id:N>"
|
||||
// placeholders that never equalled the incoming names, so every re-import
|
||||
// of an unchanged structured template spuriously read Modified.
|
||||
var existing = MakeTemplate("Pump");
|
||||
existing.FolderId = 5;
|
||||
existing.ParentTemplateId = 7;
|
||||
existing.Compositions.Add(new TemplateComposition("Mod1") { ComposedTemplateId = 9 });
|
||||
|
||||
var folderNameById = new Dictionary<int, string> { [5] = "Machines" };
|
||||
var templateNameById = new Dictionary<int, string> { [7] = "BasePump", [9] = "FeatureX" };
|
||||
|
||||
var incoming = MakeTemplateDto("Pump") with
|
||||
{
|
||||
FolderName = "Machines",
|
||||
BaseTemplateName = "BasePump",
|
||||
Compositions = new[] { new TemplateCompositionDto("Mod1", "FeatureX") },
|
||||
};
|
||||
|
||||
var item = _diff.CompareTemplate(incoming, existing, folderNameById, templateNameById);
|
||||
|
||||
Assert.Equal(ConflictKind.Identical, item.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTemplate_DivergentBaseTemplateName_IsModified()
|
||||
{
|
||||
// Sanity: the maps must not mask a REAL change. Existing base = "BasePump",
|
||||
// incoming names a different base → Modified.
|
||||
var existing = MakeTemplate("Pump");
|
||||
existing.ParentTemplateId = 7;
|
||||
var templateNameById = new Dictionary<int, string> { [7] = "BasePump" };
|
||||
|
||||
var incoming = MakeTemplateDto("Pump") with { BaseTemplateName = "DifferentBase" };
|
||||
|
||||
var item = _diff.CompareTemplate(incoming, existing, folderNameById: null, templateNameById: templateNameById);
|
||||
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
}
|
||||
|
||||
// ============ #05-T6: single-source child equality — diff sees the full field set ============
|
||||
|
||||
[Fact]
|
||||
public void CompareTemplate_AttributeElementDataTypeChange_IsModified()
|
||||
{
|
||||
var existing = MakeTemplate("T1");
|
||||
existing.Attributes.Add(new TemplateAttribute("L")
|
||||
{
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.Int32,
|
||||
Value = "[1,2]",
|
||||
});
|
||||
var incoming = MakeTemplateDto("T1", attributes:
|
||||
[
|
||||
new TemplateAttributeDto("L", "[1,2]", DataType.List, false, null, null, DataType.String),
|
||||
]);
|
||||
|
||||
var item = _diff.CompareTemplate(incoming, existing);
|
||||
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
ChangeFor(item, "Attributes.L"); // ElementDataType diverged — previously missed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTemplate_LockedInDerivedChange_IsModified()
|
||||
{
|
||||
var existing = MakeTemplate("T1");
|
||||
existing.Attributes.Add(new TemplateAttribute("L")
|
||||
{
|
||||
DataType = DataType.Int32,
|
||||
Value = "1",
|
||||
LockedInDerived = false,
|
||||
});
|
||||
var incoming = MakeTemplateDto("T1", attributes:
|
||||
[
|
||||
new TemplateAttributeDto("L", "1", DataType.Int32, false, null, null, null, LockedInDerived: true),
|
||||
]);
|
||||
|
||||
var item = _diff.CompareTemplate(incoming, existing);
|
||||
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
ChangeFor(item, "Attributes.L"); // LockedInDerived diverged — previously missed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified()
|
||||
{
|
||||
var existing = MakeTemplate("T1");
|
||||
existing.Scripts.Add(new TemplateScript("S", "return 1;") { ExecutionTimeoutSeconds = 30 });
|
||||
var incoming = MakeTemplateDto("T1", scripts:
|
||||
[
|
||||
// identical code; only ExecutionTimeoutSeconds differs.
|
||||
new TemplateScriptDto("S", "return 1;", null, null, null, null, false, null, ExecutionTimeoutSeconds: 60),
|
||||
]);
|
||||
|
||||
var item = _diff.CompareTemplate(incoming, existing);
|
||||
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
ChangeFor(item, "Scripts.S"); // ExecutionTimeoutSeconds diverged — previously missed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified()
|
||||
{
|
||||
var existing = MakeTemplate("T1");
|
||||
existing.Scripts.Add(new TemplateScript("Run", "return 1;") { Id = 1 });
|
||||
existing.Alarms.Add(new TemplateAlarm("A")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
OnTriggerScriptId = 1, // resolves to "Run"
|
||||
});
|
||||
var incoming = MakeTemplateDto("T1",
|
||||
scripts: [new TemplateScriptDto("Run", "return 1;", null, null, null, null, false, null)],
|
||||
alarms:
|
||||
[
|
||||
// same alarm fields but no on-trigger script (was "Run").
|
||||
new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, OnTriggerScriptName: null),
|
||||
]);
|
||||
|
||||
var item = _diff.CompareTemplate(incoming, existing);
|
||||
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed.
|
||||
}
|
||||
|
||||
// ============ M8: CompareSite ============
|
||||
|
||||
[Fact]
|
||||
@@ -433,14 +564,18 @@ public sealed class ArtifactDiffTests
|
||||
private static Commons.Entities.Templates.Template MakeTemplate(string name) =>
|
||||
new(name);
|
||||
|
||||
private static TemplateDto MakeTemplateDto(string name, IReadOnlyList<TemplateScriptDto>? scripts = null) =>
|
||||
private static TemplateDto MakeTemplateDto(
|
||||
string name,
|
||||
IReadOnlyList<TemplateScriptDto>? scripts = null,
|
||||
IReadOnlyList<TemplateAttributeDto>? attributes = null,
|
||||
IReadOnlyList<TemplateAlarmDto>? alarms = null) =>
|
||||
new(
|
||||
Name: name,
|
||||
FolderName: null,
|
||||
BaseTemplateName: null,
|
||||
Description: null,
|
||||
Attributes: Array.Empty<TemplateAttributeDto>(),
|
||||
Alarms: Array.Empty<TemplateAlarmDto>(),
|
||||
Attributes: attributes ?? Array.Empty<TemplateAttributeDto>(),
|
||||
Alarms: alarms ?? Array.Empty<TemplateAlarmDto>(),
|
||||
Scripts: scripts ?? Array.Empty<TemplateScriptDto>(),
|
||||
Compositions: Array.Empty<TemplateCompositionDto>());
|
||||
|
||||
|
||||
@@ -29,6 +29,36 @@ public sealed class BundleSessionStoreTests
|
||||
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
Contents: Array.Empty<ManifestContentEntry>());
|
||||
|
||||
[Fact]
|
||||
public void Open_beyond_MaxConcurrentImportSessions_throws_and_removing_one_admits_a_new_session()
|
||||
{
|
||||
// #05-T24: the store bounds concurrently-open sessions (each pins a
|
||||
// decrypted bundle in memory). The (cap+1)th Open is rejected; freeing a
|
||||
// slot re-admits a new session.
|
||||
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
|
||||
var opts = Options();
|
||||
var cap = opts.Value.MaxConcurrentImportSessions;
|
||||
var sut = new BundleSessionStore(opts, clock);
|
||||
var future = clock.GetUtcNow().AddMinutes(30);
|
||||
|
||||
var sessions = new List<BundleSession>();
|
||||
for (var i = 0; i < cap; i++)
|
||||
{
|
||||
var s = SessionExpiringAt(future);
|
||||
sut.Open(s);
|
||||
sessions.Add(s);
|
||||
}
|
||||
|
||||
var overflow = SessionExpiringAt(future);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => sut.Open(overflow));
|
||||
Assert.Contains("Too many concurrent import sessions", ex.Message, StringComparison.Ordinal);
|
||||
|
||||
// Free a slot → the previously-rejected session is admitted.
|
||||
sut.Remove(sessions[0].SessionId);
|
||||
sut.Open(overflow);
|
||||
Assert.NotNull(sut.Get(overflow.SessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_then_Get_returns_session()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
||||
@@ -189,4 +191,29 @@ public sealed class LineDifferTests
|
||||
Assert.Equal(0, result.AddedCount);
|
||||
Assert.Equal(0, result.RemovedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedInputs_ZeroCommonLines_DegradesToSummary_WithoutRunningMyers()
|
||||
{
|
||||
// Two 10,000-line inputs with zero common lines. Running the Myers trace here would
|
||||
// record ~20,000 V-snapshots of ~160 KB each (multi-GB transient allocation), so the
|
||||
// input-size guard must short-circuit to a summary-only result long before that.
|
||||
var oldText = string.Join("\n", Enumerable.Range(1, 10_000).Select(i => $"old-line-{i}"));
|
||||
var newText = string.Join("\n", Enumerable.Range(1, 10_000).Select(i => $"new-line-{i}"));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = LineDiffer.Diff(oldText, newText);
|
||||
sw.Stop();
|
||||
|
||||
// Summary-only shape: same as the output cap — Truncated, full totals, no hunks.
|
||||
Assert.True(result.Truncated);
|
||||
Assert.Empty(result.Lines);
|
||||
Assert.Equal(10_000, result.AddedCount);
|
||||
Assert.Equal(10_000, result.RemovedCount);
|
||||
|
||||
// Must return within a small time budget; pathological slowness is a failure.
|
||||
Assert.True(
|
||||
sw.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"Diff of oversized inputs took {sw.Elapsed.TotalSeconds:F1}s — the input-size guard did not short-circuit.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
@@ -158,6 +159,141 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("MotorA", rtComp.InstanceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_template_preserves_LockedInDerived_on_attribute_alarm_script()
|
||||
{
|
||||
var basic = new Template("Basic") { Id = 1 };
|
||||
basic.Attributes.Add(new TemplateAttribute("Pressure")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
DataType = DataType.Double,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
basic.Alarms.Add(new TemplateAlarm("High")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
|
||||
var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };
|
||||
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
// Export carries the flag on all three child DTOs.
|
||||
var dtoBasic = Assert.Single(dto.Templates);
|
||||
Assert.True(Assert.Single(dtoBasic.Attributes).LockedInDerived);
|
||||
Assert.True(Assert.Single(dtoBasic.Alarms).LockedInDerived);
|
||||
Assert.True(Assert.Single(dtoBasic.Scripts).LockedInDerived);
|
||||
|
||||
// FromBundleContent restores it onto the entities.
|
||||
var rtBasic = Assert.Single(sut.FromBundleContent(dto).Templates);
|
||||
Assert.True(Assert.Single(rtBasic.Attributes).LockedInDerived);
|
||||
Assert.True(Assert.Single(rtBasic.Alarms).LockedInDerived);
|
||||
Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_template_preserves_NativeAlarmSources()
|
||||
{
|
||||
var basic = new Template("Basic") { Id = 1 };
|
||||
basic.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
Description = "boiler alarms",
|
||||
ConnectionName = "OpcUaPrimary",
|
||||
SourceReference = "ns=3;s=Boiler.Alarm",
|
||||
ConditionFilter = "severity>500",
|
||||
IsLocked = true,
|
||||
IsInherited = true,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
|
||||
var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };
|
||||
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
var dtoSrc = Assert.Single(Assert.Single(dto.Templates).NativeAlarmSources);
|
||||
Assert.Equal("Boiler", dtoSrc.Name);
|
||||
Assert.Equal("OpcUaPrimary", dtoSrc.ConnectionName);
|
||||
Assert.Equal("ns=3;s=Boiler.Alarm", dtoSrc.SourceReference);
|
||||
Assert.Equal("severity>500", dtoSrc.ConditionFilter);
|
||||
Assert.True(dtoSrc.IsLocked);
|
||||
Assert.True(dtoSrc.IsInherited);
|
||||
Assert.True(dtoSrc.LockedInDerived);
|
||||
|
||||
var rtSrc = Assert.Single(Assert.Single(sut.FromBundleContent(dto).Templates).NativeAlarmSources);
|
||||
Assert.Equal("Boiler", rtSrc.Name);
|
||||
Assert.Equal("boiler alarms", rtSrc.Description);
|
||||
Assert.Equal("OpcUaPrimary", rtSrc.ConnectionName);
|
||||
Assert.Equal("ns=3;s=Boiler.Alarm", rtSrc.SourceReference);
|
||||
Assert.Equal("severity>500", rtSrc.ConditionFilter);
|
||||
Assert.True(rtSrc.IsLocked);
|
||||
Assert.True(rtSrc.IsInherited);
|
||||
Assert.True(rtSrc.LockedInDerived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false()
|
||||
{
|
||||
// A bundle written before LockedInDerived existed: the property is absent
|
||||
// from every template-child object. It must deserialize to false, not throw.
|
||||
// PascalCase keys + string enum names — the on-wire form BundleJsonOptions
|
||||
// produces (no naming policy, JsonStringEnumConverter, case-sensitive). All
|
||||
// required collections present; LockedInDerived absent on every child.
|
||||
const string json = """
|
||||
{
|
||||
"TemplateFolders": [],
|
||||
"Templates": [
|
||||
{
|
||||
"Name": "Legacy",
|
||||
"FolderName": null,
|
||||
"BaseTemplateName": null,
|
||||
"Description": null,
|
||||
"Attributes": [
|
||||
{ "Name": "A", "Value": "1", "DataType": "Double", "IsLocked": false,
|
||||
"Description": null, "DataSourceReference": null }
|
||||
],
|
||||
"Alarms": [
|
||||
{ "Name": "Alm", "Description": null, "PriorityLevel": 1,
|
||||
"TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false,
|
||||
"OnTriggerScriptName": null }
|
||||
],
|
||||
"Scripts": [
|
||||
{ "Name": "S", "Code": "return 1;", "TriggerType": null,
|
||||
"TriggerConfiguration": null, "ParameterDefinitions": null,
|
||||
"ReturnDefinition": null, "IsLocked": false }
|
||||
],
|
||||
"Compositions": []
|
||||
}
|
||||
],
|
||||
"SharedScripts": [],
|
||||
"ExternalSystems": [],
|
||||
"DatabaseConnections": [],
|
||||
"NotificationLists": [],
|
||||
"SmtpConfigs": [],
|
||||
"ApiMethods": []
|
||||
}
|
||||
""";
|
||||
|
||||
var content = JsonSerializer.Deserialize<BundleContentDto>(json, BundleJsonOptions.Default)!;
|
||||
var template = Assert.Single(content.Templates);
|
||||
Assert.False(Assert.Single(template.Attributes).LockedInDerived);
|
||||
Assert.False(Assert.Single(template.Alarms).LockedInDerived);
|
||||
Assert.False(Assert.Single(template.Scripts).LockedInDerived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_template_folder_preserves_hierarchy()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user