84a050420f
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
679 lines
37 KiB
C#
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();
|
|
}
|
|
}
|