feat(transport): restore composition + alarm-script edges on bundle import
This commit is contained in:
@@ -22,7 +22,7 @@ The Transport component provides a file-based, encrypted, environment-agnostic w
|
||||
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
||||
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, or a Blocker.
|
||||
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running the pre-deployment semantic validator before committing.
|
||||
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, and `BundleImportUnlockFailed` audit events via `IAuditService`.
|
||||
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, and `BundleImportCompositionUnresolved` audit events via `IAuditService`.
|
||||
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
|
||||
- Enforce `RequireDesign` on export and `RequireAdmin` on import both at the Razor page layer and inside the service entrypoints (defense in depth).
|
||||
|
||||
@@ -252,6 +252,8 @@ Import flows through the same audited repository methods the UI and CLI use, so
|
||||
| External system overwritten | `ExternalSystemDefinitionUpdated` + per-method rows |
|
||||
| Notification list added | `NotificationListCreated` + per-recipient rows |
|
||||
| API key added | `ApiKeyCreated` |
|
||||
| Imported alarm references missing on-trigger script | `BundleImportAlarmScriptUnresolved` (warning; alarm FK left null) |
|
||||
| Imported template's composition references missing target template | `BundleImportCompositionUnresolved` (warning; composition row not written) |
|
||||
|
||||
**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The existing Configuration Audit Log Viewer gains a **Bundle Import** filter that surfaces all rows for a given import. The `BundleImported` summary row links to the filtered view.
|
||||
|
||||
@@ -283,7 +285,7 @@ Import flows through the same audited repository methods the UI and CLI use, so
|
||||
- **Central UI** — Hosts the Export Bundle (`/design/transport/export`) and Import Bundle (`/design/transport/import`) wizard pages under the Design nav group. The result page links to the Deployments page and to the filtered Configuration Audit Log Viewer.
|
||||
- **Deployment Manager** — Never directly invoked by Transport. Transport-driven template changes propagate to deployed instances through the existing revision-hash drift detection in `DeploymentService.CompareAsync`; the Deployments page surfaces affected instances as stale automatically.
|
||||
- **Security & Auth** — Provides `RequireDesign` and `RequireAdmin` policies from `ScadaLink.Security`, enforced at both the page and service layers.
|
||||
- **Audit Log (Configuration)** — Writes `BundleExported` / `BundleImported` / `BundleImportFailed` / `UnencryptedBundleExport` / `BundleImportUnlockFailed` rows via `IAuditService`; per-entity rows from audited repositories are correlated by `BundleImportId` via `IAuditCorrelationContext`.
|
||||
- **Audit Log (Configuration)** — Writes `BundleExported` / `BundleImported` / `BundleImportFailed` / `UnencryptedBundleExport` / `BundleImportUnlockFailed` rows via `IAuditService`, plus per-import name-resolution warnings `BundleImportAlarmScriptUnresolved` and `BundleImportCompositionUnresolved`; per-entity rows from audited repositories are correlated by `BundleImportId` via `IAuditCorrelationContext`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -518,6 +518,27 @@ public sealed class BundleImporter : IBundleImporter
|
||||
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Run semantic validation FIRST — before any writes are staged.
|
||||
// This is purely a name-resolution scan over the in-memory DTOs +
|
||||
// pre-existing target reads, so it has no ordering dependency on
|
||||
// the Apply* helpers. Failing here means the change tracker is
|
||||
// still empty, which keeps the rollback contract simple on both
|
||||
// the in-memory and relational providers (FU-B: intermediate
|
||||
// SaveChangesAsync between Apply* and the second-pass rewire
|
||||
// would otherwise prevent the in-memory provider from undoing
|
||||
// already-flushed template rows on validation failure — the
|
||||
// in-memory transaction is a no-op, so the only safe pattern is
|
||||
// ONE deferred SaveChangesAsync at the very end of try-block).
|
||||
//
|
||||
// Skip-resolved DTOs are excluded from the in-bundle name set so
|
||||
// a Skip on a dependency surfaces as a missing-reference error
|
||||
// rather than silently passing.
|
||||
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
throw new SemanticValidationException(validationErrors);
|
||||
}
|
||||
|
||||
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
@@ -528,15 +549,24 @@ public sealed class BundleImporter : IBundleImporter
|
||||
await ApplyApiKeysAsync(content.ApiKeys, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
|
||||
// Minimal semantic validation — see XML comment above for the v1
|
||||
// scope. Skip-resolved DTOs are excluded from the in-bundle name
|
||||
// set so a Skip on a dependency surfaces as a missing-reference
|
||||
// error rather than silently passing.
|
||||
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
throw new SemanticValidationException(validationErrors);
|
||||
}
|
||||
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
|
||||
// FKs that can only be resolved AFTER every template's scripts and
|
||||
// child rows have been staged. We flush here so Pass A can look up
|
||||
// the just-persisted scripts by name and Pass B can look up the
|
||||
// just-persisted templates by name; both passes stage further
|
||||
// mutations that ride the SAME outer transaction (committed below).
|
||||
//
|
||||
// EF tracking note: AddAsync on the in-memory provider assigns
|
||||
// synthetic ids eagerly, so this intermediate flush mostly
|
||||
// materialises identity values on a relational provider. The
|
||||
// rollback contract is preserved because semantic validation
|
||||
// already ran above — any throw from this point onward represents
|
||||
// a successful merge that the user wants to keep, and the only
|
||||
// remaining failure surface is the final BundleImported audit
|
||||
// write itself.
|
||||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||||
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||||
|
||||
await _auditService.LogAsync(
|
||||
user: user,
|
||||
@@ -785,12 +815,26 @@ public sealed class BundleImporter : IBundleImporter
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Template"/> from a bundle DTO, copying attributes /
|
||||
/// alarms / scripts but deliberately NOT wiring composition edges — those
|
||||
/// need FK ids resolved against the target DB and the first-cut import
|
||||
/// covers the field-level data; rebuilding the composition graph belongs
|
||||
/// in a follow-up task once the target's pre-existing template ids can be
|
||||
/// joined in. <paramref name="overrideName"/> supports the Rename
|
||||
/// resolution; pass <c>null</c> to keep the DTO's original name.
|
||||
/// alarms / scripts. Two name-keyed FKs are NOT wired here because they
|
||||
/// require post-flush identity values:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TemplateAlarm.OnTriggerScriptId</c> — points at a sibling
|
||||
/// <c>TemplateScript</c>; resolved by <see cref="ResolveAlarmScriptLinksAsync"/>
|
||||
/// once <c>SaveChangesAsync</c> has assigned script ids.</item>
|
||||
/// <item><c>TemplateComposition.ComposedTemplateId</c> — points at any
|
||||
/// other persisted <c>Template</c>; resolved by
|
||||
/// <see cref="ResolveCompositionEdgesAsync"/> once all bundled templates
|
||||
/// have been flushed and any pre-existing target templates can be joined
|
||||
/// in by name.</item>
|
||||
/// </list>
|
||||
/// Both resolution passes run inside the same outer import transaction.
|
||||
/// <paramref name="overrideName"/> supports the Rename resolution; pass
|
||||
/// <c>null</c> to keep the DTO's original name. Renamed templates are
|
||||
/// looked up by their <em>imported</em> name (i.e. <c>RenameTo</c>) when
|
||||
/// the second pass resolves their alarm/composition FKs; however, bundle
|
||||
/// DTOs that reference a renamed template by its <em>original</em> name
|
||||
/// will still fall through to the unresolved-audit path — call sites are
|
||||
/// not rewritten in v1.
|
||||
/// </summary>
|
||||
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
|
||||
{
|
||||
@@ -831,6 +875,203 @@ public sealed class BundleImporter : IBundleImporter
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
|
||||
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
|
||||
/// carries any alarm with <c>OnTriggerScriptName</c>, look up the
|
||||
/// persisted alarm + script by name on the same template and set the FK.
|
||||
/// If the named script is missing from the imported template, leave the
|
||||
/// FK null and emit a <c>BundleImportAlarmScriptUnresolved</c> audit row
|
||||
/// (correlation context still carries <c>BundleImportId</c>).
|
||||
/// </summary>
|
||||
private async Task ResolveAlarmScriptLinksAsync(
|
||||
IReadOnlyList<TemplateDto> dtos,
|
||||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||||
string user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (dtos.Count == 0) return;
|
||||
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||
|
||||
// Resolve the imported template by its post-rename name. Skip-
|
||||
// resolved DTOs were already filtered out above; everything else
|
||||
// landed under either the DTO name or RenameTo.
|
||||
var importedName = resolution.Action == ResolutionAction.Rename
|
||||
? (resolution.RenameTo ?? dto.Name)
|
||||
: dto.Name;
|
||||
|
||||
// Use the children-loaded variant so the alarm + script collections
|
||||
// are populated — the unfiltered GetAllTemplatesAsync returns the
|
||||
// tracked entities EF assigned ids to during the flush, but the
|
||||
// navigation collections aren't necessarily included.
|
||||
var stub = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||||
string.Equals(t.Name, importedName, StringComparison.Ordinal));
|
||||
if (stub is null) continue;
|
||||
var template = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
|
||||
if (template is null) continue;
|
||||
|
||||
foreach (var alarmDto in dto.Alarms)
|
||||
{
|
||||
if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;
|
||||
|
||||
var alarm = template.Alarms.FirstOrDefault(a =>
|
||||
string.Equals(a.Name, alarmDto.Name, StringComparison.Ordinal));
|
||||
if (alarm is null) continue;
|
||||
|
||||
var script = template.Scripts.FirstOrDefault(s =>
|
||||
string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.Ordinal));
|
||||
if (script is null)
|
||||
{
|
||||
// Unresolved — emit warning audit row, leave FK null.
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"BundleImportAlarmScriptUnresolved",
|
||||
"TemplateAlarm",
|
||||
alarm.Id.ToString(),
|
||||
$"{template.Name}.{alarm.Name}",
|
||||
new
|
||||
{
|
||||
TemplateName = template.Name,
|
||||
AlarmName = alarm.Name,
|
||||
UnresolvedScriptName = alarmDto.OnTriggerScriptName,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
alarm.OnTriggerScriptId = script.Id;
|
||||
await _templateRepo.UpdateTemplateAlarmAsync(alarm, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FU-B / #39 — Pass B of the post-template-flush rewire. For every
|
||||
/// imported template (Add / Overwrite / Rename) whose bundle DTO carries
|
||||
/// any <c>Compositions</c>, replace the persisted template's existing
|
||||
/// composition rows with new ones whose <c>ComposedTemplateId</c> is
|
||||
/// resolved from <c>ComposedTemplateName</c> by looking up the now-
|
||||
/// persisted template (just-imported set first, then pre-existing target).
|
||||
/// <para>
|
||||
/// Overwrite semantics: the persisted template's existing composition
|
||||
/// rows are CLEARED before re-adding from the bundle so an Overwrite
|
||||
/// truly overwrites the composition graph and doesn't silently retain
|
||||
/// stale edges that aren't in the bundle anymore.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>ComposedTemplateName</c> cannot be resolved — most commonly
|
||||
/// because the user chose Skip on the referenced template — we emit a
|
||||
/// <c>BundleImportCompositionUnresolved</c> audit row and skip the edge.
|
||||
/// We deliberately do NOT throw: a Skip-resolved dependency is a
|
||||
/// legitimate operator choice, not an import-killing error.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task ResolveCompositionEdgesAsync(
|
||||
IReadOnlyList<TemplateDto> dtos,
|
||||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||||
string user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (dtos.Count == 0) return;
|
||||
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||
if (dto.Compositions.Count == 0) continue;
|
||||
|
||||
var importedName = resolution.Action == ResolutionAction.Rename
|
||||
? (resolution.RenameTo ?? dto.Name)
|
||||
: dto.Name;
|
||||
|
||||
var parentStub = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||||
string.Equals(t.Name, importedName, StringComparison.Ordinal));
|
||||
if (parentStub is null) continue;
|
||||
var parent = await _templateRepo.GetTemplateWithChildrenAsync(parentStub.Id, ct).ConfigureAwait(false);
|
||||
if (parent is null) continue;
|
||||
|
||||
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
|
||||
// the deletes via the navigation; explicit DeleteAsync ensures the
|
||||
// change is staged even when the relational provider doesn't
|
||||
// detect orphans automatically.
|
||||
if (parent.Compositions.Count > 0)
|
||||
{
|
||||
var existingComps = parent.Compositions.ToList();
|
||||
foreach (var existing in existingComps)
|
||||
{
|
||||
await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
parent.Compositions.Clear();
|
||||
}
|
||||
|
||||
foreach (var compDto in dto.Compositions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
|
||||
{
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"BundleImportCompositionUnresolved",
|
||||
"TemplateComposition",
|
||||
"0",
|
||||
$"{parent.Name}.{compDto.InstanceName}",
|
||||
new
|
||||
{
|
||||
OwnerTemplateName = parent.Name,
|
||||
compDto.InstanceName,
|
||||
UnresolvedComposedTemplateName = (string?)null,
|
||||
Reason = "Composition DTO carried empty ComposedTemplateName.",
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the composed template by name — first against the
|
||||
// tracked Local set (which includes anything just imported,
|
||||
// pre-flush or post-flush), then against the wider target DB
|
||||
// (pre-existing rows not staged in this transaction).
|
||||
var composedStub = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||||
string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal));
|
||||
int? composedId = composedStub?.Id;
|
||||
if (composedId is null)
|
||||
{
|
||||
var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||||
composedId = allTargets
|
||||
.FirstOrDefault(t => string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal))?.Id;
|
||||
}
|
||||
|
||||
if (composedId is null)
|
||||
{
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"BundleImportCompositionUnresolved",
|
||||
"TemplateComposition",
|
||||
"0",
|
||||
$"{parent.Name}.{compDto.InstanceName}",
|
||||
new
|
||||
{
|
||||
OwnerTemplateName = parent.Name,
|
||||
compDto.InstanceName,
|
||||
UnresolvedComposedTemplateName = compDto.ComposedTemplateName,
|
||||
Reason = "Composed template name not present in bundle or target.",
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var comp = new TemplateComposition(compDto.InstanceName)
|
||||
{
|
||||
TemplateId = parent.Id,
|
||||
ComposedTemplateId = composedId.Value,
|
||||
};
|
||||
await _templateRepo.AddTemplateCompositionAsync(comp, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySharedScriptsAsync(
|
||||
IReadOnlyList<SharedScriptDto> dtos,
|
||||
Dictionary<(string, string), ImportResolution> map,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.ConfigurationDatabase.Services;
|
||||
using ScadaLink.Transport;
|
||||
|
||||
namespace ScadaLink.Transport.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// FU-B — integration coverage for the post-flush second-pass rewire in
|
||||
/// <c>BundleImporter.ApplyAsync</c>: composition edges (<c>#39</c>) and
|
||||
/// alarm-script FKs (remainder of <c>#37</c>). All three scenarios drive the
|
||||
/// full export → load → apply pipeline so the wire-level DTO carries the
|
||||
/// name-keyed references the importer is expected to resolve.
|
||||
/// </summary>
|
||||
public sealed class CompositionImportTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public CompositionImportTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"CompositionImportTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaLinkDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Builds a bundle of the templates currently in the DB and returns the
|
||||
/// raw bytes. Mirrors the helper in <c>RoundTripTests</c> but parameterised
|
||||
/// to keep the per-test setup terse.
|
||||
/// </summary>
|
||||
private async Task<byte[]> ExportAllTemplatesAsync()
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiKeyIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: false);
|
||||
|
||||
var stream = await exporter.ExportAsync(selection,
|
||||
user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private async Task WipeTemplatesAsync()
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
|
||||
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
|
||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Composition_edges_are_restored_on_import()
|
||||
{
|
||||
// Seed: Template A composes Template B via InstanceName="b1".
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("B") { Description = "leaf" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
|
||||
|
||||
var a = new Template("A") { Description = "composer" };
|
||||
ctx.Templates.Add(a);
|
||||
await ctx.SaveChangesAsync();
|
||||
a.Compositions.Add(new TemplateComposition("b1")
|
||||
{
|
||||
TemplateId = a.Id,
|
||||
ComposedTemplateId = b.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleBytes = await ExportAllTemplatesAsync();
|
||||
await WipeTemplatesAsync();
|
||||
|
||||
// Load + preview + apply with Add for both.
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
using var ms = new MemoryStream(bundleBytes, writable: false);
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
preview = await importer.PreviewAsync(sessionId);
|
||||
}
|
||||
var resolutions = preview.Items
|
||||
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
|
||||
.ToList();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
||||
}
|
||||
|
||||
// Assert: A has exactly one TemplateComposition pointing at B.
|
||||
await using (var assertScope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var a = await ctx.Templates
|
||||
.Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Name == "A");
|
||||
var comp = Assert.Single(a.Compositions);
|
||||
Assert.Equal("b1", comp.InstanceName);
|
||||
|
||||
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
|
||||
Assert.Equal(b.Id, comp.ComposedTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Composition_referencing_skipped_template_emits_unresolved_audit_and_skips_edge()
|
||||
{
|
||||
// Same seed as the happy-path test; the divergence is in the Apply
|
||||
// resolutions — B is Skip-resolved so its composition reference is
|
||||
// expected to surface as a BundleImportCompositionUnresolved audit row
|
||||
// and the composition edge must NOT be written.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("B") { Description = "leaf" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
|
||||
|
||||
var a = new Template("A") { Description = "composer" };
|
||||
ctx.Templates.Add(a);
|
||||
await ctx.SaveChangesAsync();
|
||||
a.Compositions.Add(new TemplateComposition("b1")
|
||||
{
|
||||
TemplateId = a.Id,
|
||||
ComposedTemplateId = b.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleBytes = await ExportAllTemplatesAsync();
|
||||
await WipeTemplatesAsync();
|
||||
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
using var ms = new MemoryStream(bundleBytes, writable: false);
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
// Add A but Skip B. The composition's ComposedTemplateName="B"
|
||||
// therefore can't resolve (B isn't being written and isn't in the
|
||||
// target — we wiped) and must surface as an unresolved audit row.
|
||||
var resolutions = new List<ImportResolution>
|
||||
{
|
||||
new("Template", "A", ResolutionAction.Add, null),
|
||||
new("Template", "B", ResolutionAction.Skip, null),
|
||||
};
|
||||
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
||||
}
|
||||
|
||||
await using (var assertScope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var a = await ctx.Templates
|
||||
.Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Name == "A");
|
||||
// Composition couldn't resolve → zero edges on A.
|
||||
Assert.Empty(a.Compositions);
|
||||
|
||||
// B was skipped → still not in target.
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "B"));
|
||||
|
||||
// BundleImportCompositionUnresolved audit row exists and is
|
||||
// correlated by the run's BundleImportId.
|
||||
var unresolved = await ctx.AuditLogEntries
|
||||
.Where(e => e.Action == "BundleImportCompositionUnresolved")
|
||||
.ToListAsync();
|
||||
var row = Assert.Single(unresolved);
|
||||
Assert.Equal(result.BundleImportId, row.BundleImportId);
|
||||
Assert.Equal("TemplateComposition", row.EntityType);
|
||||
// Entity name is "<owner>.<instance>" so an operator can find the
|
||||
// offending row in the Configuration Audit Log Viewer quickly.
|
||||
Assert.Equal("A.b1", row.EntityName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTriggerScript_alarm_link_is_restored_on_import()
|
||||
{
|
||||
// Seed: a template with a "Startup" script and an alarm whose
|
||||
// OnTriggerScriptId points at "Startup". The DTO carries
|
||||
// OnTriggerScriptName="Startup" and the importer's second pass should
|
||||
// re-resolve the FK to Startup's new id.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("Pump") { Description = "with alarm" };
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var script = new TemplateScript("Startup", "return 1;")
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
};
|
||||
t.Scripts.Add(script);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
t.Alarms.Add(new TemplateAlarm("High")
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
PriorityLevel = 2,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
OnTriggerScriptId = script.Id,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleBytes = await ExportAllTemplatesAsync();
|
||||
await WipeTemplatesAsync();
|
||||
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
using var ms = new MemoryStream(bundleBytes, writable: false);
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var preview = await importer.PreviewAsync(sessionId);
|
||||
var resolutions = preview.Items
|
||||
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
|
||||
.ToList();
|
||||
await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
||||
}
|
||||
|
||||
await using (var assertScope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var pump = await ctx.Templates
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.SingleAsync(t => t.Name == "Pump");
|
||||
|
||||
var script = Assert.Single(pump.Scripts);
|
||||
Assert.Equal("Startup", script.Name);
|
||||
|
||||
var alarm = Assert.Single(pump.Alarms);
|
||||
Assert.Equal("High", alarm.Name);
|
||||
// FK rewired to the imported Startup script's NEW id (not the
|
||||
// pre-export id, which is gone after the wipe).
|
||||
Assert.Equal(script.Id, alarm.OnTriggerScriptId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,10 +221,14 @@ public sealed class RoundTripTests : IDisposable
|
||||
.Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Name == "Pump");
|
||||
Assert.Equal("composes BaseDevice", pump.Description);
|
||||
// Composition edges intentionally NOT restored by the v1 import
|
||||
// (see BundleImporter.BuildTemplate XML comment). Round-trip's
|
||||
// field-level identity holds; rebuilding the composition graph
|
||||
// belongs to a follow-up task.
|
||||
// FU-B / #39 — composition graph IS restored on import. The bundle
|
||||
// carried Pump composing BaseDevice via InstanceName="base"; the
|
||||
// importer's second pass (ResolveCompositionEdgesAsync) re-resolved
|
||||
// ComposedTemplateName to BaseDevice's new id after the template
|
||||
// flush and re-added the row.
|
||||
var pumpComp = Assert.Single(pump.Compositions);
|
||||
Assert.Equal("base", pumpComp.InstanceName);
|
||||
Assert.Equal(baseDevice.Id, pumpComp.ComposedTemplateId);
|
||||
|
||||
var standalone = await ctx.Templates.SingleAsync(t => t.Name == "Standalone");
|
||||
Assert.Equal("no deps", standalone.Description);
|
||||
|
||||
Reference in New Issue
Block a user