From 8e73e60f4ae678b1561f107bee6ab626bec55322 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 06:16:24 -0400 Subject: [PATCH] feat(transport): restore composition + alarm-script edges on bundle import --- docs/requirements/Component-Transport.md | 6 +- .../Import/BundleImporter.cs | 271 ++++++++++++++- .../CompositionImportTests.cs | 311 ++++++++++++++++++ .../RoundTripTests.cs | 12 +- 4 files changed, 579 insertions(+), 21 deletions(-) create mode 100644 tests/ScadaLink.Transport.IntegrationTests/CompositionImportTests.cs 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);