fix(transport): reject bundle imports that would persist an inheritance/composition cycle
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
+120
@@ -12,6 +12,7 @@ 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;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
@@ -80,6 +81,7 @@ public sealed class InheritanceImportTests : IDisposable
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
|
||||
await ctx.SaveChangesAsync();
|
||||
@@ -201,4 +203,122 @@ public sealed class InheritanceImportTests : IDisposable
|
||||
Assert.Equal("WaterPump", row.EntityName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Seed base "root" + derived "leaf" (leaf ParentTemplateId -> root).</summary>
|
||||
private async Task SeedParentChildAsync(string rootName, string leafName)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var root = new Template(rootName) { Description = "root" };
|
||||
ctx.Templates.Add(root);
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.Templates.Add(new Template(leafName) { Description = "leaf", ParentTemplateId = root.Id });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Overwrite_ThatClosesInheritanceCycle_Throws_AndRollsBack()
|
||||
{
|
||||
// Source: base "Alpha" (root) + derived "Beta" (parent Alpha), so the
|
||||
// exported Beta DTO carries BaseTemplateName = "Alpha".
|
||||
await SeedParentChildAsync("Alpha", "Beta");
|
||||
var bundle = await ExportAllTemplatesAsync();
|
||||
await WipeTemplatesAsync();
|
||||
|
||||
// Target: the MIRROR — "Beta" (root) + "Alpha" (parent Beta). Overwriting
|
||||
// Beta so it inherits from Alpha closes Alpha -> Beta -> Alpha.
|
||||
await SeedParentChildAsync("Beta", "Alpha");
|
||||
|
||||
var sessionId = await LoadAsync(bundle);
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var resolutions = new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Alpha", ResolutionAction.Skip, null),
|
||||
new("Template", "Beta", ResolutionAction.Overwrite, null),
|
||||
};
|
||||
var ex = await Assert.ThrowsAsync<SemanticValidationException>(
|
||||
() => importer.ApplyAsync(sessionId, resolutions, user: "bob"));
|
||||
Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Rolled back: target Beta's parent edge is untouched (still a root).
|
||||
await using (var assert = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assert.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var beta = await ctx.Templates.SingleAsync(t => t.Name == "Beta");
|
||||
Assert.Null(beta.ParentTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Overwrite_ThatClosesCompositionCycle_Throws_AndRollsBack()
|
||||
{
|
||||
// Source: leaf "Gamma" + "Delta" that composes Gamma, so the exported
|
||||
// Delta DTO carries a composition edge to "Gamma".
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Gamma") { Description = "leaf" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var gamma = await ctx.Templates.SingleAsync(t => t.Name == "Gamma");
|
||||
var delta = new Template("Delta") { Description = "composer" };
|
||||
ctx.Templates.Add(delta);
|
||||
await ctx.SaveChangesAsync();
|
||||
delta.Compositions.Add(new TemplateComposition("g1")
|
||||
{
|
||||
TemplateId = delta.Id,
|
||||
ComposedTemplateId = gamma.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundle = await ExportAllTemplatesAsync();
|
||||
await WipeTemplatesAsync();
|
||||
|
||||
// Target: the MIRROR — "Gamma" composes "Delta". Overwriting Delta so it
|
||||
// composes Gamma closes the composition loop Gamma -> Delta -> Gamma.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Delta") { Description = "target-leaf" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var delta = await ctx.Templates.SingleAsync(t => t.Name == "Delta");
|
||||
var gamma = new Template("Gamma") { Description = "target-composer" };
|
||||
ctx.Templates.Add(gamma);
|
||||
await ctx.SaveChangesAsync();
|
||||
gamma.Compositions.Add(new TemplateComposition("d1")
|
||||
{
|
||||
TemplateId = gamma.Id,
|
||||
ComposedTemplateId = delta.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await LoadAsync(bundle);
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var resolutions = new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Gamma", ResolutionAction.Skip, null),
|
||||
new("Template", "Delta", ResolutionAction.Overwrite, null),
|
||||
};
|
||||
var ex = await Assert.ThrowsAsync<SemanticValidationException>(
|
||||
() => importer.ApplyAsync(sessionId, resolutions, user: "bob"));
|
||||
Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Rolled back: target Delta still has NO composition rows (Overwrite did
|
||||
// not persist the loop-closing edge).
|
||||
await using (var assert = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assert.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var delta = await ctx.Templates
|
||||
.Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Name == "Delta");
|
||||
Assert.Empty(delta.Compositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user