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:
+20
-21
@@ -129,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,
|
||||
@@ -136,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);
|
||||
@@ -159,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();
|
||||
}
|
||||
|
||||
@@ -320,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();
|
||||
@@ -340,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"));
|
||||
}
|
||||
|
||||
@@ -488,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();
|
||||
@@ -502,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"));
|
||||
}
|
||||
|
||||
@@ -1400,14 +1400,13 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
||||
{
|
||||
// A template whose script calls an unresolved helper fails semantic
|
||||
// validation BEFORE commit, so nothing is published.
|
||||
// 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>();
|
||||
var t = new Template("Bad");
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = UnknownHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.ApiMethods.Add(new ApiMethod("Bad", "var x = UnknownHelper();"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -1417,13 +1416,13 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
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(),
|
||||
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: 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);
|
||||
@@ -1439,7 +1438,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Bad", ResolutionAction.Add, null) },
|
||||
new List<ImportResolution> { new("ApiMethod", "Bad", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
|
||||
+58
-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,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
||||
}
|
||||
|
||||
[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
|
||||
// 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
|
||||
// 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 +357,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 +366,64 @@ 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]
|
||||
|
||||
+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();
|
||||
}
|
||||
|
||||
|
||||
+139
-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,21 +275,20 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user