DetectBlockersAsync was feeding TemplateAttribute.DataSourceReference
into the identifier scanner alongside script bodies, but that field is
an OPC UA node-address path (e.g. "ns=3;s=Tank.Level") owned by the
device, not script source. The dot delimiter inside the path tripped
the heuristic into flagging the address segment ("Tank", "Sensor",
"TestChildObject", "DevAppEngine") as a missing SharedScript or
ExternalSystem reference -- a 100% false-positive class on any
template catalog with OPC-UA-mapped attributes.
Drop the DataSourceReference scan entirely. Attribute.Value is still
scanned because it can carry a design-time default expression that
calls into runtime APIs. Add a regression test pinning the new behavior.
342 lines
15 KiB
C#
342 lines
15 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
|
using ScadaLink.Commons.Entities.Scripts;
|
|
using ScadaLink.Commons.Entities.Templates;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Interfaces.Transport;
|
|
using ScadaLink.Commons.Types.Transport;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
|
using ScadaLink.ConfigurationDatabase.Services;
|
|
using ScadaLink.Transport;
|
|
|
|
namespace ScadaLink.Transport.IntegrationTests.Import;
|
|
|
|
/// <summary>
|
|
/// Integration tests for <see cref="ScadaLink.Transport.Import.BundleImporter.PreviewAsync"/>.
|
|
/// Reuses the same in-memory host pattern as the exporter tests: real
|
|
/// repositories, real EF in-memory provider, real Transport pipeline. Each test
|
|
/// seeds the target DB, exports a bundle, then loads + previews it via the
|
|
/// importer.
|
|
/// </summary>
|
|
public sealed class BundleImporterPreviewTests : IDisposable
|
|
{
|
|
private readonly ServiceProvider _provider;
|
|
|
|
public BundleImporterPreviewTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(
|
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
|
|
|
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
|
|
services.AddDbContext<ScadaLinkDbContext>(opts => opts.UseInMemoryDatabase(dbName));
|
|
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddTransport();
|
|
|
|
_provider = services.BuildServiceProvider();
|
|
}
|
|
|
|
public void Dispose() => _provider.Dispose();
|
|
|
|
private async Task<Stream> ExportTemplatesAsync()
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var ids = await ctx.Templates.Select(t => t.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>(),
|
|
ApiKeyIds: Array.Empty<int>(),
|
|
ApiMethodIds: Array.Empty<int>(),
|
|
IncludeDependencies: false);
|
|
|
|
return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
}
|
|
|
|
private static async Task<byte[]> StreamToBytes(Stream s)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
await s.CopyToAsync(ms);
|
|
return ms.ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
|
|
{
|
|
// Arrange: seed a template, export it, leave target unchanged. The
|
|
// bundle's DTO is the literal projection of the target, so the diff
|
|
// should classify it as Identical.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "stable" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Act
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
|
preview = await importer.PreviewAsync(session.SessionId);
|
|
}
|
|
|
|
// Assert
|
|
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
|
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
|
|
Assert.Null(pumpItem.FieldDiffJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff()
|
|
{
|
|
// Arrange: seed a template with Description="new", export it, then
|
|
// overwrite the target template's Description with "old". The bundle's
|
|
// version differs from the target, so the diff should flag the
|
|
// Description field.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "new" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Mutate the target between export and preview so the diff has
|
|
// something to report. The bundle still carries Description="new".
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
|
t.Description = "old";
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
|
preview = await importer.PreviewAsync(session.SessionId);
|
|
}
|
|
|
|
// Assert
|
|
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
|
Assert.Equal(ConflictKind.Modified, pumpItem.Kind);
|
|
Assert.NotNull(pumpItem.FieldDiffJson);
|
|
// The diff should mention the Description field by name.
|
|
Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target()
|
|
{
|
|
// Arrange: seed a template, export it, then delete it from the target
|
|
// database. The bundle still contains the template, so the diff should
|
|
// classify it as New (target is now empty).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "to-be-deleted" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var t = await ctx.Templates.SingleAsync();
|
|
ctx.Templates.Remove(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
|
preview = await importer.PreviewAsync(session.SessionId);
|
|
}
|
|
|
|
// Assert
|
|
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
|
Assert.Equal(ConflictKind.New, pumpItem.Kind);
|
|
Assert.Null(pumpItem.FieldDiffJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing()
|
|
{
|
|
// Arrange: seed a template whose script body calls MissingHelper(), and
|
|
// an unrelated HelperFn() shared script that *is* defined but isn't the
|
|
// referenced one. We then export WITHOUT IncludeDependencies and use a
|
|
// selection that only pulls the template — the bundle won't carry
|
|
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
|
|
//
|
|
// To get MissingHelper into the bundle script body without the export
|
|
// resolver pulling it in (it can't — it doesn't exist), we just seed
|
|
// the template with a script that mentions it; the resolver scan only
|
|
// matters for entity discovery, the body text is preserved verbatim.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
|
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"));
|
|
|
|
var t = new Template("Pump") { Description = "broken" };
|
|
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Wipe the SharedScripts table so MissingHelper has no chance of being
|
|
// resolved in the target either. (HelperFn is intentionally seeded so
|
|
// we can verify the blocker check is specific — it should NOT flag
|
|
// HelperFn since it's in the target.)
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
// Keep HelperFn + ErpSystem so they're in the target's resolved set.
|
|
// Just confirm via assertion that MissingHelper is the blocker name.
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
|
preview = await importer.PreviewAsync(session.SessionId);
|
|
}
|
|
|
|
// Assert: there's at least one Blocker, and the MissingHelper one is in there.
|
|
Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker);
|
|
Assert.Contains(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker
|
|
&& i.Name == "MissingHelper"
|
|
&& i.BlockerReason is not null
|
|
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
|
|
// Conversely, HelperFn must NOT be a blocker — it's seeded in the target.
|
|
Assert.DoesNotContain(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker && i.Name == "HelperFn");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers()
|
|
{
|
|
// Arrange: a template with an attribute whose DataSourceReference is an
|
|
// OPC UA node-address path -- e.g. "ns=3;s=Tank.Level". The segment
|
|
// before the dot ("Tank") used to be parsed by the blocker heuristic as
|
|
// a potential SharedScript reference, even though tag paths live in the
|
|
// device's address space and are not script-callable.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var t = new Template("Pump") { Description = "tag-path-check" };
|
|
t.Attributes.Add(new TemplateAttribute("Level")
|
|
{
|
|
Value = "0",
|
|
DataSourceReference = "ns=3;s=Tank.Level",
|
|
});
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Act
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
|
preview = await importer.PreviewAsync(session.SessionId);
|
|
}
|
|
|
|
// Assert: "Tank" (the device-owned tag-path root segment) must not be
|
|
// flagged as a missing SharedScript or ExternalSystem reference.
|
|
Assert.DoesNotContain(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker && i.Name == "Tank");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers()
|
|
{
|
|
// Arrange: a template script that uses a representative mix of stdlib
|
|
// calls, runtime-API roots, and member-access patterns. None of these
|
|
// are user-defined SharedScripts or ExternalSystems and the previous
|
|
// heuristic was flagging every one of them.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var t = new Template("Pump") { Description = "noise-check" };
|
|
t.Scripts.Add(new TemplateScript("init", """
|
|
var now = DateTimeOffset.UtcNow;
|
|
var s = Convert.ToString(123);
|
|
await Notify.Send("alerts", "msg");
|
|
var x = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM t");
|
|
var y = await ExternalSystem.Call("erp", "ping");
|
|
obj.Dispose();
|
|
"""));
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Act
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
|
preview = await importer.PreviewAsync(session.SessionId);
|
|
}
|
|
|
|
// Assert: none of the well-known names produce blocker rows.
|
|
string[] noiseNames =
|
|
{
|
|
"DateTimeOffset", "UtcNow", "Convert", "ToString", "Notify", "Send",
|
|
"Database", "ExecuteScalarAsync", "COUNT", "ExternalSystem", "Call",
|
|
"Dispose",
|
|
};
|
|
foreach (var name in noiseNames)
|
|
{
|
|
Assert.DoesNotContain(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker && i.Name == name);
|
|
}
|
|
}
|
|
}
|