318 lines
16 KiB
C#
318 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// (<c>GrpcEndpointRules</c>) and every name that becomes a site actor name
|
|
/// (<c>ActorNameRules</c>: instance unique names, template script / alarm /
|
|
/// composition-slot / native-alarm-source names). A violation is a Blocker row in
|
|
/// the preview and a <see cref="SemanticValidationException"/> at apply, raised
|
|
/// before any row is staged — never an exception mid-apply or a crash at deploy.
|
|
/// </summary>
|
|
public sealed class ImportNameRuleTests : IDisposable
|
|
{
|
|
private readonly ServiceProvider _provider;
|
|
|
|
/// <summary>Initializes the test class with a service provider backed by an in-memory database.</summary>
|
|
public ImportNameRuleTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>(
|
|
new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
|
|
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection().Build());
|
|
|
|
var dbName = $"ImportNameRuleTests_{Guid.NewGuid()}";
|
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
|
.UseInMemoryDatabase(dbName)
|
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
|
|
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddTransport();
|
|
|
|
_provider = services.BuildServiceProvider();
|
|
}
|
|
|
|
/// <summary>Releases the resources used by this instance.</summary>
|
|
public void Dispose() => _provider.Dispose();
|
|
|
|
private static BundleContentDto Empty() => new(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
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<TemplateAttributeDto>(),
|
|
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<Guid> PackAndLoadAsync(BundleContentDto content)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
|
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
|
|
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<ManifestContentEntry>(),
|
|
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<ImportPreview> PreviewAsync(Guid sessionId)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>().PreviewAsync(sessionId);
|
|
}
|
|
|
|
private async Task<ImportResult> ApplyAsync(Guid sessionId, params ImportResolution[] resolutions)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
|
.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// 03/R-1 — site gRPC endpoints
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that a malformed gRPC endpoint on a site the import would create is a preview Blocker.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that applying a malformed gRPC endpoint is rejected before anything is written.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<SemanticValidationException>(() =>
|
|
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<ScadaBridgeDbContext>();
|
|
Assert.Empty(await ctx.Sites.ToListAsync());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ScadaBridgeDbContext>();
|
|
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<ScadaBridgeDbContext>().Sites.SingleAsync();
|
|
Assert.Equal("http://good:8083", site.GrpcNodeAAddress);
|
|
}
|
|
|
|
/// <summary>Verifies that Overwrite onto an existing site with a malformed endpoint is rejected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ScadaBridgeDbContext>();
|
|
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<SemanticValidationException>(() =>
|
|
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
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies that template member names Akka would reject are preview Blockers, one per member.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Verifies that applying a template with a non-actor-safe member name is rejected before any write.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<SemanticValidationException>(() =>
|
|
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<ScadaBridgeDbContext>().Templates.ToListAsync());
|
|
}
|
|
|
|
/// <summary>Verifies that a Skip-resolved template's member names are not checked at apply.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an instance unique name (or its rename target) Akka would reject is caught.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ScadaBridgeDbContext>();
|
|
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<InstanceAttributeOverrideDto>(), Array.Empty<InstanceAlarmOverrideDto>(),
|
|
Array.Empty<InstanceNativeAlarmSourceOverrideDto>(), Array.Empty<InstanceConnectionBindingDto>());
|
|
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<SemanticValidationException>(() =>
|
|
ApplyAsync(second, new ImportResolution("Instance", "Pump-02", ResolutionAction.Rename, "Pump/02")));
|
|
Assert.Contains(ex.Errors, e => e.Contains("Pump/02"));
|
|
}
|
|
}
|