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; /// /// 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; } /// /// Seeds a template + site + instance whose single alarm override carries the /// given as its /// TriggerConfigurationOverride, so an export→load carries the instance /// alarm-override trigger expression through the trust gate. /// private async Task SeedInstanceAlarmOverrideAsync(string triggerConfigJson) { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); 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(); } /// Exports every seeded site (+ template) closure into a bundle and loads it. private async Task ExportSitesAndLoadAsync() { byte[] bundleBytes; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); 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(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), 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(); 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(); 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 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(); 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(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { 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(); 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(); 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(); 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("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(); 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(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { 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(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { 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(); 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(); 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()); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await importer.ApplyAsync(sessionId, new List { 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(); 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(); 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(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { 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(); 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")); } } }