Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/DerivedTemplateTransportTests.cs
T
Joseph Doherty 84a050420f fix(transport): carry derived composition-slot templates and rebuild them through the compose flow (ScadaBridge#118)
Bundles did not record that a template was a derived slot template, so the
importer re-created every slot as a BASE template named after the slot.

- Export: TemplateDto gains an additive, null-omitted `Derivation`
  (owner path + base path by stable names); a derived template carries only
  its own rows (overrides/additions, not inherited placeholders). The closure
  pulls in each derived template's base/owner; base orders first.
  schemaVersion 1.1 -> 1.2, bundleFormatVersion stays 1.
- Import: base templates go through the ordinary passes; derived templates are
  ordered topologically (DerivedTemplatePlan; cycle / duplicate / malformed =
  preview Blocker + SemanticValidationException before anything is staged),
  their owner and base resolved by path (#100 root rule), and the slot is
  created with TemplateService.AddCompositionAsync, synced in place when it
  already exists with the same base, or deleted+recomposed when the base
  differs; own rows applied as overrides, then ResyncInheritedMembersAsync.
  Missing owner/base fails that record (BundleImportDerivedTemplateUnresolved
  audit + ImportResult warning), never creating a base in its place.
- Preview/diff: derived rows are keyed by qualified name and compared against
  the target slot at the same owner path (own rows + base), so an unchanged
  slot is Identical and a same-named base template is never matched.
- Pre-#118 bundles import exactly as before (new fixture
  pre-118-derived-plaintext.scadabundle, written by a13aabc7). The field is in
  content, pinned by the signed manifest's contentHash.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 05:02:06 -05:00

679 lines
37 KiB
C#

using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
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.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// ScadaBridge#118 — derived (composition-slot) templates travel as derived templates: the
/// bundle carries each one's owner path, base path and own rows, and the importer rebuilds
/// the slot through the TemplateService compose flow instead of creating a base template
/// named after the slot. Driven through the real export → load → preview → apply pipeline.
/// </summary>
public sealed class DerivedTemplateTransportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with an in-memory database and the Transport services.</summary>
public DerivedTemplateTransportTests() => _provider = BuildProvider(new Dictionary<string, string?>());
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _provider.Dispose();
private static ServiceProvider BuildProvider(IDictionary<string, string?> settings)
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
var dbName = $"DerivedTemplateTransportTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddScoped(sp => new ScadaBridgeDbContext(
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
return services.BuildServiceProvider();
}
// ── seeding through the real compose flow ───────────────────────────────────
private static async Task<T> WithServiceAsync<T>(ServiceProvider provider, Func<TemplateService, ScadaBridgeDbContext, Task<T>> body)
{
await using var scope = provider.CreateAsyncScope();
var svc = new TemplateService(
scope.ServiceProvider.GetRequiredService<ITemplateEngineRepository>(),
scope.ServiceProvider.GetRequiredService<IAuditService>());
return await body(svc, scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>());
}
private static T Ok<T>(Result<T> result)
{
Assert.True(result.IsSuccess, result.IsSuccess ? null : result.Error);
return result.Value;
}
/// <summary>
/// Motor (Speed=1, Mode="a", script Run, alarm High → Run) and Pump composing Motor as
/// slot M1; on the slot, Speed is overridden to 5 and an own attribute Extra is added.
/// Returns the derived slot template's id.
/// </summary>
private static Task<int> SeedPumpWithDerivedMotorAsync(ServiceProvider provider) =>
WithServiceAsync(provider, async (svc, ctx) =>
{
var motor = Ok(await svc.CreateTemplateAsync("Motor", "motor base", null, "seed"));
Ok(await svc.AddAttributeAsync(motor.Id, new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "1" }, "seed"));
Ok(await svc.AddAttributeAsync(motor.Id, new TemplateAttribute("Mode") { DataType = DataType.String, Value = "a" }, "seed"));
var run = Ok(await svc.AddScriptAsync(motor.Id, new TemplateScript("Run", "return 1;") { TriggerType = "Startup" }, "seed"));
Ok(await svc.AddAlarmAsync(motor.Id, new TemplateAlarm("High")
{
PriorityLevel = 500,
TriggerType = AlarmTriggerType.ValueMatch,
TriggerConfiguration = "{\"attribute\":\"Speed\",\"matchValue\":\"9\"}",
OnTriggerScriptId = run.Id,
}, "seed"));
var pump = Ok(await svc.CreateTemplateAsync("Pump", "pump", null, "seed"));
var slot = Ok(await svc.AddCompositionAsync(pump.Id, motor.Id, "M1", "seed"));
var derivedId = slot.ComposedTemplateId;
Ok(await svc.AddAttributeAsync(derivedId,
new TemplateAttribute("Extra") { DataType = DataType.String, Value = "x" }, "seed"));
var speed = await ctx.TemplateAttributes.AsNoTracking().SingleAsync(a => a.TemplateId == derivedId && a.Name == "Speed");
Ok(await svc.UpdateAttributeAsync(speed.Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "5", IsInherited = false }, "seed"));
return derivedId;
});
// ── export / load / preview / apply ─────────────────────────────────────────
private static async Task<byte[]> ExportAsync(ServiceProvider provider, string? passphrase = null, params string[] baseTemplateNames)
{
await using var scope = provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var ids = await ctx.Templates.Where(t => !t.IsDerived && (baseTemplateNames.Length == 0 || baseTemplateNames.Contains(t.Name)))
.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>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
var stream = await scope.ServiceProvider.GetRequiredService<IBundleExporter>()
.ExportAsync(selection, "alice", "dev", passphrase);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
private static async Task WipeTemplatesAsync(ServiceProvider provider)
{
await using var scope = provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.Templates.RemoveRange(ctx.Templates);
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
await ctx.SaveChangesAsync();
}
private static async Task<BundleSession> LoadAsync(ServiceProvider provider, byte[] bundle, string? passphrase = null)
{
await using var scope = provider.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
.LoadAsync(new MemoryStream(bundle, writable: false), passphrase);
}
private static async Task<ImportPreview> PreviewAsync(ServiceProvider provider, Guid sessionId)
{
await using var scope = provider.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>().PreviewAsync(sessionId);
}
/// <summary>Applies with the Central UI's default mapping (New→Add, Modified→Overwrite, Identical→Skip).</summary>
private static async Task<ImportResult> ApplyDefaultsAsync(
ServiceProvider provider, Guid sessionId, ImportPreview preview, params ImportResolution[] overrides)
{
var map = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Modified => ResolutionAction.Overwrite,
_ => ResolutionAction.Skip,
};
map[(item.EntityType, item.Name)] = new ImportResolution(item.EntityType, item.Name, action, null);
}
foreach (var o in overrides) map[(o.EntityType, o.Name)] = o;
await using var scope = provider.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
.ApplyAsync(sessionId, map.Values.ToList(), user: "bob");
}
private static async Task<ImportResult> RoundTripAsync(ServiceProvider provider, byte[] bundle, params ImportResolution[] overrides)
{
var session = await LoadAsync(provider, bundle);
var preview = await PreviewAsync(provider, session.SessionId);
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker);
return await ApplyDefaultsAsync(provider, session.SessionId, preview, overrides);
}
private static async Task<Template> LoadSlotAsync(ScadaBridgeDbContext ctx, string owner, string slot)
{
var ownerT = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == owner && !t.IsDerived);
var comp = Assert.Single(ownerT.Compositions, c => c.InstanceName == slot);
return await ctx.Templates
.Include(t => t.Attributes).Include(t => t.Alarms).Include(t => t.Scripts).Include(t => t.Compositions)
.SingleAsync(t => t.Id == comp.ComposedTemplateId);
}
// ── bundle surgery (hand-edited / crafted bundles) ──────────────────────────
private static BundleContentDto ReadContent(byte[] bundle)
{
using var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read);
using var stream = zip.GetEntry("content.json")!.Open();
return JsonSerializer.Deserialize<BundleContentDto>(stream, BundleJsonOptions.Default)!;
}
private async Task<Guid> PackAndLoadAsync(BundleContentDto content)
{
await using var scope = _provider.CreateAsyncScope();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
var summary = new BundleSummary(
Templates: content.Templates.Count,
TemplateFolders: 0, SharedScripts: 0, ExternalSystems: 0, DbConnections: 0,
NotificationLists: 0, SmtpConfigs: 0, ApiMethods: 0);
var manifest = manifestBuilder.Build("dev", "alice", "1.0.0", null, summary,
Array.Empty<ManifestContentEntry>(), serializer.SerializeContentBytes(content));
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
using var ms = new MemoryStream();
await packed.CopyToAsync(ms);
return (await LoadAsync(_provider, ms.ToArray())).SessionId;
}
// ── tests ───────────────────────────────────────────────────────────────────
/// <summary>
/// Export → wipe → import of a base + derived pair: the slot comes back as a DERIVED
/// template owned by its composition and inheriting from the base, with its override and
/// its own addition intact and the base's members as inherited placeholders — not as a
/// base template named after the slot.
/// </summary>
[Fact]
public async Task Base_and_derived_pair_round_trips_with_the_slot_still_derived()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
// The bundle marks the slot derived and carries only its own rows.
var dto = Assert.Single(ReadContent(bundle).Templates, t => t.Name == "M1");
Assert.NotNull(dto.Derivation);
Assert.Equal("Pump", dto.Derivation!.OwnerTemplateName);
Assert.Equal(new[] { "M1" }, dto.Derivation.SlotPath);
Assert.Equal("Motor", dto.Derivation.BaseTemplateName);
Assert.Empty(dto.Derivation.BaseSlotPath);
Assert.Equal(new[] { "Extra", "Speed" }, dto.Attributes.Select(a => a.Name).OrderBy(n => n));
Assert.Empty(dto.Scripts);
// Base templates carry no derivation (their wire shape is unchanged).
Assert.All(ReadContent(bundle).Templates.Where(t => t.Name != "M1"), t => Assert.Null(t.Derivation));
await WipeTemplatesAsync(_provider);
var session = await LoadAsync(_provider, bundle);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Contains(preview.Items, i => i.Name == "Pump.M1" && i.Kind == ConflictKind.New);
Assert.DoesNotContain(preview.Items, i => i.Name == "M1");
var result = await ApplyDefaultsAsync(_provider, session.SessionId, preview);
Assert.Empty(result.Warnings);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "M1" && !t.IsDerived), "the slot must not become a base template");
var motor = await ctx.Templates.Include(t => t.Scripts).SingleAsync(t => t.Name == "Motor");
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
Assert.True(slot.IsDerived);
Assert.Equal("M1", slot.Name);
Assert.Equal(motor.Id, slot.ParentTemplateId);
Assert.Equal(Assert.Single(pump.Compositions).Id, slot.OwnerCompositionId);
var speed = slot.Attributes.Single(a => a.Name == "Speed");
Assert.False(speed.IsInherited);
Assert.Equal("5", speed.Value);
var extra = slot.Attributes.Single(a => a.Name == "Extra");
Assert.False(extra.IsInherited);
Assert.Equal("x", extra.Value);
var mode = slot.Attributes.Single(a => a.Name == "Mode");
Assert.True(mode.IsInherited);
Assert.Equal("a", mode.Value);
Assert.True(slot.Scripts.Single(s => s.Name == "Run").IsInherited);
Assert.True(slot.Alarms.Single(a => a.Name == "High").IsInherited);
}
/// <summary>
/// Derived-ness is part of the identity for the differential import: re-importing the
/// same bundle reports the slot Identical (so it is skipped), and a target base template
/// that merely shares the slot's name is neither matched nor touched.
/// </summary>
[Fact]
public async Task Derived_identity_is_the_owner_path_not_the_slot_name()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
// Same environment: everything is Identical, the slot under its qualified name.
var again = await LoadAsync(_provider, bundle);
var identical = await PreviewAsync(_provider, again.SessionId);
Assert.All(identical.Items, i => Assert.Equal(ConflictKind.Identical, i.Kind));
Assert.Contains(identical.Items, i => i.Name == "Pump.M1");
// A changed override reads Modified on the derived row only.
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
var speed = slot.Attributes.Single(a => a.Name == "Speed");
Ok(await svc.UpdateAttributeAsync(speed.Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "7", IsInherited = false }, "seed"));
return 0;
});
var modified = await PreviewAsync(_provider, (await LoadAsync(_provider, bundle)).SessionId);
Assert.Equal(ConflictKind.Modified, modified.Items.Single(i => i.Name == "Pump.M1").Kind);
Assert.Equal(ConflictKind.Identical, modified.Items.Single(i => i.Name == "Pump").Kind);
// Fresh target holding an unrelated BASE template named like the slot.
await WipeTemplatesAsync(_provider);
await WithServiceAsync(_provider, async (svc, _) =>
{
var m1 = Ok(await svc.CreateTemplateAsync("M1", "unrelated", null, "seed"));
Ok(await svc.AddAttributeAsync(m1.Id, new TemplateAttribute("Own") { DataType = DataType.String, Value = "keep" }, "seed"));
return 0;
});
var session = await LoadAsync(_provider, bundle);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Equal(ConflictKind.New, preview.Items.Single(i => i.Name == "Pump.M1").Kind);
await ApplyDefaultsAsync(_provider, session.SessionId, preview);
await using var scope = _provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var unrelated = await db.Templates.Include(t => t.Attributes).SingleAsync(t => t.Name == "M1" && !t.IsDerived);
Assert.Equal("unrelated", unrelated.Description);
Assert.Equal(new[] { "Own" }, unrelated.Attributes.Select(a => a.Name));
Assert.True((await LoadSlotAsync(db, "Pump", "M1")).IsDerived);
}
/// <summary>
/// A nested slot (Station composes Pump as P, the cascade creates Station.P.M1 deriving
/// from Pump.M1) round-trips: its base is the source-side DERIVED template, addressed by
/// path, and its own override survives.
/// </summary>
[Fact]
public async Task Nested_cascade_slot_round_trips_onto_the_source_side_derived_base()
{
await SeedPumpWithDerivedMotorAsync(_provider);
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived);
var station = Ok(await svc.CreateTemplateAsync("Station", null, null, "seed"));
Ok(await svc.AddCompositionAsync(station.Id, pump.Id, "P", "seed"));
var p = await LoadSlotAsync(ctx, "Station", "P");
var nestedComp = Assert.Single(p.Compositions);
var nestedSpeed = await ctx.TemplateAttributes.AsNoTracking()
.SingleAsync(a => a.TemplateId == nestedComp.ComposedTemplateId && a.Name == "Speed");
Ok(await svc.UpdateAttributeAsync(nestedSpeed.Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "9", IsInherited = false }, "seed"));
return 0;
});
var bundle = await ExportAsync(_provider, null, "Station");
var nested = Assert.Single(ReadContent(bundle).Templates, t => t.Derivation?.QualifiedName() == "Station.P.M1");
Assert.Equal("Pump.M1", nested.Derivation!.BaseQualifiedName());
await WipeTemplatesAsync(_provider);
Assert.Empty((await RoundTripAsync(_provider, bundle)).Warnings);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pumpM1 = await LoadSlotAsync(ctx, "Pump", "M1");
var p = await LoadSlotAsync(ctx, "Station", "P");
var stationPm1 = await ctx.Templates.Include(t => t.Attributes)
.SingleAsync(t => t.Id == Assert.Single(p.Compositions).ComposedTemplateId);
Assert.True(stationPm1.IsDerived);
Assert.Equal(pumpM1.Id, stationPm1.ParentTemplateId);
Assert.Equal("9", stationPm1.Attributes.Single(a => a.Name == "Speed").Value);
Assert.False(stationPm1.Attributes.Single(a => a.Name == "Speed").IsInherited);
Assert.Equal(3, await ctx.Templates.CountAsync(t => !t.IsDerived)); // Motor, Pump, Station
}
/// <summary>
/// The importer orders derived templates itself (owner and base first): a hand-edited
/// bundle listing every derived template — nested slot first — ahead of the base
/// templates still imports correctly.
/// </summary>
[Fact]
public async Task Derived_templates_listed_before_their_base_and_owner_still_import()
{
await SeedPumpWithDerivedMotorAsync(_provider);
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived);
var station = Ok(await svc.CreateTemplateAsync("Station", null, null, "seed"));
Ok(await svc.AddCompositionAsync(station.Id, pump.Id, "P", "seed"));
return 0;
});
var content = ReadContent(await ExportAsync(_provider, null, "Station"));
var reordered = content.Templates
.OrderByDescending(t => t.Derivation?.SlotPath.Count ?? 0) // deepest derived first, bases last
.ToList();
Assert.Equal("Station.P.M1", reordered[0].Derivation?.QualifiedName());
await WipeTemplatesAsync(_provider);
var sessionId = await PackAndLoadAsync(content with { Templates = reordered });
var preview = await PreviewAsync(_provider, sessionId);
var result = await ApplyDefaultsAsync(_provider, sessionId, preview);
Assert.Empty(result.Warnings);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var p = await LoadSlotAsync(ctx, "Station", "P");
Assert.True(p.IsDerived);
var nested = await ctx.Templates.SingleAsync(t => t.Id == Assert.Single(p.Compositions).ComposedTemplateId);
Assert.Equal((await LoadSlotAsync(ctx, "Pump", "M1")).Id, nested.ParentTemplateId);
Assert.Equal(0, await ctx.Templates.CountAsync(t => (t.Name == "M1" || t.Name == "P") && !t.IsDerived));
}
/// <summary>
/// A derived template whose base is in neither the bundle (Skip) nor the target fails
/// that one record with a clear audit row and result warning; no base template is created
/// in its place, and the rest of the import lands.
/// </summary>
[Fact]
public async Task Missing_base_fails_the_derived_record_with_an_audit_row()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
await WipeTemplatesAsync(_provider);
var result = await RoundTripAsync(_provider, bundle, new ImportResolution("Template", "Motor", ResolutionAction.Skip, null));
var warning = Assert.Single(result.Warnings);
Assert.Contains("Pump.M1", warning, StringComparison.Ordinal);
Assert.Contains("base 'Motor'", warning, StringComparison.Ordinal);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "Motor" || t.Name == "M1"));
Assert.Empty((await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump")).Compositions);
var audit = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleImportDerivedTemplateUnresolved");
Assert.Equal("Pump.M1", audit.EntityName);
Assert.Equal(result.BundleImportId, audit.BundleImportId);
Assert.Contains("no base template was created", audit.AfterStateJson!, StringComparison.Ordinal);
}
/// <summary>
/// A base that is NOT in the bundle but already exists on the target is composed against.
/// </summary>
[Fact]
public async Task Base_already_in_the_target_is_composed_against()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var content = ReadContent(await ExportAsync(_provider, null, "Pump"));
var withoutMotor = content with { Templates = content.Templates.Where(t => t.Name != "Motor").ToList() };
// Target: only Motor survives.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
ctx.Templates.RemoveRange(ctx.Templates.Where(t => t.Name != "Motor"));
await ctx.SaveChangesAsync();
}
var sessionId = await PackAndLoadAsync(withoutMotor);
var result = await ApplyDefaultsAsync(_provider, sessionId, await PreviewAsync(_provider, sessionId));
Assert.Empty(result.Warnings);
await using var assertScope = _provider.CreateAsyncScope();
var db = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var slot = await LoadSlotAsync(db, "Pump", "M1");
Assert.Equal((await db.Templates.SingleAsync(t => t.Name == "Motor")).Id, slot.ParentTemplateId);
Assert.Equal("5", slot.Attributes.Single(a => a.Name == "Speed").Value);
}
/// <summary>
/// Overwrite syncs an existing slot IN PLACE (same derived row, so nested slots that
/// derive from it keep their FK): a changed override is applied and an own row the
/// bundle no longer carries is reverted / removed by the inherited-member resync.
/// </summary>
[Fact]
public async Task Overwrite_syncs_the_existing_slot_in_place()
{
var derivedId = await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
Ok(await svc.UpdateAttributeAsync(slot.Attributes.Single(a => a.Name == "Speed").Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "7", IsInherited = false }, "seed"));
Ok(await svc.UpdateAttributeAsync(slot.Attributes.Single(a => a.Name == "Mode").Id,
new TemplateAttribute("Mode") { DataType = DataType.String, Value = "z", IsInherited = false }, "seed"));
// (AddAttributeAsync on a slot with overrides trips the collision detector, so the
// stale own row is staged directly — the shape an older edit could leave behind.)
ctx.TemplateAttributes.Add(new TemplateAttribute("Stale") { TemplateId = slot.Id, DataType = DataType.String, Value = "s" });
// The owner is Modified too, so its composition pass runs (Overwrite) — and must
// leave the derived slot's composition row to the derived pass.
(await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived)).Description = "changed";
await ctx.SaveChangesAsync();
return 0;
});
var session = await LoadAsync(_provider, bundle);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Equal(ConflictKind.Modified, preview.Items.Single(i => i.Name == "Pump").Kind);
Assert.Equal(ConflictKind.Modified, preview.Items.Single(i => i.Name == "Pump.M1").Kind);
await ApplyDefaultsAsync(_provider, session.SessionId, preview);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var after = await LoadSlotAsync(ctx, "Pump", "M1");
Assert.Equal(derivedId, after.Id);
Assert.Equal("5", after.Attributes.Single(a => a.Name == "Speed").Value);
var mode = after.Attributes.Single(a => a.Name == "Mode");
Assert.True(mode.IsInherited);
Assert.Equal("a", mode.Value);
Assert.DoesNotContain(after.Attributes, a => a.Name == "Stale");
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.IsDerived));
Assert.Equal("pump", (await ctx.Templates.SingleAsync(t => t.Name == "Pump")).Description);
}
/// <summary>
/// A derivation cycle (two slots each deriving from the other) is a preview Blocker and
/// an apply-time <see cref="SemanticValidationException"/> naming both, raised before
/// anything is written.
/// </summary>
[Fact]
public async Task Derivation_cycle_is_a_clear_import_error()
{
static TemplateDto Slot(string slot, string baseSlot) =>
new(slot, null, null, null, Array.Empty<TemplateAttributeDto>(), Array.Empty<TemplateAlarmDto>(),
Array.Empty<TemplateScriptDto>(), Array.Empty<TemplateCompositionDto>())
{
Derivation = new TemplateDerivationDto("Pump", new[] { slot }, "Pump", new[] { baseSlot }),
};
var pump = new TemplateDto("Pump", null, null, null, Array.Empty<TemplateAttributeDto>(),
Array.Empty<TemplateAlarmDto>(), Array.Empty<TemplateScriptDto>(),
new[] { new TemplateCompositionDto("X", "X"), new TemplateCompositionDto("Y", "Y") });
var content = new BundleContentDto(
Array.Empty<TemplateFolderDto>(), new[] { pump, Slot("X", "Y"), Slot("Y", "X") },
Array.Empty<SharedScriptDto>(), Array.Empty<ExternalSystemDto>(), Array.Empty<DatabaseConnectionDto>(),
Array.Empty<NotificationListDto>(), Array.Empty<SmtpConfigDto>(), Array.Empty<ApiMethodDto>());
var sessionId = await PackAndLoadAsync(content);
var preview = await PreviewAsync(_provider, sessionId);
var blocker = Assert.Single(preview.Items, i => i.Kind == ConflictKind.Blocker);
Assert.Contains("cycle", blocker.BlockerReason, StringComparison.Ordinal);
await using var scope = _provider.CreateAsyncScope();
var ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
scope.ServiceProvider.GetRequiredService<IBundleImporter>().ApplyAsync(sessionId,
new[] { new ImportResolution("Template", "Pump", ResolutionAction.Add, null) }, user: "bob"));
var error = Assert.Single(ex.Errors);
Assert.Contains("cycle", error, StringComparison.Ordinal);
Assert.Contains("Pump.X", error, StringComparison.Ordinal);
Assert.Contains("Pump.Y", error, StringComparison.Ordinal);
await using var check = _provider.CreateAsyncScope();
Assert.False(await check.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Templates.AnyAsync());
}
/// <summary>
/// Format compatibility: a bundle exported by the pre-#118 code (no derivation field —
/// <c>Fixtures/pre-118-derived-plaintext.scadabundle</c>, a Pump whose slot M1 was a
/// derived template of Motor with Speed overridden to 5) imports exactly as it always did:
/// the slot arrives as a base template named <c>M1</c> inheriting from Motor and composed
/// by Pump. Never regenerate the fixture — its value is that old code wrote it.
/// </summary>
[Fact]
public async Task Pre_118_bundle_without_the_field_imports_exactly_as_before()
{
var bundle = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pre-118-derived-plaintext.scadabundle"));
Assert.All(ReadContent(bundle).Templates, t => Assert.Null(t.Derivation));
var session = await LoadAsync(_provider, bundle);
Assert.Equal("1.1", session.Manifest.SchemaVersion);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Equal(new[] { "M1", "Motor", "Pump" }, preview.Items.Select(i => i.Name).OrderBy(n => n, StringComparer.Ordinal));
Assert.All(preview.Items, i => Assert.Equal(ConflictKind.New, i.Kind));
var result = await ApplyDefaultsAsync(_provider, session.SessionId, preview);
Assert.Equal(3, result.Added);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var motor = await ctx.Templates.SingleAsync(t => t.Name == "Motor");
var m1 = await ctx.Templates.Include(t => t.Attributes).SingleAsync(t => t.Name == "M1");
Assert.False(m1.IsDerived);
Assert.Null(m1.OwnerCompositionId);
Assert.Equal(motor.Id, m1.ParentTemplateId);
Assert.Equal("5", Assert.Single(m1.Attributes).Value);
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
Assert.Equal(m1.Id, Assert.Single(pump.Compositions).ComposedTemplateId);
}
/// <summary>
/// A signed bundle carrying derivations verifies and imports the slot as derived; the
/// derivation sits inside the content the signed manifest's content hash pins, so editing
/// it is refused — with the old hash (content mismatch) and with a recomputed hash (the
/// signature over the manifest no longer verifies).
/// </summary>
[Fact]
public async Task Signed_bundle_with_derivations_verifies_and_pins_the_field()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var settings = new Dictionary<string, string?>
{
["ScadaBridge:Transport:Signing:Enabled"] = "true",
["ScadaBridge:Transport:Signing:PrivateKey"] = key.ExportPkcs8PrivateKeyPem(),
["ScadaBridge:Transport:Signing:TrustedKeys:0:Name"] = "self",
["ScadaBridge:Transport:Signing:TrustedKeys:0:PublicKey"] = key.ExportSubjectPublicKeyInfoPem(),
["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true",
};
await using var provider = BuildProvider(settings);
await SeedPumpWithDerivedMotorAsync(provider);
var bundle = await ExportAsync(provider, null, "Pump");
Assert.Contains("\"Derivation\"", ReadEntry(bundle, "content.json"), StringComparison.Ordinal);
Assert.Contains("\"SchemaVersion\":\"1.2\"", ReadEntry(bundle, "manifest.json"), StringComparison.Ordinal);
await WipeTemplatesAsync(provider);
var session = await LoadAsync(provider, bundle);
Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus);
var preview = await PreviewAsync(provider, session.SessionId);
await ApplyDefaultsAsync(provider, session.SessionId, preview);
await using (var scope = provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.True((await LoadSlotAsync(ctx, "Pump", "M1")).IsDerived);
}
// Tamper with the derivation only.
var tamperedContent = ReadEntry(bundle, "content.json").Replace("\"BaseTemplateName\":\"Motor\",\"BaseSlotPath\"",
"\"BaseTemplateName\":\"Pump\",\"BaseSlotPath\"", StringComparison.Ordinal);
Assert.NotEqual(ReadEntry(bundle, "content.json"), tamperedContent);
var staleHash = ReplaceEntries(bundle, ("content.json", tamperedContent));
await Assert.ThrowsAnyAsync<Exception>(() => LoadAsync(provider, staleHash));
var newHash = "sha256:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tamperedContent))).ToLowerInvariant();
var manifest = ReadEntry(bundle, "manifest.json");
var oldHash = JsonDocument.Parse(manifest).RootElement.GetProperty("ContentHash").GetString()!;
var rehashed = ReplaceEntries(bundle,
("content.json", tamperedContent), ("manifest.json", manifest.Replace(oldHash, newHash, StringComparison.Ordinal)));
await Assert.ThrowsAsync<BundleSignatureException>(() => LoadAsync(provider, rehashed));
}
private static string ReadEntry(byte[] bundle, string name)
{
using var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read);
using var reader = new StreamReader(zip.GetEntry(name)!.Open());
return reader.ReadToEnd();
}
private static byte[] ReplaceEntries(byte[] bundle, params (string Name, string Text)[] replacements)
{
var output = new MemoryStream();
using (var source = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read))
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in source.Entries)
{
var replacement = replacements.FirstOrDefault(r => r.Name == entry.FullName);
var newEntry = target.CreateEntry(entry.FullName);
using var dest = newEntry.Open();
if (replacement.Name is not null)
{
var bytes = Encoding.UTF8.GetBytes(replacement.Text);
dest.Write(bytes);
}
else
{
using var src = entry.Open();
src.CopyTo(dest);
}
}
}
return output.ToArray();
}
}