Files
ScadaBridge/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs
T
Joseph Doherty e3ca9af1be fix(transport): Overwrite resolution now syncs child collections (2 findings)
Transport-001: template Overwrite now diff-and-merges the bundle's
Attributes / Alarms / Scripts onto the target template via three private
helpers (SyncTemplateAttributesAsync / SyncTemplateAlarmsAsync /
SyncTemplateScriptsAsync). Each helper emits one audit row per detected
add / update / delete and feeds the post-merge state into the existing
ResolveAlarmScriptLinks and ResolveCompositionEdges passes.

Transport-002: external-system Overwrite now syncs the Methods collection
via a parallel SyncExternalSystemMethodsAsync helper mirroring the T-001
shape, with ExternalSystemMethodAdded / Updated / Deleted audit rows.

Both fixes are covered by new integration tests in BundleImporterApplyTests.
README regenerated — open findings dropped from 146 to 136; all 10 open
High findings are now closed (0 Critical, 0 High, 46 Medium, 90 Low
remaining).
2026-05-28 05:54:03 -04:00

751 lines
34 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
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.Enums;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Services;
using ScadaLink.Transport;
using ScadaLink.Transport.Import;
namespace ScadaLink.Transport.IntegrationTests.Import;
/// <summary>
/// Integration tests for <see cref="BundleImporter.ApplyAsync"/>. Reuses the
/// in-memory host pattern from <see cref="BundleImporterPreviewTests"/> and
/// <c>BundleExporterTests</c>: real repositories, real EF in-memory provider,
/// real Transport pipeline.
/// <para>
/// In-memory EF caveat: <see cref="DbContext.Database.BeginTransactionAsync"/>
/// is a no-op on this provider, so the rollback test depends on ApplyAsync's
/// implementation deferring <c>SaveChangesAsync</c> to a single call just
/// before <c>CommitAsync</c>. The implementation enforces that contract +
/// calls <c>ChangeTracker.Clear()</c> on the catch path to defend against
/// in-memory bleed-through; the rollback test asserts via row counts that the
/// invariant holds.
/// </para>
/// </summary>
public sealed class BundleImporterApplyTests : IDisposable
{
private readonly ServiceProvider _provider;
public BundleImporterApplyTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"BundleImporterApplyTests_{Guid.NewGuid()}";
// In-memory provider throws by default when BeginTransactionAsync is
// called (InMemoryEventId.TransactionIgnoredWarning is escalated to an
// exception). ApplyAsync legitimately opens a transaction for
// relational providers; downgrade the warning here so the in-memory
// run is a no-op and the rest of the apply runs through. See the
// ApplyAsync XML comment for the rollback-safety contract that makes
// this safe (single deferred SaveChangesAsync + ChangeTracker.Clear
// on catch).
services.AddDbContext<ScadaLinkDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
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();
// ---- helpers ----
/// <summary>
/// Exports the entire seeded content as a bundle, then immediately loads it
/// via <see cref="IBundleImporter.LoadAsync"/> and returns the opened
/// session. Used by every test that needs a session to feed
/// <see cref="IBundleImporter.ApplyAsync"/>. Selection is "all templates +
/// all shared scripts" because the tests want the bundle to carry whatever
/// the test seeded.
/// </summary>
private async Task<Guid> ExportAndLoadAsync()
{
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
ExternalSystemIds: externalSystemIds,
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
using var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms);
ms.Position = 0;
await using var loadScope = _provider.CreateAsyncScope();
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
private async Task WipeContentAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.RemoveRange(ctx.Templates);
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
await ctx.SaveChangesAsync();
}
// ---- tests ----
[Fact]
public async Task ApplyAsync_adds_new_artifacts_in_single_transaction()
{
// Arrange: seed → export → wipe → apply. The wipe ensures the import
// is exercising the Add path (the bundle's artifacts are absent from
// the target).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Act
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var resolutions = new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Add, null),
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
};
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump"));
Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn"));
}
Assert.Equal(2, result.Added);
Assert.Equal(0, result.Overwritten);
Assert.Equal(0, result.Skipped);
Assert.NotEqual(Guid.Empty, result.BundleImportId);
}
[Fact]
public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite()
{
// Arrange: seed Pump with Description=new, export, then mutate to
// Description=old. The bundle still carries "new". Overwrite must
// restore the description.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "new" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
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
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
Assert.Equal("new", t.Description);
}
Assert.Equal(1, result.Overwritten);
}
[Fact]
public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip()
{
// Arrange: identical seed + bundle; Skip resolution should leave
// target unchanged and bump Skipped count.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "stable" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Act
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Skip, null) },
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
// Exactly one Pump still, with Description unchanged.
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
Assert.Equal("stable", t.Description);
}
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Added);
Assert.Equal(0, result.Overwritten);
}
[Fact]
public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename()
{
// Arrange: seed X, export, wipe so the Rename target Y doesn't
// collide. Apply Rename X→Y.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("X") { Description = "orig" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Act
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "X", ResolutionAction.Rename, "Y") },
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
Assert.Equal(0, await ctx.Templates.CountAsync(t => t.Name == "X"));
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Y"));
}
Assert.Equal(1, result.Renamed);
}
[Fact]
public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
{
// Arrange: seed a template whose script body calls MissingHelper().
// No SharedScript by that name exists in source or (after wipe) in the
// target, so semantic validation must reject the apply.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = new Template("BrokenPump") { Description = "broken" };
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Act
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
user: "bob"));
}
// Assert — target still wiped (template not committed), AND a
// BundleImportFailed row exists.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
Assert.Equal(0, await ctx.Templates.CountAsync());
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
}
// T-007: a failed apply used to keep the BundleSession (and its decrypted
// secrets) in the in-memory store for the full 30-minute TTL. The session
// must now be removed immediately so the plaintext is released.
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
Assert.Null(sessionStore.Get(sessionId));
}
[Fact]
public async Task ApplyAsync_removes_session_on_success_path_too()
{
// T-007: companion to the failed-apply test — the success path must also
// remove the session (it was already doing so before T-007, but the new
// test asserts the contract explicitly so a future refactor cannot
// accidentally leave plaintext in the store).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("PumpForT007") { Description = "fresh" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "PumpForT007", ResolutionAction.Add, null) },
user: "alice");
}
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
Assert.Null(sessionStore.Get(sessionId));
}
[Fact]
public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row()
{
// The correlation guarantee — every per-entity audit row emitted during
// ApplyAsync must carry the same BundleImportId as the returned result.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Snapshot the audit-row ids before the apply so the assertion only
// looks at rows the apply itself emitted (the export wrote a
// BundleExported row too, with no BundleImportId — that's correct, it
// wasn't part of an import).
int beforeMaxId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
beforeMaxId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
}
// Act
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Add, null),
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
},
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var newRows = await ctx.AuditLogEntries.Where(a => a.Id > beforeMaxId).ToListAsync();
// We expect at least: TemplateCreated + SharedScriptCreated + BundleImported.
Assert.True(newRows.Count >= 3,
$"Expected at least 3 new audit rows, got {newRows.Count}.");
Assert.All(newRows, row =>
Assert.Equal(result.BundleImportId, row.BundleImportId));
}
}
[Fact]
public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction()
{
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Act
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
user: "bob");
}
// Assert: BundleImported row exists, has the right SourceEnvironment in
// its AfterStateJson, and carries the BundleImportId from the result.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImported");
Assert.NotNull(row);
Assert.Equal("Bundle", row!.EntityType);
Assert.Equal(result.BundleImportId, row.BundleImportId);
Assert.NotNull(row.AfterStateJson);
Assert.Contains("dev", row.AfterStateJson!, StringComparison.Ordinal);
// Summary block in payload.
Assert.Contains("Summary", row.AfterStateJson!, StringComparison.Ordinal);
}
}
[Fact]
public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction()
{
// Paired with the rollback test — the failure row IS present even
// though every other write was rolled back, AND it carries
// BundleImportId == null (the rolled-back id is intentionally
// disowned from the failure record).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = new Template("BrokenPump") { Description = "broken" };
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Act
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
user: "bob"));
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImportFailed");
Assert.NotNull(row);
Assert.Equal("Bundle", row!.EntityType);
// Correlation MUST be null on the failure row — the rolled-back
// BundleImportId is intentionally disowned.
Assert.Null(row.BundleImportId);
}
}
[Fact]
public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle()
{
// T-001 regression. The Overwrite branch used to write only Description
// / FolderId on the existing template; the bundle's Attributes / Alarms
// / Scripts collections were silently dropped on the floor. This test
// seeds a template with one shape, exports it, mutates the target to a
// divergent shape, then asserts that Overwrite restores every child
// collection AND emits per-field audit rows.
//
// Bundle shape (exported from "Pump"):
// Attributes: [SetPoint (Float, 50.0), Pressure (Float, 100.0)]
// Alarms: [HiAlarm (PriorityLevel=1)]
// Scripts: [Init (Code="return 1;")]
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = new Template("Pump") { Description = "from-bundle" };
t.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Float, Value = "50.0" });
t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "100.0" });
t.Alarms.Add(new TemplateAlarm("HiAlarm") { PriorityLevel = 1, TriggerType = AlarmTriggerType.ValueMatch });
t.Scripts.Add(new TemplateScript("Init", "return 1;"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target so every child collection diverges from the bundle.
// Attributes: SetPoint value mutated, Pressure DELETED, NewAttr ADDED
// Alarms: HiAlarm PriorityLevel mutated, ExtraAlarm ADDED
// Scripts: Init code mutated, ExtraScript ADDED
// Description also mutated so the scalar field still flips.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var pump = await ctx.Templates
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.SingleAsync(t => t.Name == "Pump");
pump.Description = "target-mutated";
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
setPoint.Value = "999.0"; // mutated
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
pump.Attributes.Remove(pressure);
ctx.TemplateAttributes.Remove(pressure);
pump.Attributes.Add(new TemplateAttribute("NewAttr")
{
DataType = DataType.String,
Value = "should-be-deleted-by-overwrite",
});
var hiAlarm = pump.Alarms.Single(a => a.Name == "HiAlarm");
hiAlarm.PriorityLevel = 99; // mutated
pump.Alarms.Add(new TemplateAlarm("ExtraAlarm")
{
PriorityLevel = 5,
TriggerType = AlarmTriggerType.RangeViolation,
});
var initScript = pump.Scripts.Single(s => s.Name == "Init");
initScript.Code = "return 999;"; // mutated
pump.Scripts.Add(new TemplateScript("ExtraScript", "return 0;"));
await ctx.SaveChangesAsync();
}
// Capture the audit baseline so we can scope assertions to rows
// emitted by THIS apply.
int beforeMaxAuditId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
}
// Act — apply Overwrite.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — children mirror the bundle.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var pump = await ctx.Templates
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.SingleAsync(t => t.Name == "Pump");
Assert.Equal("from-bundle", pump.Description);
// Attributes — exactly { SetPoint, Pressure }, values restored.
Assert.Equal(2, pump.Attributes.Count);
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
Assert.Equal("50.0", setPoint.Value);
Assert.Equal(DataType.Float, setPoint.DataType);
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
Assert.Equal("100.0", pressure.Value);
Assert.DoesNotContain(pump.Attributes, a => a.Name == "NewAttr");
// Alarms — exactly { HiAlarm }, PriorityLevel restored.
Assert.Single(pump.Alarms);
var hi = pump.Alarms.Single();
Assert.Equal("HiAlarm", hi.Name);
Assert.Equal(1, hi.PriorityLevel);
Assert.DoesNotContain(pump.Alarms, a => a.Name == "ExtraAlarm");
// Scripts — exactly { Init }, code restored.
Assert.Single(pump.Scripts);
var init = pump.Scripts.Single();
Assert.Equal("Init", init.Name);
Assert.Equal("return 1;", init.Code);
Assert.DoesNotContain(pump.Scripts, s => s.Name == "ExtraScript");
// Per-field audit rows — design doc enumerates Added / Updated /
// Deleted shapes; all of these should appear, all stamped with
// the BundleImportId from the result.
var newRows = await ctx.AuditLogEntries
.Where(a => a.Id > beforeMaxAuditId)
.ToListAsync();
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
// Attribute audit events.
Assert.Contains(newRows, r => r.Action == "TemplateAttributeUpdated" && r.EntityName == "Pump.SetPoint");
Assert.Contains(newRows, r => r.Action == "TemplateAttributeAdded" && r.EntityName == "Pump.Pressure");
Assert.Contains(newRows, r => r.Action == "TemplateAttributeDeleted" && r.EntityName == "Pump.NewAttr");
// Alarm audit events.
Assert.Contains(newRows, r => r.Action == "TemplateAlarmUpdated" && r.EntityName == "Pump.HiAlarm");
Assert.Contains(newRows, r => r.Action == "TemplateAlarmDeleted" && r.EntityName == "Pump.ExtraAlarm");
// Script audit events.
Assert.Contains(newRows, r => r.Action == "TemplateScriptUpdated" && r.EntityName == "Pump.Init");
Assert.Contains(newRows, r => r.Action == "TemplateScriptDeleted" && r.EntityName == "Pump.ExtraScript");
}
Assert.Equal(1, result.Overwritten);
}
[Fact]
public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle()
{
// T-002 regression. The ExternalSystem Overwrite branch used to write
// only EndpointUrl / AuthType / AuthConfiguration on the existing
// definition; the bundle's Methods collection was silently dropped on
// the floor. This test seeds an external system with one method
// shape, exports it, mutates the target's methods to diverge, then
// asserts that Overwrite restores every method AND emits per-field
// audit rows.
//
// Bundle shape (exported from "Erp"):
// Methods: [
// GetUser (GET /users/{id}, param=A, return=R),
// PostJob (POST /jobs, param=B, return=S),
// ]
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var sys = new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey");
ctx.ExternalSystemDefinitions.Add(sys);
await ctx.SaveChangesAsync();
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetUser", "GET", "/users/{id}")
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = "A",
ReturnDefinition = "R",
});
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostJob", "POST", "/jobs")
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = "B",
ReturnDefinition = "S",
});
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target so the methods diverge from the bundle:
// GetUser — Path mutated (UPDATE expected on Overwrite)
// PostJob — DELETED (ADD expected on Overwrite to restore)
// ExtraOp — ADDED (DELETE expected on Overwrite to remove)
// EndpointUrl / AuthType also mutated so the scalar update still fires.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
sys.EndpointUrl = "https://wrong.example";
sys.AuthType = "Basic";
var getUser = await ctx.ExternalSystemMethods
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "GetUser");
getUser.Path = "/wrong/path";
var postJob = await ctx.ExternalSystemMethods
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "PostJob");
ctx.ExternalSystemMethods.Remove(postJob);
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("ExtraOp", "DELETE", "/extra")
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = "X",
ReturnDefinition = "Y",
});
await ctx.SaveChangesAsync();
}
// Capture the audit baseline so we can scope assertions to rows
// emitted by THIS apply.
int beforeMaxAuditId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
}
// Act — apply Overwrite on the external system.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — methods mirror the bundle exactly.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
Assert.Equal("https://erp.example", sys.EndpointUrl);
var methods = await ctx.ExternalSystemMethods
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
.ToListAsync();
Assert.Equal(2, methods.Count);
var getUser = methods.Single(m => m.Name == "GetUser");
Assert.Equal("GET", getUser.HttpMethod);
Assert.Equal("/users/{id}", getUser.Path);
Assert.Equal("A", getUser.ParameterDefinitions);
Assert.Equal("R", getUser.ReturnDefinition);
var postJob = methods.Single(m => m.Name == "PostJob");
Assert.Equal("POST", postJob.HttpMethod);
Assert.Equal("/jobs", postJob.Path);
Assert.Equal("B", postJob.ParameterDefinitions);
Assert.Equal("S", postJob.ReturnDefinition);
Assert.DoesNotContain(methods, m => m.Name == "ExtraOp");
// Per-field audit rows — design doc enumerates Added / Updated /
// Deleted shapes; all of these should appear, all stamped with
// the BundleImportId from the result.
var newRows = await ctx.AuditLogEntries
.Where(a => a.Id > beforeMaxAuditId)
.ToListAsync();
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodUpdated" && r.EntityName == "Erp.GetUser");
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodAdded" && r.EntityName == "Erp.PostJob");
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodDeleted" && r.EntityName == "Erp.ExtraOp");
}
Assert.Equal(1, result.Overwritten);
}
}