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; /// /// FU-C — integration tests for the two-tier semantic validation wired into /// : Pass 1 is the minimal name- /// resolution scan (carried forward from the v1 importer) and Pass 2 is the /// full SemanticValidator over each imported template's /// FlattenedConfiguration. 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). /// /// 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 /// with errors propagated. /// /// public sealed class SemanticValidatorImportTests : IDisposable { private readonly ServiceProvider _provider; public SemanticValidatorImportTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"SemanticValidatorImportTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); // M8: DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); /// /// Export everything currently seeded, wipe the DB, then LoadAsync the /// bundle. Returns the session id. Mirrors the helper in /// BundleImporterApplyTests but exported as a free helper so each /// test can seed its own template shape without sharing fixture state. /// private async Task ExportWipeAndLoadAsync() { byte[] bundleBytes; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); 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(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), 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(); 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(); 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(); 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(); result = await importer.ApplyAsync(sessionId, new List { 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(); 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(); 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(); result = await importer.ApplyAsync(sessionId, new List { 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(); 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(); result = await importer.ApplyAsync(sessionId, new List { 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(); 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(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { 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(); 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(); 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(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { 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(); 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(); 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(); result = await importer.ApplyAsync(sessionId, new List { 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(); Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "CleanPump")); Assert.True(await ctx.TemplateAlarms.AnyAsync(a => a.Name == "Overspeed")); } } }