741 lines
32 KiB
C#
741 lines
32 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds a template + site + instance whose single alarm override carries the
|
|
/// given <paramref name="triggerConfigJson"/> as its
|
|
/// <c>TriggerConfigurationOverride</c>, so an export→load carries the instance
|
|
/// alarm-override trigger expression through the trust gate.
|
|
/// </summary>
|
|
private async Task SeedInstanceAlarmOverrideAsync(string triggerConfigJson)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
|
|
var template = new Template("Pump") { Description = "pump tpl" };
|
|
ctx.Templates.Add(template);
|
|
|
|
var site = new Site("Plant 1", "plant-1")
|
|
{
|
|
NodeAAddress = "akka://site@10.0.0.1:2552",
|
|
NodeBAddress = "akka://site@10.0.0.2:2552",
|
|
GrpcNodeAAddress = "10.0.0.1:8083",
|
|
GrpcNodeBAddress = "10.0.0.2:8083",
|
|
};
|
|
ctx.Sites.Add(site);
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var instance = new Instance("Pump-01")
|
|
{
|
|
TemplateId = template.Id,
|
|
SiteId = site.Id,
|
|
State = InstanceState.Enabled,
|
|
};
|
|
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
|
|
{
|
|
TriggerConfigurationOverride = triggerConfigJson,
|
|
});
|
|
ctx.Instances.Add(instance);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>Exports every seeded site (+ template) closure into a bundle and loads it.</summary>
|
|
private async Task<Guid> ExportSitesAndLoadAsync()
|
|
{
|
|
byte[] bundleBytes;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
|
SharedScriptIds: Array.Empty<int>(),
|
|
ExternalSystemIds: Array.Empty<int>(),
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: Array.Empty<int>(),
|
|
IncludeDependencies: true,
|
|
SiteIds: siteIds);
|
|
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
using var ms = new MemoryStream();
|
|
await stream.CopyToAsync(ms);
|
|
bundleBytes = ms.ToArray();
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
using var input = new MemoryStream(bundleBytes, writable: false);
|
|
return (await importer.LoadAsync(input, passphrase: null)).SessionId;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
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 Apply_TemplateScriptWithForbiddenApi_HardBlocks()
|
|
{
|
|
// #05-T20 — bundle import is the fifth script-trust call site. A template
|
|
// script that reaches a forbidden API (Process.Start) must be rejected at
|
|
// import review, NOT left to fail at runtime. Unlike the name-resolution
|
|
// heuristic, a trust verdict is authoritative → hard error for ALL kinds.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Malicious");
|
|
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
|
"init",
|
|
"System.Diagnostics.Process.Start(\"cmd\");"));
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sessionId = await ExportWipeAndLoadAsync();
|
|
|
|
SemanticValidationException ex = default!;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Malicious", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob"));
|
|
}
|
|
|
|
Assert.NotEmpty(ex.Errors);
|
|
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "Malicious"));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Apply_ApiMethodWithForbiddenApi_HardBlocks()
|
|
{
|
|
// #05-T20 — same trust gate over ApiMethod script bodies.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ApiMethods.Add(new Commons.Entities.InboundApi.ApiMethod(
|
|
"Ingest",
|
|
"System.Diagnostics.Process.Start(\"cmd\"); return 1;"));
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sessionId = await ExportWipeAndLoadAsync();
|
|
|
|
SemanticValidationException ex = default!;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("ApiMethod", "Ingest", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob"));
|
|
}
|
|
|
|
Assert.NotEmpty(ex.Errors);
|
|
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Apply_AlarmExpressionTriggerWithForbiddenApi_HardBlocks()
|
|
{
|
|
// #05-T20 (code-review fix): an alarm's Expression trigger compiles and
|
|
// executes at the site, so a forbidden API in the trigger expression must
|
|
// be caught at import — not just in template/shared/ApiMethod script bodies.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("AlarmedTank");
|
|
t.Alarms.Add(new TemplateAlarm("Bad")
|
|
{
|
|
TriggerType = AlarmTriggerType.Expression,
|
|
TriggerConfiguration = "{\"expression\":\"System.Diagnostics.Process.GetCurrentProcess() != null\"}",
|
|
PriorityLevel = 1,
|
|
});
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sessionId = await ExportWipeAndLoadAsync();
|
|
|
|
SemanticValidationException ex = default!;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "AlarmedTank", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob"));
|
|
}
|
|
|
|
Assert.NotEmpty(ex.Errors);
|
|
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks()
|
|
{
|
|
// N5 — an instance alarm override's TriggerConfigurationOverride replaces the
|
|
// template alarm's trigger config in the flattened config and compiles/executes
|
|
// at the site, so a forbidden API in it must be caught at import review — the
|
|
// same hard-block severity as every other trust-gate surface.
|
|
await SeedInstanceAlarmOverrideAsync(
|
|
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
|
|
|
|
var sessionId = await ExportSitesAndLoadAsync();
|
|
|
|
SemanticValidationException ex = default!;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Add, null),
|
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob"));
|
|
}
|
|
|
|
Assert.NotEmpty(ex.Errors);
|
|
Assert.Contains(ex.Errors, e =>
|
|
e.Contains("Process", StringComparison.Ordinal)
|
|
&& e.Contains("trigger override", StringComparison.Ordinal));
|
|
|
|
// Rollback contract: nothing from THIS import was written (the seed rows
|
|
// pre-exist, but no new import ran — the throw is pre-write at Pass 0).
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker()
|
|
{
|
|
await SeedInstanceAlarmOverrideAsync(
|
|
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
|
|
|
|
var sessionId = await ExportSitesAndLoadAsync();
|
|
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
preview = await importer.PreviewAsync(sessionId);
|
|
}
|
|
|
|
Assert.Contains(preview.Items, b =>
|
|
b.Kind == ConflictKind.Blocker && b.EntityType == "Instance"
|
|
&& b.BlockerReason!.Contains("trust violation", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports()
|
|
{
|
|
// A clean expression against the trigger surface imports without hard-block;
|
|
// the override row is persisted verbatim (bundle fidelity).
|
|
await SeedInstanceAlarmOverrideAsync(
|
|
"{\"expression\":\"Attributes[\\\"Temp\\\"] != null\"}");
|
|
|
|
var sessionId = await ExportSitesAndLoadAsync();
|
|
|
|
// Wipe so the apply exercises a create; re-create the template the instance binds to.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
|
|
ctx.Instances.RemoveRange(ctx.Instances);
|
|
ctx.Sites.RemoveRange(ctx.Sites);
|
|
ctx.Templates.RemoveRange(ctx.Templates);
|
|
await ctx.SaveChangesAsync();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
|
Connections: Array.Empty<ConnectionMapping>());
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob", ct: CancellationToken.None, nameMap: nameMap);
|
|
}
|
|
|
|
await using (var verify = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var ovr = await ctx.InstanceAlarmOverrides.SingleAsync();
|
|
Assert.Equal("HiAlarm", ovr.AlarmCanonicalName);
|
|
Assert.Contains("Attributes", ovr.TriggerConfigurationOverride!);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
|
|
{
|
|
// #05-T19 (code-review fix): a local function declared in a TEMPLATE script
|
|
// must not suppress a genuinely-missing reference of the same name in an
|
|
// ApiMethod — the ApiMethod finding stays a HARD error. A pre-fix global
|
|
// locallyDeclared set silently defeated this.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Helper");
|
|
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
|
"init",
|
|
"decimal SharedHelper(decimal x) => x * 2; return SharedHelper(1m);"));
|
|
ctx.Templates.Add(t);
|
|
ctx.ApiMethods.Add(new Commons.Entities.InboundApi.ApiMethod(
|
|
"Ingest",
|
|
"var x = SharedHelper(); return x;"));
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sessionId = await ExportWipeAndLoadAsync();
|
|
|
|
SemanticValidationException ex = default!;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Helper", ResolutionAction.Add, null),
|
|
new("ApiMethod", "Ingest", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob"));
|
|
}
|
|
|
|
Assert.NotEmpty(ex.Errors);
|
|
Assert.Contains(ex.Errors, err => err.Contains("SharedHelper", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
|
|
{
|
|
// 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"));
|
|
}
|
|
}
|
|
}
|