fix(transport): blocker heuristic no longer hard-blocks valid imports — local declarations excluded, denylist extended, template-script findings downgraded to warnings
#05-T19. Import name-resolution findings are now split by origin: template-script (and attribute-default-expression) references become non-blocking advisory warnings (ConflictKind.Warning + ImportResult.Warnings) since the design-time deploy gate re-validates authoritatively; ApiMethod-script references stay hard errors (SemanticValidationException / ConflictKind.Blocker) as they have no downstream gate. Names declared locally in a body (Roslyn-parsed local functions / methods) are excluded so a script's own helper isn't flagged; KnownNonReferenceNames extended with common BCL/LINQ/SQL surface. DetectBlockersAsync and RunSemanticValidationAsync Pass 1 apply the identical split so preview and apply agree. Rollback/hard-fail tests retargeted to ApiMethods (the still-hard-failing vehicle). CentralUI preview renders the new Warning badge/advisory. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -25,7 +25,7 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
|||||||
- 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.
|
- 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.
|
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
||||||
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
||||||
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, or a Blocker.
|
- 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 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`.
|
- 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`.
|
||||||
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, `BundleImportCompositionUnresolved`, and `BundleImportBaseTemplateUnresolved` audit events via `IAuditService`.
|
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, `BundleImportCompositionUnresolved`, and `BundleImportBaseTemplateUnresolved` audit events via `IAuditService`.
|
||||||
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
|
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
|
||||||
@@ -227,7 +227,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.
|
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).
|
**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).
|
||||||
|
|
||||||
@@ -307,7 +309,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` |
|
| 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 | 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 |
|
| 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 | 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 |
|
| 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 |
|
| Session | TTL expired | Diff step onward, refresh prompts re-upload |
|
||||||
|
|||||||
@@ -275,6 +275,14 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -421,6 +429,7 @@
|
|||||||
ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
|
ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
|
||||||
ConflictKind.New => ("bg-success", "New"),
|
ConflictKind.New => ("bg-success", "New"),
|
||||||
ConflictKind.Blocker => ("bg-danger", "Blocker"),
|
ConflictKind.Blocker => ("bg-danger", "Blocker"),
|
||||||
|
ConflictKind.Warning => ("bg-warning text-dark", "Warning"),
|
||||||
_ => ("bg-light text-dark", item.Kind.ToString()),
|
_ => ("bg-light text-dark", item.Kind.ToString()),
|
||||||
};
|
};
|
||||||
<span class="badge @cls">@label</span>
|
<span class="badge @cls">@label</span>
|
||||||
@@ -444,6 +453,13 @@
|
|||||||
<span class="text-muted small">—</span>
|
<span class="text-muted small">—</span>
|
||||||
return;
|
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);
|
var key = (item.EntityType, item.Name);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
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(
|
public sealed record ImportPreviewItem(
|
||||||
string EntityType,
|
string EntityType,
|
||||||
|
|||||||
@@ -11,4 +11,15 @@ public sealed record ImportResult(
|
|||||||
// Number of legacy inbound API keys found in the bundle that were ignored
|
// Number of legacy inbound API keys found in the bundle that were ignored
|
||||||
// (keys are not transported; re-create them on this environment).
|
// (keys are not transported; re-create them on this environment).
|
||||||
// Defaults to 0 so existing positional construction sites stay source-compatible.
|
// 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>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -779,10 +782,22 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// Name is a valid identifier, if Name appears in NEITHER set, surface
|
// Name is a valid identifier, if Name appears in NEITHER set, surface
|
||||||
// it as a Blocker. This catches the documented use-case
|
// it as a Blocker. This catches the documented use-case
|
||||||
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
|
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
|
||||||
var referencedFromBundle = new HashSet<string>(StringComparer.Ordinal);
|
// #05-T19 severity split: track candidate references by ORIGIN. Template-
|
||||||
|
// script (and attribute-default-expression) references are advisory
|
||||||
|
// warnings — the design-time deploy gate re-validates them; ApiMethod
|
||||||
|
// references are hard blockers (no downstream gate). Local-function /
|
||||||
|
// method declarations in a body are excluded so a script's own helper
|
||||||
|
// isn't mistaken for a missing SharedScript.
|
||||||
|
var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var locallyDeclared = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var t in content.Templates)
|
foreach (var t in content.Templates)
|
||||||
{
|
{
|
||||||
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referencedFromBundle);
|
foreach (var s in t.Scripts)
|
||||||
|
{
|
||||||
|
CollectCallIdentifiers(s.Code, referencedFromTemplates);
|
||||||
|
CollectLocalDeclarations(s.Code, locallyDeclared);
|
||||||
|
}
|
||||||
foreach (var a in t.Attributes)
|
foreach (var a in t.Attributes)
|
||||||
{
|
{
|
||||||
// Attribute.Value carries the design-time default expression, which
|
// Attribute.Value carries the design-time default expression, which
|
||||||
@@ -791,37 +806,44 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// it's never script source and must NOT be scanned, or the dot
|
// it's never script source and must NOT be scanned, or the dot
|
||||||
// delimiter trips the heuristic into flagging the address segments
|
// delimiter trips the heuristic into flagging the address segments
|
||||||
// as missing SharedScript/ExternalSystem references.
|
// as missing SharedScript/ExternalSystem references.
|
||||||
CollectCallIdentifiers(a.Value, referencedFromBundle);
|
CollectCallIdentifiers(a.Value, referencedFromTemplates);
|
||||||
|
CollectLocalDeclarations(a.Value, locallyDeclared);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (var m in content.ApiMethods)
|
foreach (var m in content.ApiMethods)
|
||||||
{
|
{
|
||||||
CollectCallIdentifiers(m.Script, referencedFromBundle);
|
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
|
||||||
|
CollectLocalDeclarations(m.Script, locallyDeclared);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each candidate, only report it as a blocker if it looks like a
|
// For each candidate, only report it if it looks like a resource
|
||||||
// resource reference (PascalCase, length > 1), isn't a well-known
|
// reference (PascalCase, length > 1), isn't a well-known language /
|
||||||
// language / runtime / SQL token, AND isn't present anywhere we can
|
// runtime / SQL token, isn't the body's own local declaration, AND
|
||||||
// satisfy it. The denylist is the noise filter that keeps the
|
// isn't present anywhere we can satisfy it. A candidate that appears in
|
||||||
// heuristic usable on real script bodies — without it, every member
|
// an ApiMethod script is a hard Blocker; a template-only candidate is a
|
||||||
// access (`obj.ToString()`) and stdlib type (`DateTimeOffset`) gets
|
// non-blocking Warning.
|
||||||
// flagged.
|
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
|
||||||
foreach (var candidate in referencedFromBundle.OrderBy(n => n, StringComparer.Ordinal))
|
allCandidates.UnionWith(referencedFromApiMethods);
|
||||||
|
foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
if (!LooksLikeResourceName(candidate)) continue;
|
if (!LooksLikeResourceName(candidate)) continue;
|
||||||
if (KnownNonReferenceNames.Contains(candidate)) continue;
|
if (KnownNonReferenceNames.Contains(candidate)) continue;
|
||||||
|
if (locallyDeclared.Contains(candidate)) continue;
|
||||||
var isShared = sharedScriptNames.Contains(candidate);
|
var isShared = sharedScriptNames.Contains(candidate);
|
||||||
var isExternal = externalSystemNames.Contains(candidate);
|
var isExternal = externalSystemNames.Contains(candidate);
|
||||||
if (isShared || isExternal) continue;
|
if (isShared || isExternal) continue;
|
||||||
|
|
||||||
|
var fromApiMethod = referencedFromApiMethods.Contains(candidate);
|
||||||
blockers.Add(new ImportPreviewItem(
|
blockers.Add(new ImportPreviewItem(
|
||||||
EntityType: "Reference",
|
EntityType: "Reference",
|
||||||
Name: candidate,
|
Name: candidate,
|
||||||
ExistingVersion: null,
|
ExistingVersion: null,
|
||||||
IncomingVersion: null,
|
IncomingVersion: null,
|
||||||
Kind: ConflictKind.Blocker,
|
Kind: fromApiMethod ? ConflictKind.Blocker : ConflictKind.Warning,
|
||||||
FieldDiffJson: null,
|
FieldDiffJson: null,
|
||||||
BlockerReason: $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."));
|
BlockerReason: fromApiMethod
|
||||||
|
? $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."
|
||||||
|
: $"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; the deploy-time gate re-validates."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return blockers;
|
return blockers;
|
||||||
@@ -850,6 +872,40 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects the names of every local function / method DECLARED inside a
|
||||||
|
/// script body, so the reference heuristic doesn't mistake a script's own
|
||||||
|
/// helper (<c>decimal ComputeRate(...) => ...; return ComputeRate(x);</c>)
|
||||||
|
/// for a missing SharedScript / ExternalSystem. Uses a Roslyn script-kind
|
||||||
|
/// parse (partial trees are fine — a body with syntax errors still yields
|
||||||
|
/// the declarations Roslyn could recover). Purely additive noise-suppression:
|
||||||
|
/// on any parse failure the sink is simply left unchanged.
|
||||||
|
/// </summary>
|
||||||
|
private static void CollectLocalDeclarations(string? body, HashSet<string> sink)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(body)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = CSharpSyntaxTree
|
||||||
|
.ParseText(body, new CSharpParseOptions(kind: SourceCodeKind.Script))
|
||||||
|
.GetRoot();
|
||||||
|
foreach (var fn in root.DescendantNodes().OfType<LocalFunctionStatementSyntax>())
|
||||||
|
{
|
||||||
|
sink.Add(fn.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
foreach (var m in root.DescendantNodes().OfType<MethodDeclarationSyntax>())
|
||||||
|
{
|
||||||
|
sink.Add(m.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort: a parse failure just means no declarations are
|
||||||
|
// excluded for this body — the finding (at worst) surfaces as an
|
||||||
|
// advisory warning, never a hard error for template scripts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Names that look like PascalCase references but are never user-defined
|
/// Names that look like PascalCase references but are never user-defined
|
||||||
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
|
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
|
||||||
@@ -877,7 +933,20 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
|
|
||||||
// SQL keywords commonly seen inside string literals
|
// SQL keywords commonly seen inside string literals
|
||||||
"COUNT", "FROM", "GROUP", "INSERT", "JOIN", "ORDER", "SELECT",
|
"COUNT", "FROM", "GROUP", "INSERT", "JOIN", "ORDER", "SELECT",
|
||||||
"UPDATE", "WHERE",
|
"UPDATE", "WHERE", "HAVING", "VALUES", "DELETE", "DISTINCT", "LIMIT",
|
||||||
|
|
||||||
|
// #05-T19 — extended stdlib / BCL surface commonly reached from scripts.
|
||||||
|
// The list will still drift as scripts use more of the BCL — that is
|
||||||
|
// precisely why template-script findings are downgraded to warnings
|
||||||
|
// (the deploy-time gate re-validates authoritatively).
|
||||||
|
"Regex", "Match", "Matches", "IsMatch", "Replace", "Split",
|
||||||
|
"StringBuilder", "Append", "AppendLine", "Parse", "TryParse",
|
||||||
|
"Format", "Join", "Abs", "Round", "Min", "Max", "Floor", "Ceiling",
|
||||||
|
"Pow", "Sqrt", "Json", "Serialize", "Deserialize", "Where", "Select",
|
||||||
|
"First", "FirstOrDefault", "Any", "All", "Count", "Sum", "Average",
|
||||||
|
"OrderBy", "OrderByDescending", "GroupBy", "Distinct", "Add", "Remove",
|
||||||
|
"Clear", "Contains", "ContainsKey", "TryGetValue", "StartsWith",
|
||||||
|
"EndsWith", "Substring", "Trim", "ToUpper", "ToLower",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static bool LooksLikeResourceName(string name)
|
private static bool LooksLikeResourceName(string name)
|
||||||
@@ -982,7 +1051,8 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// Skip-resolved DTOs are excluded from the in-bundle name set so
|
// Skip-resolved DTOs are excluded from the in-bundle name set so
|
||||||
// a Skip on a dependency surfaces as a missing-reference error
|
// a Skip on a dependency surfaces as a missing-reference error
|
||||||
// rather than silently passing.
|
// rather than silently passing.
|
||||||
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
var (validationErrors, validationWarnings) =
|
||||||
|
await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
||||||
// Validate every site / connection / template reference the
|
// Validate every site / connection / template reference the
|
||||||
// site-instance payload depends on BEFORE any row is staged. Running
|
// site-instance payload depends on BEFORE any row is staged. Running
|
||||||
// this in the validation phase (not as an apply-pass guard) preserves
|
// this in the validation phase (not as an apply-pass guard) preserves
|
||||||
@@ -999,6 +1069,17 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
throw new SemanticValidationException(validationErrors);
|
throw new SemanticValidationException(validationErrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advisory template-script reference findings never block the import;
|
||||||
|
// log them and surface them on the ImportResult so the operator sees
|
||||||
|
// the same advisory the preview showed. The deploy-time gate is the
|
||||||
|
// authoritative re-validation.
|
||||||
|
if (validationWarnings.Count > 0)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(
|
||||||
|
"Bundle import {BundleImportId}: {Count} advisory template-script reference warning(s): {Warnings}",
|
||||||
|
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Site/instance-scoped apply: sites + connections FIRST ----
|
// ---- Site/instance-scoped apply: sites + connections FIRST ----
|
||||||
// Sites are the FK target for both data connections (SiteId) and
|
// Sites are the FK target for both data connections (SiteId) and
|
||||||
// instances (SiteId); data connections are the FK target for instance
|
// instances (SiteId); data connections are the FK target for instance
|
||||||
@@ -1152,7 +1233,8 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
Renamed: summary.Renamed,
|
Renamed: summary.Renamed,
|
||||||
StaleInstanceIds: staleInstanceIds,
|
StaleInstanceIds: staleInstanceIds,
|
||||||
AuditEventCorrelation: bundleImportId.ToString(),
|
AuditEventCorrelation: bundleImportId.ToString(),
|
||||||
ApiKeysIgnored: apiKeysIgnored);
|
ApiKeysIgnored: apiKeysIgnored,
|
||||||
|
Warnings: validationWarnings);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -4086,12 +4168,13 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// operator isn't trying to import) would block the import.
|
/// operator isn't trying to import) would block the import.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
|
private async Task<(IReadOnlyList<string> Errors, IReadOnlyList<string> Warnings)> RunSemanticValidationAsync(
|
||||||
BundleContentDto content,
|
BundleContentDto content,
|
||||||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
var warnings = new List<string>();
|
||||||
|
|
||||||
// ---- Pass 1: minimal name-resolution scan ----
|
// ---- Pass 1: minimal name-resolution scan ----
|
||||||
|
|
||||||
@@ -4135,17 +4218,25 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Collect every identifier-shaped call target from the bundle's
|
// Collect every identifier-shaped call target from the bundle's
|
||||||
// templates + api methods. We only check the bundle's bodies here
|
// templates + api methods, keyed by ORIGIN (#05-T19 severity split).
|
||||||
// (matching PreviewAsync's blocker scan); pre-existing target rows are
|
// We only check the bundle's bodies here (matching PreviewAsync's blocker
|
||||||
// assumed already validated when they were originally written.
|
// scan); pre-existing target rows are assumed already validated when they
|
||||||
var referenced = new HashSet<string>(StringComparer.Ordinal);
|
// were originally written. Local-function / method declarations in a body
|
||||||
|
// are collected too so a script's own helper isn't flagged as missing.
|
||||||
|
var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var locallyDeclared = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var t in content.Templates)
|
foreach (var t in content.Templates)
|
||||||
{
|
{
|
||||||
// Skip-resolved templates aren't being written, so their script
|
// Skip-resolved templates aren't being written, so their script
|
||||||
// references don't need to resolve.
|
// references don't need to resolve.
|
||||||
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
|
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
|
||||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||||
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referenced);
|
foreach (var s in t.Scripts)
|
||||||
|
{
|
||||||
|
CollectCallIdentifiers(s.Code, referencedFromTemplates);
|
||||||
|
CollectLocalDeclarations(s.Code, locallyDeclared);
|
||||||
|
}
|
||||||
foreach (var a in t.Attributes)
|
foreach (var a in t.Attributes)
|
||||||
{
|
{
|
||||||
// Value can hold script-callable design-time expressions;
|
// Value can hold script-callable design-time expressions;
|
||||||
@@ -4153,29 +4244,46 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// "ns=3;s=Tank.Level") and must NOT be scanned, or the dot
|
// "ns=3;s=Tank.Level") and must NOT be scanned, or the dot
|
||||||
// delimiter will flag tag-path segments as missing references.
|
// delimiter will flag tag-path segments as missing references.
|
||||||
// Symmetric with DetectBlockersAsync.
|
// Symmetric with DetectBlockersAsync.
|
||||||
CollectCallIdentifiers(a.Value, referenced);
|
CollectCallIdentifiers(a.Value, referencedFromTemplates);
|
||||||
|
CollectLocalDeclarations(a.Value, locallyDeclared);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (var m in content.ApiMethods)
|
foreach (var m in content.ApiMethods)
|
||||||
{
|
{
|
||||||
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
|
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
|
||||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||||
CollectCallIdentifiers(m.Script, referenced);
|
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
|
||||||
|
CollectLocalDeclarations(m.Script, locallyDeclared);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var candidate in referenced.OrderBy(n => n, StringComparer.Ordinal))
|
// ApiMethod references are hard errors (no downstream deploy gate);
|
||||||
|
// template-only references are advisory warnings (the design-time deploy
|
||||||
|
// gate re-validates with a real compile).
|
||||||
|
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
|
||||||
|
allCandidates.UnionWith(referencedFromApiMethods);
|
||||||
|
foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
if (!LooksLikeResourceName(candidate)) continue;
|
if (!LooksLikeResourceName(candidate)) continue;
|
||||||
if (KnownNonReferenceNames.Contains(candidate)) continue;
|
if (KnownNonReferenceNames.Contains(candidate)) continue;
|
||||||
|
if (locallyDeclared.Contains(candidate)) continue;
|
||||||
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
|
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
|
||||||
|
if (referencedFromApiMethods.Contains(candidate))
|
||||||
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
|
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
warnings.Add(
|
||||||
|
$"Template script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; re-validated at deploy time.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fail fast — running the full validator over templates that already
|
// Fail fast — running the full validator over templates that already
|
||||||
// failed name resolution would produce duplicate / lower-quality errors
|
// failed name resolution would produce duplicate / lower-quality errors
|
||||||
// (the missing identifier shows up there as "callee not found" too).
|
// (the missing identifier shows up there as "callee not found" too).
|
||||||
if (errors.Count > 0) return errors;
|
// Warnings ride along regardless — they never gate the import.
|
||||||
|
if (errors.Count > 0) return (errors, warnings);
|
||||||
|
|
||||||
// ---- Pass 2: full SemanticValidator over imported templates ----
|
// ---- Pass 2: full SemanticValidator over imported templates ----
|
||||||
|
|
||||||
@@ -4230,7 +4338,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return (errors, warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+20
-21
@@ -129,6 +129,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||||
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
|
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
|
||||||
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.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(
|
var selection = new ExportSelection(
|
||||||
TemplateIds: templateIds,
|
TemplateIds: templateIds,
|
||||||
SharedScriptIds: sharedScriptIds,
|
SharedScriptIds: sharedScriptIds,
|
||||||
@@ -136,7 +137,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
DatabaseConnectionIds: Array.Empty<int>(),
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
NotificationListIds: Array.Empty<int>(),
|
NotificationListIds: Array.Empty<int>(),
|
||||||
SmtpConfigurationIds: Array.Empty<int>(),
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
ApiMethodIds: Array.Empty<int>(),
|
ApiMethodIds: apiMethodIds,
|
||||||
IncludeDependencies: false);
|
IncludeDependencies: false);
|
||||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||||
passphrase: null, cancellationToken: CancellationToken.None);
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
@@ -159,6 +160,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
ctx.Templates.RemoveRange(ctx.Templates);
|
ctx.Templates.RemoveRange(ctx.Templates);
|
||||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||||
|
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,15 +322,15 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
|
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
|
// 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())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var t = new Template("BrokenPump") { Description = "broken" };
|
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
|
||||||
ctx.Templates.Add(t);
|
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
var sessionId = await ExportAndLoadAsync();
|
var sessionId = await ExportAndLoadAsync();
|
||||||
@@ -340,16 +342,16 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||||
importer.ApplyAsync(sessionId,
|
importer.ApplyAsync(sessionId,
|
||||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
||||||
user: "bob"));
|
user: "bob"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assert — target still wiped (template not committed), AND a
|
// Assert — target still wiped (ApiMethod not committed), AND a
|
||||||
// BundleImportFailed row exists.
|
// BundleImportFailed row exists.
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
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"));
|
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,9 +490,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var t = new Template("BrokenPump") { Description = "broken" };
|
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
|
||||||
ctx.Templates.Add(t);
|
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
var sessionId = await ExportAndLoadAsync();
|
var sessionId = await ExportAndLoadAsync();
|
||||||
@@ -502,7 +502,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||||
importer.ApplyAsync(sessionId,
|
importer.ApplyAsync(sessionId,
|
||||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
||||||
user: "bob"));
|
user: "bob"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1400,14 +1400,13 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
||||||
{
|
{
|
||||||
// A template whose script calls an unresolved helper fails semantic
|
// An ApiMethod whose script calls an unresolved helper fails semantic
|
||||||
// validation BEFORE commit, so nothing is published.
|
// validation BEFORE commit (ApiMethod findings are hard errors), so
|
||||||
|
// nothing is published.
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var t = new Template("Bad");
|
ctx.ApiMethods.Add(new ApiMethod("Bad", "var x = UnknownHelper();"));
|
||||||
t.Scripts.Add(new TemplateScript("init", "var x = UnknownHelper();"));
|
|
||||||
ctx.Templates.Add(t);
|
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,13 +1416,13 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var selection = new ExportSelection(
|
var selection = new ExportSelection(
|
||||||
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
TemplateIds: Array.Empty<int>(),
|
||||||
SharedScriptIds: Array.Empty<int>(),
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
ExternalSystemIds: Array.Empty<int>(),
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
DatabaseConnectionIds: Array.Empty<int>(),
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
NotificationListIds: Array.Empty<int>(),
|
NotificationListIds: Array.Empty<int>(),
|
||||||
SmtpConfigurationIds: Array.Empty<int>(),
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
ApiMethodIds: Array.Empty<int>(),
|
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
|
||||||
IncludeDependencies: false);
|
IncludeDependencies: false);
|
||||||
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||||
passphrase: null, cancellationToken: CancellationToken.None);
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
@@ -1439,7 +1438,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||||
importer.ApplyAsync(sessionId,
|
importer.ApplyAsync(sessionId,
|
||||||
new List<ImportResolution> { new("Template", "Bad", ResolutionAction.Add, null) },
|
new List<ImportResolution> { new("ApiMethod", "Bad", ResolutionAction.Add, null) },
|
||||||
user: "bob"));
|
user: "bob"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+58
-22
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
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.Instances;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
@@ -330,7 +331,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing()
|
public async Task PreviewAsync_emits_Warning_when_template_script_dependency_missing()
|
||||||
{
|
{
|
||||||
// Arrange: seed a template whose script body calls MissingHelper(), and
|
// Arrange: seed a template whose script body calls MissingHelper(), and
|
||||||
// an unrelated HelperFn() shared script that *is* defined but isn't the
|
// an unrelated HelperFn() shared script that *is* defined but isn't the
|
||||||
@@ -338,10 +339,9 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
// selection that only pulls the template — the bundle won't carry
|
// selection that only pulls the template — the bundle won't carry
|
||||||
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
|
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
|
||||||
//
|
//
|
||||||
// To get MissingHelper into the bundle script body without the export
|
// Under #05-T19 a TEMPLATE-script name-resolution finding is ADVISORY
|
||||||
// resolver pulling it in (it can't — it doesn't exist), we just seed
|
// (the design-time deploy gate re-validates it), so the preview surfaces
|
||||||
// the template with a script that mentions it; the resolver scan only
|
// it as ConflictKind.Warning — NOT a Blocker.
|
||||||
// matters for entity discovery, the body text is preserved verbatim.
|
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
@@ -357,18 +357,6 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
var bundleStream = await ExportTemplatesAsync();
|
var bundleStream = await ExportTemplatesAsync();
|
||||||
var bytes = await StreamToBytes(bundleStream);
|
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
|
// Act
|
||||||
ImportPreview preview;
|
ImportPreview preview;
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
@@ -378,16 +366,64 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
preview = await importer.PreviewAsync(session.SessionId);
|
preview = await importer.PreviewAsync(session.SessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assert: there's at least one Blocker, and the MissingHelper one is in there.
|
// Assert: MissingHelper surfaces as a Warning (advisory), not a Blocker.
|
||||||
Assert.Contains(preview.Items, i => i.Kind == ConflictKind.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 =>
|
Assert.Contains(preview.Items, i =>
|
||||||
i.Kind == ConflictKind.Blocker
|
i.Kind == ConflictKind.Blocker
|
||||||
&& i.Name == "MissingHelper"
|
&& i.Name == "MissingHelper"
|
||||||
&& i.BlockerReason is not null
|
&& i.BlockerReason is not null
|
||||||
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
|
&& 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]
|
[Fact]
|
||||||
|
|||||||
+11
-10
@@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
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.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
@@ -108,17 +109,15 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws()
|
public async Task ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws()
|
||||||
{
|
{
|
||||||
// Arrange: seed a template whose script body references MissingHelper()
|
// Arrange: seed an ApiMethod whose script body references MissingHelper()
|
||||||
// so semantic validation will reject the apply (same broken-bundle shape
|
// so semantic validation will reject the apply (ApiMethod findings are
|
||||||
// as BundleImporterApplyTests.ApplyAsync_rolls_back_all_changes_…). Then
|
// hard errors; same broken-bundle shape as BundleImporterApplyTests.
|
||||||
// arm the interceptor to throw on rollback so the catch path has to
|
// ApplyAsync_rolls_back_all_changes_…). Then arm the interceptor to throw
|
||||||
// survive a rollback failure.
|
// on rollback so the catch path has to survive a rollback failure.
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var t = new Template("BrokenPump") { Description = "broken" };
|
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
|
||||||
ctx.Templates.Add(t);
|
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
|||||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
thrown = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
thrown = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||||
importer.ApplyAsync(sessionId,
|
importer.ApplyAsync(sessionId,
|
||||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
||||||
user: "bob"));
|
user: "bob"));
|
||||||
}
|
}
|
||||||
Assert.NotNull(thrown);
|
Assert.NotNull(thrown);
|
||||||
@@ -177,6 +176,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
|||||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||||
|
var apiMethodIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
||||||
var selection = new ExportSelection(
|
var selection = new ExportSelection(
|
||||||
TemplateIds: templateIds,
|
TemplateIds: templateIds,
|
||||||
SharedScriptIds: Array.Empty<int>(),
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
@@ -184,7 +184,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
|||||||
DatabaseConnectionIds: Array.Empty<int>(),
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
NotificationListIds: Array.Empty<int>(),
|
NotificationListIds: Array.Empty<int>(),
|
||||||
SmtpConfigurationIds: Array.Empty<int>(),
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
ApiMethodIds: Array.Empty<int>(),
|
ApiMethodIds: apiMethodIds,
|
||||||
IncludeDependencies: false);
|
IncludeDependencies: false);
|
||||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||||
passphrase: null, cancellationToken: CancellationToken.None);
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
@@ -207,6 +207,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
|||||||
ctx.Templates.RemoveRange(ctx.Templates);
|
ctx.Templates.RemoveRange(ctx.Templates);
|
||||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||||
|
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+139
-15
@@ -77,6 +77,10 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
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(
|
var selection = new ExportSelection(
|
||||||
TemplateIds: ids,
|
TemplateIds: ids,
|
||||||
SharedScriptIds: Array.Empty<int>(),
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
@@ -84,7 +88,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
DatabaseConnectionIds: Array.Empty<int>(),
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
NotificationListIds: Array.Empty<int>(),
|
NotificationListIds: Array.Empty<int>(),
|
||||||
SmtpConfigurationIds: Array.Empty<int>(),
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
ApiMethodIds: Array.Empty<int>(),
|
ApiMethodIds: apiIds,
|
||||||
IncludeDependencies: false);
|
IncludeDependencies: false);
|
||||||
|
|
||||||
var stream = await exporter.ExportAsync(selection,
|
var stream = await exporter.ExportAsync(selection,
|
||||||
@@ -103,6 +107,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||||
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
|
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
|
||||||
ctx.Templates.RemoveRange(ctx.Templates);
|
ctx.Templates.RemoveRange(ctx.Templates);
|
||||||
|
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,15 +123,14 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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
|
// PascalCase identifier that doesn't resolve to any SharedScript or
|
||||||
// ExternalSystem in the bundle or the target. This is the operator-
|
// ExternalSystem in the bundle or the target. Under the severity split,
|
||||||
// facing "invalid call target" surface — the full SemanticValidator's
|
// template-script name-resolution findings are ADVISORY (the design-time
|
||||||
// CallScript/CallShared signature checks live downstream of name
|
// deploy gate re-validates authoritatively), so the import SUCCEEDS with
|
||||||
// resolution (you can't check arg count against a function that
|
// the finding surfaced on ImportResult.Warnings — it does NOT hard-block.
|
||||||
// doesn't exist). Pass 1 catches it first and fails fast.
|
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
@@ -140,8 +144,129 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
|
|
||||||
var sessionId = await ExportWipeAndLoadAsync();
|
var sessionId = await ExportWipeAndLoadAsync();
|
||||||
|
|
||||||
// Act — apply must throw SemanticValidationException carrying the bad
|
// Act — apply succeeds (no throw); the finding rides on Warnings.
|
||||||
// call target by name.
|
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!;
|
SemanticValidationException ex = default!;
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
@@ -150,21 +275,20 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
importer.ApplyAsync(sessionId,
|
importer.ApplyAsync(sessionId,
|
||||||
new List<ImportResolution>
|
new List<ImportResolution>
|
||||||
{
|
{
|
||||||
new("Template", "ScriptCallsUnknown", ResolutionAction.Add, null),
|
new("ApiMethod", "Ingest", ResolutionAction.Add, null),
|
||||||
},
|
},
|
||||||
user: "bob"));
|
user: "bob"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assert — error message names the bad target.
|
|
||||||
Assert.NotEmpty(ex.Errors);
|
Assert.NotEmpty(ex.Errors);
|
||||||
Assert.Contains(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())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
@@ -57,37 +58,38 @@ public sealed class ValidationFailureTests : IDisposable
|
|||||||
public async Task Semantic_validation_failure_rolls_back_all_writes()
|
public async Task Semantic_validation_failure_rolls_back_all_writes()
|
||||||
{
|
{
|
||||||
// Arrange:
|
// 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
|
// a SharedScript that is NOT in the bundle (we don't seed one) and
|
||||||
// also NOT in the target after the wipe.
|
// also NOT in the target after the wipe. ApiMethod name-resolution
|
||||||
// - Export the template only (no helper alongside).
|
// 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.
|
// - Wipe the target so the apply runs through Add-paths.
|
||||||
// - Snapshot row counts so the rollback assertion is unambiguous.
|
// - Snapshot row counts so the rollback assertion is unambiguous.
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
var t = new Template("BrokenPump") { Description = "calls a missing helper" };
|
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
||||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
|
||||||
ctx.Templates.Add(t);
|
|
||||||
await ctx.SaveChangesAsync();
|
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.
|
// MissingHelper is NOT pulled in even if one happened to exist.
|
||||||
byte[] bundleBytes;
|
byte[] bundleBytes;
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
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(
|
var selection = new ExportSelection(
|
||||||
TemplateIds: ids,
|
TemplateIds: Array.Empty<int>(),
|
||||||
SharedScriptIds: Array.Empty<int>(),
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
ExternalSystemIds: Array.Empty<int>(),
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
DatabaseConnectionIds: Array.Empty<int>(),
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
NotificationListIds: Array.Empty<int>(),
|
NotificationListIds: Array.Empty<int>(),
|
||||||
SmtpConfigurationIds: Array.Empty<int>(),
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
ApiMethodIds: Array.Empty<int>(),
|
ApiMethodIds: ids,
|
||||||
IncludeDependencies: false);
|
IncludeDependencies: false);
|
||||||
|
|
||||||
var stream = await exporter.ExportAsync(selection,
|
var stream = await exporter.ExportAsync(selection,
|
||||||
@@ -103,21 +105,20 @@ public sealed class ValidationFailureTests : IDisposable
|
|||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
||||||
ctx.Templates.RemoveRange(ctx.Templates);
|
|
||||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
int templatesBefore;
|
int apiMethodsBefore;
|
||||||
int sharedScriptsBefore;
|
int sharedScriptsBefore;
|
||||||
await using (var scope = _provider.CreateAsyncScope())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
templatesBefore = await ctx.Templates.CountAsync();
|
apiMethodsBefore = await ctx.ApiMethods.CountAsync();
|
||||||
sharedScriptsBefore = await ctx.SharedScripts.CountAsync();
|
sharedScriptsBefore = await ctx.SharedScripts.CountAsync();
|
||||||
}
|
}
|
||||||
Assert.Equal(0, templatesBefore);
|
Assert.Equal(0, apiMethodsBefore);
|
||||||
Assert.Equal(0, sharedScriptsBefore);
|
Assert.Equal(0, sharedScriptsBefore);
|
||||||
|
|
||||||
// Load the bundle into a session.
|
// Load the bundle into a session.
|
||||||
@@ -139,7 +140,7 @@ public sealed class ValidationFailureTests : IDisposable
|
|||||||
importer.ApplyAsync(sessionId,
|
importer.ApplyAsync(sessionId,
|
||||||
new List<ImportResolution>
|
new List<ImportResolution>
|
||||||
{
|
{
|
||||||
new("Template", "BrokenPump", ResolutionAction.Add, null),
|
new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null),
|
||||||
},
|
},
|
||||||
user: "bob"));
|
user: "bob"));
|
||||||
}
|
}
|
||||||
@@ -150,11 +151,11 @@ public sealed class ValidationFailureTests : IDisposable
|
|||||||
Assert.Contains(ex.Errors,
|
Assert.Contains(ex.Errors,
|
||||||
err => err.Contains("MissingHelper", StringComparison.Ordinal));
|
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())
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
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());
|
Assert.Equal(sharedScriptsBefore, await ctx.SharedScripts.CountAsync());
|
||||||
|
|
||||||
// 3. A BundleImportFailed audit row exists. The BundleImporter
|
// 3. A BundleImportFailed audit row exists. The BundleImporter
|
||||||
|
|||||||
Reference in New Issue
Block a user