using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; 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.Templates; 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.Import; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// Review 2026-10-21 03/R-1 + lane-F follow-up: the importer validates the values it /// would write that the runtime later trusts blindly — a site's gRPC endpoints /// (GrpcEndpointRules) and every name that becomes a site actor name /// (ActorNameRules: instance unique names, template script / alarm / /// composition-slot / native-alarm-source names). A violation is a Blocker row in /// the preview and a at apply, raised /// before any row is staged — never an exception mid-apply or a crash at deploy. /// public sealed class ImportNameRuleTests : IDisposable { private readonly ServiceProvider _provider; /// Initializes the test class with a service provider backed by an in-memory database. public ImportNameRuleTests() { var services = new ServiceCollection(); services.AddSingleton( new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()); services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"ImportNameRuleTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } /// Releases the resources used by this instance. public void Dispose() => _provider.Dispose(); private static BundleContentDto Empty() => new( TemplateFolders: Array.Empty(), Templates: Array.Empty(), SharedScripts: Array.Empty(), ExternalSystems: Array.Empty(), DatabaseConnections: Array.Empty(), NotificationLists: Array.Empty(), SmtpConfigs: Array.Empty(), ApiMethods: Array.Empty()); private static SiteDto SiteWithGrpc(string? a, string? b) => new("plant-1", "Plant 1", null, null, null, a, b); private static TemplateDto TemplateWithMembers(string script, string alarm, string slot, string nativeSource) => new( Name: "Pump", FolderName: null, BaseTemplateName: null, Description: null, Attributes: Array.Empty(), Alarms: new[] { new TemplateAlarmDto(alarm, null, 1, AlarmTriggerType.ValueMatch, null, false, null), }, Scripts: new[] { new TemplateScriptDto(script, "return 1;", null, null, null, null, false, null), }, Compositions: new[] { new TemplateCompositionDto(slot, "Other") }) { NativeAlarmSources = new[] { new TemplateNativeAlarmSourceDto(nativeSource, null, "Opc", "ns=1;s=A", null, false, false, false), }, }; private async Task PackAndLoadAsync(BundleContentDto content) { await using var scope = _provider.CreateAsyncScope(); var manifestBuilder = scope.ServiceProvider.GetRequiredService(); var serializer = scope.ServiceProvider.GetRequiredService(); var importer = scope.ServiceProvider.GetRequiredService(); var summary = new BundleSummary( Templates: content.Templates.Count, TemplateFolders: 0, SharedScripts: 0, ExternalSystems: 0, DbConnections: 0, NotificationLists: 0, SmtpConfigs: 0, ApiMethods: 0, Sites: content.Sites.Count, DataConnections: 0, Instances: content.Instances.Count); var manifest = manifestBuilder.Build( sourceEnvironment: "dev", exportedBy: "alice", scadaBridgeVersion: "1.0.0", encryption: null, summary: summary, contents: Array.Empty(), contentBytes: serializer.SerializeContentBytes(content)); await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null); using var ms = new MemoryStream(); await packed.CopyToAsync(ms); ms.Position = 0; return (await importer.LoadAsync(ms, passphrase: null)).SessionId; } private async Task PreviewAsync(Guid sessionId) { await using var scope = _provider.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService().PreviewAsync(sessionId); } private async Task ApplyAsync(Guid sessionId, params ImportResolution[] resolutions) { await using var scope = _provider.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService() .ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None); } // ────────────────────────────────────────────────────────────────────── // 03/R-1 — site gRPC endpoints // ────────────────────────────────────────────────────────────────────── /// Verifies that a malformed gRPC endpoint on a site the import would create is a preview Blocker. /// A task that represents the asynchronous operation. [Fact] public async Task Preview_malformed_grpc_address_on_a_new_site_is_a_blocker() { var sessionId = await PackAndLoadAsync(Empty() with { Sites = new[] { SiteWithGrpc("site-a-node-a:8083", "http://site-a-node-b:8083") }, }); var preview = await PreviewAsync(sessionId); var blocker = Assert.Single(preview.Items, i => i.Kind == ConflictKind.Blocker); Assert.Equal("Site", blocker.EntityType); Assert.Equal("plant-1", blocker.Name); Assert.Contains("GrpcNodeAAddress", blocker.BlockerReason); Assert.Contains("site-a-node-a:8083", blocker.BlockerReason); } /// Verifies that applying a malformed gRPC endpoint is rejected before anything is written. /// A task that represents the asynchronous operation. [Fact] public async Task Apply_malformed_grpc_address_on_a_new_site_is_rejected_before_any_write() { var sessionId = await PackAndLoadAsync(Empty() with { Sites = new[] { SiteWithGrpc("http://site-a-node-a:8083", "scadabridge-site-a-node-b") }, }); var ex = await Assert.ThrowsAsync(() => ApplyAsync(sessionId, new ImportResolution("Site", "plant-1", ResolutionAction.Add, null))); Assert.Contains(ex.Errors, e => e.Contains("GrpcNodeBAddress") && e.Contains("plant-1")); await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Empty(await ctx.Sites.ToListAsync()); } /// /// Verifies that a malformed endpoint on a bundled site that maps to an existing /// target site and is Skip-resolved does not block — the address is never written. /// /// A task that represents the asynchronous operation. [Fact] public async Task Apply_malformed_grpc_address_is_allowed_when_the_existing_site_is_skipped() { await using (var seed = _provider.CreateAsyncScope()) { var ctx = seed.ServiceProvider.GetRequiredService(); ctx.Sites.Add(new Site("Plant 1", "plant-1") { GrpcNodeAAddress = "http://good:8083" }); await ctx.SaveChangesAsync(); } var sessionId = await PackAndLoadAsync(Empty() with { Sites = new[] { SiteWithGrpc("bad-address", null) }, }); var preview = await PreviewAsync(sessionId); Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker); Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Warning && i.EntityType == "Site"); await ApplyAsync(sessionId, new ImportResolution("Site", "plant-1", ResolutionAction.Skip, null)); await using var scope = _provider.CreateAsyncScope(); var site = await scope.ServiceProvider.GetRequiredService().Sites.SingleAsync(); Assert.Equal("http://good:8083", site.GrpcNodeAAddress); } /// Verifies that Overwrite onto an existing site with a malformed endpoint is rejected. /// A task that represents the asynchronous operation. [Fact] public async Task Apply_malformed_grpc_address_is_rejected_on_overwrite_of_an_existing_site() { await using (var seed = _provider.CreateAsyncScope()) { var ctx = seed.ServiceProvider.GetRequiredService(); ctx.Sites.Add(new Site("Plant 1", "plant-1") { GrpcNodeAAddress = "http://good:8083" }); await ctx.SaveChangesAsync(); } var sessionId = await PackAndLoadAsync(Empty() with { Sites = new[] { SiteWithGrpc("bad-address", null) }, }); var ex = await Assert.ThrowsAsync(() => ApplyAsync(sessionId, new ImportResolution("Site", "plant-1", ResolutionAction.Overwrite, null))); Assert.Contains(ex.Errors, e => e.Contains("GrpcNodeAAddress")); } // ────────────────────────────────────────────────────────────────────── // ActorNameRules — names that become site actor names // ────────────────────────────────────────────────────────────────────── /// Verifies that template member names Akka would reject are preview Blockers, one per member. /// A task that represents the asynchronous operation. [Fact] public async Task Preview_template_member_names_that_are_not_actor_safe_are_blockers() { var sessionId = await PackAndLoadAsync(Empty() with { Templates = new[] { TemplateWithMembers("bad/script", "bad alarm", "slot#1", "$native") }, }); var preview = await PreviewAsync(sessionId); var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList(); Assert.Contains(blockers, b => b.Name == "Pump.bad/script" && b.BlockerReason!.Contains("Script")); Assert.Contains(blockers, b => b.Name == "Pump.bad alarm" && b.BlockerReason!.Contains("Alarm")); Assert.Contains(blockers, b => b.Name == "Pump.slot#1" && b.BlockerReason!.Contains("Composition")); Assert.Contains(blockers, b => b.Name == "Pump.$native" && b.BlockerReason!.Contains("Native alarm source")); } /// Verifies that applying a template with a non-actor-safe member name is rejected before any write. /// A task that represents the asynchronous operation. [Fact] public async Task Apply_template_member_name_that_is_not_actor_safe_is_rejected_before_any_write() { var sessionId = await PackAndLoadAsync(Empty() with { Templates = new[] { TemplateWithMembers("bad/script", "Hi", "Slot", "Native") }, }); var ex = await Assert.ThrowsAsync(() => ApplyAsync(sessionId, new ImportResolution("Template", "Pump", ResolutionAction.Add, null))); Assert.Contains(ex.Errors, e => e.Contains("bad/script")); await using var scope = _provider.CreateAsyncScope(); Assert.Empty(await scope.ServiceProvider.GetRequiredService().Templates.ToListAsync()); } /// Verifies that a Skip-resolved template's member names are not checked at apply. /// A task that represents the asynchronous operation. [Fact] public async Task Apply_skipped_template_is_not_name_checked() { var sessionId = await PackAndLoadAsync(Empty() with { Templates = new[] { TemplateWithMembers("bad/script", "Hi", "Slot", "Native") }, }); var result = await ApplyAsync(sessionId, new ImportResolution("Template", "Pump", ResolutionAction.Skip, null)); Assert.Equal(1, result.Skipped); } /// Verifies that an instance unique name (or its rename target) Akka would reject is caught. /// A task that represents the asynchronous operation. [Fact] public async Task Instance_unique_name_and_rename_target_are_actor_name_checked() { await using (var seed = _provider.CreateAsyncScope()) { var ctx = seed.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump")); ctx.Sites.Add(new Site("Plant 1", "plant-1")); await ctx.SaveChangesAsync(); } var instance = new InstanceDto( "Pump 01", "Pump", "plant-1", null, InstanceState.NotDeployed, Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); var sessionId = await PackAndLoadAsync(Empty() with { Instances = new[] { instance } }); var preview = await PreviewAsync(sessionId); Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance" && i.Name == "Pump 01"); // Renaming to a valid name clears the instance-name error… var renamed = await ApplyAsync(sessionId, new ImportResolution("Instance", "Pump 01", ResolutionAction.Rename, "Pump-01")); Assert.Equal(1, renamed.Renamed); // …and renaming to an invalid one is rejected. var second = await PackAndLoadAsync(Empty() with { Instances = new[] { instance with { UniqueName = "Pump-02" } } }); var ex = await Assert.ThrowsAsync(() => ApplyAsync(second, new ImportResolution("Instance", "Pump-02", ResolutionAction.Rename, "Pump/02"))); Assert.Contains(ex.Errors, e => e.Contains("Pump/02")); } }