Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs
T
Joseph Doherty c60347c5e7 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
2026-07-10 02:12:26 -04:00

407 lines
18 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// <summary>
/// FU-C — integration tests for the two-tier semantic validation wired into
/// <see cref="BundleImporter.ApplyAsync"/>: Pass 1 is the minimal name-
/// resolution scan (carried forward from the v1 importer) and Pass 2 is the
/// full <c>SemanticValidator</c> over each imported template's
/// <c>FlattenedConfiguration</c>. Pass 1 fails fast — Pass 2 only runs when
/// Pass 1 succeeds — so the Pass 2 scenarios here are chosen to live entirely
/// in alarm shape (alarm JSON is not scanned by Pass 1).
/// <para>
/// The "invalid call target" test exercises Pass 1 because every
/// SemanticValidator call-target rule presupposes the called identifier is
/// already known to the script body's surface; an entirely-unknown identifier
/// surfaces at Pass 1 first by design. Both tiers throw the same
/// <see cref="SemanticValidationException"/> with errors propagated.
/// </para>
/// </summary>
public sealed class SemanticValidatorImportTests : IDisposable
{
private readonly ServiceProvider _provider;
public SemanticValidatorImportTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"SemanticValidatorImportTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
_provider = services.BuildServiceProvider();
}
public void Dispose() => _provider.Dispose();
/// <summary>
/// Export everything currently seeded, wipe the DB, then LoadAsync the
/// bundle. Returns the session id. Mirrors the helper in
/// <c>BundleImporterApplyTests</c> but exported as a free helper so each
/// test can seed its own template shape without sharing fixture state.
/// </summary>
private async Task<Guid> ExportWipeAndLoadAsync()
{
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();
// #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>(),
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);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
// Wipe so the apply is exercising the Add path.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
ctx.Templates.RemoveRange(ctx.Templates);
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
await ctx.SaveChangesAsync();
}
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var input = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(input, passphrase: null);
sessionId = session.SessionId;
}
return sessionId;
}
[Fact]
public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import()
{
// #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. 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>();
var t = new Template("ScriptCallsUnknown");
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
"init",
"var x = UnknownHelper();"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
// 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())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("ApiMethod", "Ingest", ResolutionAction.Add, null),
},
user: "bob"));
}
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors,
err => err.Contains("ErpMissing", StringComparison.Ordinal));
// Rollback — no ApiMethod row landed.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.ApiMethods.AnyAsync(m => m.Name == "Ingest"));
}
}
[Fact]
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
{
// Arrange — template with a String attribute Status and a
// RangeViolation alarm against it. The full SemanticValidator must
// report TriggerOperandType (RangeViolation requires numeric).
// Pass 1 doesn't scan alarm JSON, so the error reaches Pass 2.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("TankWithBadAlarm") { Description = "RangeViolation on string attr" };
t.Attributes.Add(new TemplateAttribute("Status")
{
DataType = DataType.String,
Value = "OK",
});
t.Alarms.Add(new TemplateAlarm("BadRange")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Status\",\"min\":0,\"max\":100}",
PriorityLevel = 1,
});
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
// Act
SemanticValidationException ex = default!;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "TankWithBadAlarm", ResolutionAction.Add, null),
},
user: "bob"));
}
// Assert — error names the offending alarm and the bad trigger
// type so the operator can locate the fix.
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors,
err => err.Contains("BadRange", StringComparison.Ordinal)
&& err.Contains("RangeViolation", StringComparison.Ordinal));
// Rollback — no template row landed.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "TankWithBadAlarm"));
}
}
[Fact]
public async Task Valid_bundle_passes_semantic_validation()
{
// Arrange — clean template that satisfies both passes: one Double
// attribute, one ValueMatch alarm on it, one script with no external
// call identifiers. ValueMatch doesn't constrain the operand data
// type (only RangeViolation / HiLo do), so this template's alarm is
// legal.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("CleanPump") { Description = "passes both passes" };
t.Attributes.Add(new TemplateAttribute("Speed")
{
DataType = DataType.Double,
Value = "0",
});
t.Alarms.Add(new TemplateAlarm("Overspeed")
{
TriggerType = AlarmTriggerType.ValueMatch,
TriggerConfiguration = "{\"attributeName\":\"Speed\",\"value\":100}",
PriorityLevel = 1,
});
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
"tick",
"// no external calls"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
// Act — happy-path import.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "CleanPump", ResolutionAction.Add, null),
},
user: "bob");
}
// Assert — template + alarm survived the round-trip.
Assert.Equal(1, result.Added);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "CleanPump"));
Assert.True(await ctx.TemplateAlarms.AnyAsync(a => a.Name == "Overspeed"));
}
}
}