diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md
index 4b0717e..dacdab5 100644
--- a/docs/requirements/Component-Transport.md
+++ b/docs/requirements/Component-Transport.md
@@ -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`.
---
diff --git a/src/ScadaLink.Transport/Import/BundleImporter.cs b/src/ScadaLink.Transport/Import/BundleImporter.cs
index 13dfe26..881af70 100644
--- a/src/ScadaLink.Transport/Import/BundleImporter.cs
+++ b/src/ScadaLink.Transport/Import/BundleImporter.cs
@@ -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
///
/// Builds a 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. supports the Rename
- /// resolution; pass null to keep the DTO's original name.
+ /// alarms / scripts. Two name-keyed FKs are NOT wired here because they
+ /// require post-flush identity values:
+ ///
+ /// - TemplateAlarm.OnTriggerScriptId — points at a sibling
+ /// TemplateScript; resolved by
+ /// once SaveChangesAsync has assigned script ids.
+ /// - TemplateComposition.ComposedTemplateId — points at any
+ /// other persisted Template; resolved by
+ /// once all bundled templates
+ /// have been flushed and any pre-existing target templates can be joined
+ /// in by name.
+ ///
+ /// Both resolution passes run inside the same outer import transaction.
+ /// supports the Rename resolution; pass
+ /// null to keep the DTO's original name. Renamed templates are
+ /// looked up by their imported name (i.e. RenameTo) when
+ /// the second pass resolves their alarm/composition FKs; however, bundle
+ /// DTOs that reference a renamed template by its original name
+ /// will still fall through to the unresolved-audit path — call sites are
+ /// not rewritten in v1.
///
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
{
@@ -831,6 +875,203 @@ public sealed class BundleImporter : IBundleImporter
return t;
}
+ ///
+ /// 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 OnTriggerScriptName, 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 BundleImportAlarmScriptUnresolved audit row
+ /// (correlation context still carries BundleImportId).
+ ///
+ private async Task ResolveAlarmScriptLinksAsync(
+ IReadOnlyList 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);
+ }
+ }
+ }
+
+ ///
+ /// FU-B / #39 — Pass B of the post-template-flush rewire. For every
+ /// imported template (Add / Overwrite / Rename) whose bundle DTO carries
+ /// any Compositions, replace the persisted template's existing
+ /// composition rows with new ones whose ComposedTemplateId is
+ /// resolved from ComposedTemplateName by looking up the now-
+ /// persisted template (just-imported set first, then pre-existing target).
+ ///
+ /// 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.
+ ///
+ ///
+ /// When ComposedTemplateName cannot be resolved — most commonly
+ /// because the user chose Skip on the referenced template — we emit a
+ /// BundleImportCompositionUnresolved 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.
+ ///
+ ///
+ private async Task ResolveCompositionEdgesAsync(
+ IReadOnlyList 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 dtos,
Dictionary<(string, string), ImportResolution> map,
diff --git a/tests/ScadaLink.Transport.IntegrationTests/CompositionImportTests.cs b/tests/ScadaLink.Transport.IntegrationTests/CompositionImportTests.cs
new file mode 100644
index 0000000..b3c0dc1
--- /dev/null
+++ b/tests/ScadaLink.Transport.IntegrationTests/CompositionImportTests.cs
@@ -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;
+
+///
+/// FU-B — integration coverage for the post-flush second-pass rewire in
+/// BundleImporter.ApplyAsync: composition edges (#39) and
+/// alarm-script FKs (remainder of #37). 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.
+///
+public sealed class CompositionImportTests : IDisposable
+{
+ private readonly ServiceProvider _provider;
+
+ public CompositionImportTests()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(
+ new ConfigurationBuilder().AddInMemoryCollection().Build());
+
+ var dbName = $"CompositionImportTests_{Guid.NewGuid()}";
+ services.AddDbContext(opts => opts
+ .UseInMemoryDatabase(dbName)
+ .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddTransport();
+
+ _provider = services.BuildServiceProvider();
+ }
+
+ public void Dispose() => _provider.Dispose();
+
+ ///
+ /// Builds a bundle of the templates currently in the DB and returns the
+ /// raw bytes. Mirrors the helper in RoundTripTests but parameterised
+ /// to keep the per-test setup terse.
+ ///
+ private async Task ExportAllTemplatesAsync()
+ {
+ await using var scope = _provider.CreateAsyncScope();
+ var exporter = scope.ServiceProvider.GetRequiredService();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
+
+ var selection = new ExportSelection(
+ TemplateIds: templateIds,
+ SharedScriptIds: Array.Empty(),
+ ExternalSystemIds: Array.Empty(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: Array.Empty(),
+ ApiKeyIds: Array.Empty(),
+ ApiMethodIds: Array.Empty(),
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ // 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
+ {
+ 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();
+ 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 "." 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();
+ 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();
+ 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();
+ 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();
+ 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);
+ }
+ }
+}
diff --git a/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs b/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs
index 7c7c9d5..bc3b637 100644
--- a/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs
+++ b/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs
@@ -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);