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).
This commit is contained in:
@@ -2,11 +2,13 @@ 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;
|
||||
@@ -86,10 +88,11 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
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: Array.Empty<int>(),
|
||||
ExternalSystemIds: externalSystemIds,
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
@@ -476,4 +479,272 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user