Files
scadalink-design/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs
Joseph Doherty 6bdada7549 fix(transport): drop blocker false positives for stdlib + member access
The DetectBlockersAsync heuristic was catching every PascalCase
"Identifier(" or "Identifier." token in script bodies and treating it
as a candidate SharedScript or ExternalSystem reference. On a normal
template catalog this surfaced 30+ blocker rows for .NET stdlib
(DateTimeOffset, Convert, ToString, Dispose, UtcNow...), ScadaLink
runtime API roots (Notify, Database, ExternalSystem, Scripts...), and
SQL keywords inside string literals (COUNT), blocking the import.

Two surgical fixes:

1. Skip identifiers preceded by `.` so `obj.Method()` no longer flags
   `Method` as a top-level reference.
2. Maintain a `KnownNonReferenceNames` denylist for the small set of
   well-known stdlib / runtime / SQL tokens that can never be
   user-defined SharedScripts or ExternalSystems.

The documented use case -- a top-level free-standing call to a missing
SharedScript or ExternalSystem (e.g. `MissingHelper()` at the start of
an expression, or `ErpSystem.Call(...)` where ErpSystem is the
external-system identifier) -- still produces a blocker row, pinned
by the existing test plus a new noise-filter regression test.
2026-05-24 07:46:24 -04:00

303 lines
13 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_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);
}
}
}