From 2c34f12a6f753f509f4b3ac3fb0915e5285053c1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 04:55:43 -0400 Subject: [PATCH] feat(transport): BundleImporter.ApplyAsync transactional with audit correlation --- .../Import/BundleImporter.cs | 889 +++++++++++++++++- .../Import/SemanticValidationException.cs | 34 + .../Import/BundleImporterApplyTests.cs | 445 +++++++++ 3 files changed, 1363 insertions(+), 5 deletions(-) create mode 100644 src/ScadaLink.Transport/Import/SemanticValidationException.cs create mode 100644 tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs diff --git a/src/ScadaLink.Transport/Import/BundleImporter.cs b/src/ScadaLink.Transport/Import/BundleImporter.cs index 79bc21b..751a595 100644 --- a/src/ScadaLink.Transport/Import/BundleImporter.cs +++ b/src/ScadaLink.Transport/Import/BundleImporter.cs @@ -2,6 +2,10 @@ using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; +using ScadaLink.Commons.Entities.ExternalSystems; +using ScadaLink.Commons.Entities.InboundApi; +using ScadaLink.Commons.Entities.Notifications; +using ScadaLink.Commons.Entities.Scripts; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -35,12 +39,10 @@ public sealed class BundleImporter : IBundleImporter private readonly ManifestValidator _manifestValidator; private readonly BundleSecretEncryptor _encryptor; private readonly ArtifactDiff _diff = new(); -#pragma warning disable IDE0052 // wired-in dependencies for T17. private readonly EntitySerializer _entitySerializer; private readonly IAuditService _auditService; private readonly IAuditCorrelationContext _correlationContext; private readonly ScadaLinkDbContext _dbContext; -#pragma warning restore IDE0052 private readonly ITemplateEngineRepository _templateRepo; private readonly IExternalSystemRepository _externalRepo; private readonly INotificationRepository _notificationRepo; @@ -422,13 +424,890 @@ public sealed class BundleImporter : IBundleImporter private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c); private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c); - public Task ApplyAsync( + /// + /// Writes the bundle's artifacts to the central DB inside a single + /// transaction, threading BundleImportId through every audit row via + /// the scoped . + /// + /// Apply ordering — folders → templates → shared scripts → external systems + /// → database connections → notification lists → SMTP configs → API keys → + /// API methods — matches the dependency edges in the design doc so each + /// later category can resolve name-keyed references to earlier ones. + /// + /// + /// Semantic validation is the minimal v1 variant: every script-callable + /// identifier referenced by the merged target must resolve to either a + /// pre-existing or in-bundle SharedScript / ExternalSystem. + /// Wiring the full + /// requires running the flattening pipeline over the merged target, which + /// isn't reachable from the import path without a fixture — deferred to a + /// follow-up; today's check catches the same crash surface the operator + /// would otherwise hit at deploy time. The minimal check is run AGAINST the + /// merged target (incoming-bundle DTOs already in memory, target DB read + /// inside the transaction) so a Skip resolution can legitimately fail + /// validation if it would have provided a missing dependency. + /// + /// + /// Audit-row contract: every per-entity write goes through + /// with the correlation context set to + /// 's import id, plus a summary + /// BundleImported row inside the transaction. On failure the + /// transaction rolls back and a single BundleImportFailed row is + /// written OUTSIDE the rolled-back scope (correlation cleared first so the + /// row doesn't carry a non-existent import id). + /// + /// + public async Task ApplyAsync( Guid sessionId, IReadOnlyList resolutions, string user, CancellationToken ct = default) { - // Filled in by T17. - throw new NotImplementedException("ApplyAsync is implemented by task T17."); + ArgumentNullException.ThrowIfNull(resolutions); + ArgumentNullException.ThrowIfNull(user); + + var session = _sessionStore.Get(sessionId) + ?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired."); + if (session.Locked) + { + throw new InvalidOperationException($"Bundle session {sessionId} is locked."); + } + + BundleContentDto content; + try + { + content = JsonSerializer.Deserialize(session.DecryptedContent, ContentJsonOptions) + ?? throw new InvalidDataException("Session content deserialized to null."); + } + catch (JsonException ex) + { + throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex); + } + + var bundleImportId = Guid.NewGuid(); + var resolutionMap = resolutions.ToDictionary( + r => (r.EntityType, r.Name), + r => r); + var summary = new ImportSummary(); + + // Set the correlation BEFORE the transaction so any audit writes + // triggered during the apply pick up the BundleImportId — AuditService + // reads the scoped context at the moment LogAsync is called. + _correlationContext.BundleImportId = bundleImportId; + + // BeginTransactionAsync is a no-op on the in-memory EF provider (which + // logs an InMemoryEventId.TransactionIgnoredWarning by default). To keep + // rollback semantics testable on in-memory AND correct on relational + // providers, we defer the SINGLE SaveChangesAsync call until just before + // CommitAsync — every Add*Async + LogAsync call only stages on the + // change tracker, so throwing before SaveChangesAsync naturally undoes + // the entire apply on both providers. + await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false); + try + { + 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); + await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false); + await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false); + await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false); + await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false); + 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); + } + + await _auditService.LogAsync( + user: user, + action: "BundleImported", + entityType: "Bundle", + entityId: bundleImportId.ToString(), + entityName: session.Manifest.SourceEnvironment, + afterState: new + { + BundleImportId = bundleImportId, + session.Manifest.SourceEnvironment, + session.Manifest.ContentHash, + Summary = new + { + summary.Added, + summary.Overwritten, + summary.Skipped, + summary.Renamed, + }, + }, + cancellationToken: ct).ConfigureAwait(false); + + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await tx.CommitAsync(ct).ConfigureAwait(false); + + _sessionStore.Remove(sessionId); + + return new ImportResult( + BundleImportId: bundleImportId, + Added: summary.Added, + Overwritten: summary.Overwritten, + Skipped: summary.Skipped, + Renamed: summary.Renamed, + StaleInstanceIds: Array.Empty(), + AuditEventCorrelation: bundleImportId.ToString()); + } + catch (Exception ex) + { + await tx.RollbackAsync(ct).ConfigureAwait(false); + + // Clear the change tracker before writing the failure row — on the + // in-memory provider the rollback is a no-op and the staged adds + // would otherwise persist when the next SaveChangesAsync runs. + _dbContext.ChangeTracker.Clear(); + + // Clear correlation FIRST so the failure row doesn't carry the now- + // rolled-back BundleImportId. The contract is: BundleImportFailed + // exists at top level (no correlation) so audit consumers can see + // imports that aborted before any rows landed. + _correlationContext.BundleImportId = null; + await _auditService.LogAsync( + user: user, + action: "BundleImportFailed", + entityType: "Bundle", + entityId: bundleImportId.ToString(), + entityName: session.Manifest.SourceEnvironment, + afterState: new + { + BundleImportId = bundleImportId, + Reason = ex.Message, + ExceptionType = ex.GetType().FullName, + }, + cancellationToken: ct).ConfigureAwait(false); + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + throw; + } + finally + { + // Always clear — even on the success path the correlation only + // applies to the apply we just finished. Subsequent operations on + // this scope (e.g. a second concurrent apply on a circuit) must + // not inherit the import id. + _correlationContext.BundleImportId = null; + } + } + + /// Mutable per-apply counter struct, accumulated through every helper. + private sealed class ImportSummary + { + public int Added { get; set; } + public int Overwritten { get; set; } + public int Skipped { get; set; } + public int Renamed { get; set; } + } + + /// + /// Returns the resolution for the given (entityType, name) tuple, defaulting to + /// when no explicit resolution was supplied — + /// the diff engine surfaces every artifact in the preview so any missing + /// entry means the UI didn't override the default, which for a New artifact + /// is Add. This keeps the apply tolerant of partial-resolution payloads. + /// + private static ImportResolution ResolveOrDefault( + Dictionary<(string EntityType, string Name), ImportResolution> map, + string entityType, + string name) + { + return map.TryGetValue((entityType, name), out var r) + ? r + : new ImportResolution(entityType, name, ResolutionAction.Add, RenameTo: null); + } + + private async Task ApplyTemplateFoldersAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + var existing = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false); + var byName = existing.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal); + + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var folder = new TemplateFolder(name) { SortOrder = dto.SortOrder }; + await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", name, + new { folder.Name, folder.SortOrder, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex): + ex.SortOrder = dto.SortOrder; + await _templateRepo.UpdateFolderAsync(ex, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "TemplateFolder", ex.Id.ToString(), ex.Name, + new { ex.Name, ex.SortOrder }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var folder = new TemplateFolder(dto.Name) { SortOrder = dto.SortOrder }; + await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", folder.Name, + new { folder.Name, folder.SortOrder }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private async Task ApplyTemplatesAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + var stubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false); + var byName = stubs.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal); + + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "Template", dto.Name); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var t = BuildTemplate(dto, overrideName: name); + await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "Template", "0", name, + new { Name = name, dto.Description, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex): + ex.Description = dto.Description; + await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name, + new { ex.Name, ex.Description }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var t = BuildTemplate(dto, overrideName: null); + await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "Template", "0", t.Name, + new { t.Name, t.Description }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + /// + /// 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. + /// + private static Template BuildTemplate(TemplateDto dto, string? overrideName) + { + var t = new Template(overrideName ?? dto.Name) { Description = dto.Description }; + foreach (var a in dto.Attributes) + { + t.Attributes.Add(new TemplateAttribute(a.Name) + { + Value = a.Value, + DataType = a.DataType, + IsLocked = a.IsLocked, + Description = a.Description, + DataSourceReference = a.DataSourceReference, + }); + } + foreach (var al in dto.Alarms) + { + t.Alarms.Add(new TemplateAlarm(al.Name) + { + Description = al.Description, + PriorityLevel = al.PriorityLevel, + TriggerType = al.TriggerType, + TriggerConfiguration = al.TriggerConfiguration, + IsLocked = al.IsLocked, + }); + } + foreach (var s in dto.Scripts) + { + t.Scripts.Add(new TemplateScript(s.Name, s.Code) + { + TriggerType = s.TriggerType, + TriggerConfiguration = s.TriggerConfiguration, + ParameterDefinitions = s.ParameterDefinitions, + ReturnDefinition = s.ReturnDefinition, + IsLocked = s.IsLocked, + }); + } + return t; + } + + private async Task ApplySharedScriptsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "SharedScript", dto.Name); + var existing = await _templateRepo.GetSharedScriptByNameAsync(dto.Name, ct).ConfigureAwait(false); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var s = new SharedScript(name, dto.Code) + { + ParameterDefinitions = dto.ParameterDefinitions, + ReturnDefinition = dto.ReturnDefinition, + }; + await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "SharedScript", "0", name, + new { s.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when existing is not null: + existing.Code = dto.Code; + existing.ParameterDefinitions = dto.ParameterDefinitions; + existing.ReturnDefinition = dto.ReturnDefinition; + await _templateRepo.UpdateSharedScriptAsync(existing, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "SharedScript", existing.Id.ToString(), existing.Name, + new { existing.Name }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var s = new SharedScript(dto.Name, dto.Code) + { + ParameterDefinitions = dto.ParameterDefinitions, + ReturnDefinition = dto.ReturnDefinition, + }; + await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "SharedScript", "0", s.Name, + new { s.Name }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private async Task ApplyExternalSystemsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name); + var existing = await _externalRepo.GetExternalSystemByNameAsync(dto.Name, ct).ConfigureAwait(false); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var sys = BuildExternalSystem(dto, overrideName: name); + await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", name, + new { sys.Name, sys.EndpointUrl, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when existing is not null: + existing.EndpointUrl = dto.BaseUrl; + existing.AuthType = dto.AuthType; + existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null; + await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name, + new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var sys = BuildExternalSystem(dto, overrideName: null); + await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", sys.Name, + new { sys.Name, sys.EndpointUrl }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private static ExternalSystemDefinition BuildExternalSystem(ExternalSystemDto dto, string? overrideName) + { + var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType) + { + AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null, + }; + return sys; + } + + private async Task ApplyDatabaseConnectionsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "DatabaseConnection", dto.Name); + var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(dto.Name, ct).ConfigureAwait(false); + var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty; + + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var c = new DatabaseConnectionDefinition(name, connStr) + { + MaxRetries = dto.MaxRetries, + RetryDelay = dto.RetryDelay, + }; + await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", name, + new { c.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when existing is not null: + existing.ConnectionString = connStr; + existing.MaxRetries = dto.MaxRetries; + existing.RetryDelay = dto.RetryDelay; + await _externalRepo.UpdateDatabaseConnectionAsync(existing, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "DatabaseConnection", existing.Id.ToString(), existing.Name, + new { existing.Name }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var c = new DatabaseConnectionDefinition(dto.Name, connStr) + { + MaxRetries = dto.MaxRetries, + RetryDelay = dto.RetryDelay, + }; + await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", c.Name, + new { c.Name }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private async Task ApplyNotificationListsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "NotificationList", dto.Name); + var existing = await _notificationRepo.GetListByNameAsync(dto.Name, ct).ConfigureAwait(false); + + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var list = BuildNotificationList(dto, overrideName: name); + await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "NotificationList", "0", name, + new { list.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when existing is not null: + existing.Type = dto.Type; + // Recipient sync is structural — clear + re-add. The repo + // mutates the navigation collection, EF tracks the delete. + existing.Recipients.Clear(); + foreach (var r in dto.Recipients) + { + existing.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)); + } + await _notificationRepo.UpdateNotificationListAsync(existing, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "NotificationList", existing.Id.ToString(), existing.Name, + new { existing.Name, RecipientCount = existing.Recipients.Count }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var list = BuildNotificationList(dto, overrideName: null); + await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "NotificationList", "0", list.Name, + new { list.Name, RecipientCount = list.Recipients.Count }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private static NotificationList BuildNotificationList(NotificationListDto dto, string? overrideName) + { + var list = new NotificationList(overrideName ?? dto.Name) { Type = dto.Type }; + foreach (var r in dto.Recipients) + { + list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)); + } + return list; + } + + private async Task ApplySmtpConfigsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + var all = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false); + var byHost = all.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal); + + foreach (var dto in dtos) + { + // SmtpConfiguration is keyed by Host in the diff engine — mirror + // that here so a Rename targets Host, not an arbitrary "name". + var resolution = ResolveOrDefault(map, "SmtpConfiguration", dto.Host); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var host = resolution.RenameTo ?? dto.Host; + var smtp = BuildSmtp(dto, overrideHost: host); + await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", host, + new { smtp.Host, RenamedFrom = dto.Host }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when byHost.TryGetValue(dto.Host, out var ex): + ApplySmtpFields(ex, dto); + await _notificationRepo.UpdateSmtpConfigurationAsync(ex, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "SmtpConfiguration", ex.Id.ToString(), ex.Host, + new { ex.Host }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var smtp = BuildSmtp(dto, overrideHost: null); + await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", smtp.Host, + new { smtp.Host }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private static SmtpConfiguration BuildSmtp(SmtpConfigDto dto, string? overrideHost) + { + var smtp = new SmtpConfiguration(overrideHost ?? dto.Host, dto.AuthType, dto.FromAddress); + ApplySmtpFields(smtp, dto); + return smtp; + } + + private static void ApplySmtpFields(SmtpConfiguration target, SmtpConfigDto dto) + { + target.Port = dto.Port; + target.AuthType = dto.AuthType; + target.FromAddress = dto.FromAddress; + target.TlsMode = dto.TlsMode; + target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds; + target.MaxConcurrentConnections = dto.MaxConcurrentConnections; + target.MaxRetries = dto.MaxRetries; + target.RetryDelay = dto.RetryDelay; + target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null; + } + + private async Task ApplyApiKeysAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + var all = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false); + var byName = all.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal); + + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "ApiKey", dto.Name); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var key = ApiKey.FromHash(name, dto.KeyHash); + key.IsEnabled = dto.IsEnabled; + await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "ApiKey", "0", name, + new { key.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex): + ex.KeyHash = dto.KeyHash; + ex.IsEnabled = dto.IsEnabled; + await _inboundApiRepo.UpdateApiKeyAsync(ex, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "ApiKey", ex.Id.ToString(), ex.Name, + new { ex.Name, ex.IsEnabled }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var key = ApiKey.FromHash(dto.Name, dto.KeyHash); + key.IsEnabled = dto.IsEnabled; + await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "ApiKey", "0", key.Name, + new { key.Name, key.IsEnabled }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private async Task ApplyApiMethodsAsync( + IReadOnlyList dtos, + Dictionary<(string, string), ImportResolution> map, + string user, + ImportSummary summary, + CancellationToken ct) + { + if (dtos.Count == 0) return; + foreach (var dto in dtos) + { + var resolution = ResolveOrDefault(map, "ApiMethod", dto.Name); + var existing = await _inboundApiRepo.GetMethodByNameAsync(dto.Name, ct).ConfigureAwait(false); + switch (resolution.Action) + { + case ResolutionAction.Skip: + summary.Skipped++; + break; + case ResolutionAction.Rename: + { + var name = resolution.RenameTo ?? dto.Name; + var m = BuildApiMethod(dto, overrideName: name); + await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "ApiMethod", "0", name, + new { m.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false); + summary.Renamed++; + break; + } + case ResolutionAction.Overwrite when existing is not null: + existing.Script = dto.Script; + existing.ApprovedApiKeyIds = dto.ApprovedApiKeyIds; + existing.ParameterDefinitions = dto.ParameterDefinitions; + existing.ReturnDefinition = dto.ReturnDefinition; + existing.TimeoutSeconds = dto.TimeoutSeconds; + await _inboundApiRepo.UpdateApiMethodAsync(existing, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Update", "ApiMethod", existing.Id.ToString(), existing.Name, + new { existing.Name }, ct).ConfigureAwait(false); + summary.Overwritten++; + break; + case ResolutionAction.Add: + case ResolutionAction.Overwrite: + default: + { + var m = BuildApiMethod(dto, overrideName: null); + await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false); + await _auditService.LogAsync(user, "Create", "ApiMethod", "0", m.Name, + new { m.Name }, ct).ConfigureAwait(false); + summary.Added++; + break; + } + } + } + } + + private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName) + { + return new ApiMethod(overrideName ?? dto.Name, dto.Script) + { + ApprovedApiKeyIds = dto.ApprovedApiKeyIds, + ParameterDefinitions = dto.ParameterDefinitions, + ReturnDefinition = dto.ReturnDefinition, + TimeoutSeconds = dto.TimeoutSeconds, + }; + } + + /// + /// Minimal v1 semantic validation: scan every TemplateScript / ApiMethod + /// body in the (post-merge) target for identifier-shaped references that + /// cannot resolve to either a pre-existing or in-bundle SharedScript / + /// ExternalSystem. Mirrors the algorithm used by DetectBlockersAsync + /// in the preview path, but operates against the actual merge result — + /// Skip-resolved DTOs are excluded from the in-bundle name set, so a Skip + /// that would have provided a dependency surfaces here as an error. + /// + /// The full TemplateEngine.Validation.SemanticValidator (which + /// requires a FlattenedConfiguration built from the central template + /// graph) is deferred to a follow-up — wiring it into the import path + /// without a flattening fixture is non-trivial and the simpler check + /// covers the same crash surface (unresolvable callsites at runtime). + /// + /// + private async Task> RunSemanticValidationAsync( + BundleContentDto content, + Dictionary<(string, string), ImportResolution> resolutionMap, + CancellationToken ct) + { + var errors = new List(); + + // Build the known-resolvable set. For in-bundle entries, EXCLUDE the + // Skip-resolved names — those aren't being written, so they can't + // satisfy a downstream reference. Renamed entries register under both + // their original DTO name (so the script body in the bundle still + // resolves) AND the new name; the v1 import doesn't rewrite call sites. + var sharedScriptNames = new HashSet(StringComparer.Ordinal); + foreach (var s in content.SharedScripts) + { + var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name); + if (resolution.Action == ResolutionAction.Skip) continue; + sharedScriptNames.Add(s.Name); + if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)) + { + sharedScriptNames.Add(resolution.RenameTo); + } + } + var externalSystemNames = new HashSet(StringComparer.Ordinal); + foreach (var e in content.ExternalSystems) + { + var resolution = ResolveOrDefault(resolutionMap, "ExternalSystem", e.Name); + if (resolution.Action == ResolutionAction.Skip) continue; + externalSystemNames.Add(e.Name); + if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)) + { + externalSystemNames.Add(resolution.RenameTo); + } + } + + // Pre-existing target entries always count as resolvable. + foreach (var s in await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false)) + { + sharedScriptNames.Add(s.Name); + } + foreach (var e in await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false)) + { + externalSystemNames.Add(e.Name); + } + + // Collect every identifier-shaped call target from the bundle's + // templates + api methods. We only check the bundle's bodies here + // (matching PreviewAsync's blocker scan); pre-existing target rows are + // assumed already validated when they were originally written. + var referenced = new HashSet(StringComparer.Ordinal); + foreach (var t in content.Templates) + { + // Skip-resolved templates aren't being written, so their script + // references don't need to resolve. + var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name); + if (resolution.Action == ResolutionAction.Skip) continue; + foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referenced); + foreach (var a in t.Attributes) + { + CollectCallIdentifiers(a.Value, referenced); + CollectCallIdentifiers(a.DataSourceReference, referenced); + } + } + foreach (var m in content.ApiMethods) + { + var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name); + if (resolution.Action == ResolutionAction.Skip) continue; + CollectCallIdentifiers(m.Script, referenced); + } + + foreach (var candidate in referenced.OrderBy(n => n, StringComparer.Ordinal)) + { + if (!LooksLikeResourceName(candidate)) continue; + if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue; + errors.Add( + $"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target."); + } + + return errors; } } diff --git a/src/ScadaLink.Transport/Import/SemanticValidationException.cs b/src/ScadaLink.Transport/Import/SemanticValidationException.cs new file mode 100644 index 0000000..50765a6 --- /dev/null +++ b/src/ScadaLink.Transport/Import/SemanticValidationException.cs @@ -0,0 +1,34 @@ +namespace ScadaLink.Transport.Import; + +/// +/// Thrown when the post-apply semantic validation pass detects that the merged +/// target configuration would not be deployable — e.g. a template script +/// references a SharedScript or ExternalSystem that exists in neither the +/// bundle nor the (post-merge) target database. +/// +/// The exception is caught inside to +/// roll back the transaction, emit a BundleImportFailed audit row, and +/// re-throw to the caller so the UI can surface the specific errors. It is +/// deliberately distinct from so the +/// caller can distinguish "your bundle is bad" from "the import infra is bad". +/// +/// +public sealed class SemanticValidationException : Exception +{ + public IReadOnlyList Errors { get; } + + public SemanticValidationException(IReadOnlyList errors) + : base(BuildMessage(errors)) + { + Errors = errors ?? throw new ArgumentNullException(nameof(errors)); + } + + private static string BuildMessage(IReadOnlyList errors) + { + if (errors is null || errors.Count == 0) + { + return "Bundle semantic validation failed."; + } + return "Bundle semantic validation failed: " + string.Join("; ", errors); + } +} diff --git a/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs new file mode 100644 index 0000000..e6c54ea --- /dev/null +++ b/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs @@ -0,0 +1,445 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Commons.Entities.Scripts; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Interfaces.Transport; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Services; +using ScadaLink.Transport; +using ScadaLink.Transport.Import; + +namespace ScadaLink.Transport.IntegrationTests.Import; + +/// +/// Integration tests for . Reuses the +/// in-memory host pattern from and +/// BundleExporterTests: real repositories, real EF in-memory provider, +/// real Transport pipeline. +/// +/// In-memory EF caveat: +/// is a no-op on this provider, so the rollback test depends on ApplyAsync's +/// implementation deferring SaveChangesAsync to a single call just +/// before CommitAsync. The implementation enforces that contract + +/// calls ChangeTracker.Clear() on the catch path to defend against +/// in-memory bleed-through; the rollback test asserts via row counts that the +/// invariant holds. +/// +/// +public sealed class BundleImporterApplyTests : IDisposable +{ + private readonly ServiceProvider _provider; + + public BundleImporterApplyTests() + { + var services = new ServiceCollection(); + services.AddSingleton( + new ConfigurationBuilder().AddInMemoryCollection().Build()); + + var dbName = $"BundleImporterApplyTests_{Guid.NewGuid()}"; + // In-memory provider throws by default when BeginTransactionAsync is + // called (InMemoryEventId.TransactionIgnoredWarning is escalated to an + // exception). ApplyAsync legitimately opens a transaction for + // relational providers; downgrade the warning here so the in-memory + // run is a no-op and the rest of the apply runs through. See the + // ApplyAsync XML comment for the rollback-safety contract that makes + // this safe (single deferred SaveChangesAsync + ChangeTracker.Clear + // on catch). + 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(); + + // ---- helpers ---- + + /// + /// Exports the entire seeded content as a bundle, then immediately loads it + /// via and returns the opened + /// session. Used by every test that needs a session to feed + /// . Selection is "all templates + + /// all shared scripts" because the tests want the bundle to carry whatever + /// the test seeded. + /// + private async Task ExportAndLoadAsync() + { + Stream bundleStream; + 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 sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync(); + var selection = new ExportSelection( + TemplateIds: templateIds, + SharedScriptIds: sharedScriptIds, + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiKeyIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: false); + bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + } + + using var ms = new MemoryStream(); + await bundleStream.CopyToAsync(ms); + ms.Position = 0; + + await using var loadScope = _provider.CreateAsyncScope(); + var importer = loadScope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(ms, passphrase: null); + return session.SessionId; + } + + private async Task WipeContentAsync() + { + await using var scope = _provider.CreateAsyncScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.RemoveRange(ctx.Templates); + ctx.SharedScripts.RemoveRange(ctx.SharedScripts); + ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders); + await ctx.SaveChangesAsync(); + } + + // ---- tests ---- + + [Fact] + public async Task ApplyAsync_adds_new_artifacts_in_single_transaction() + { + // Arrange: seed → export → wipe → apply. The wipe ensures the import + // is exercising the Add path (the bundle's artifacts are absent from + // the target). + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); + ctx.Templates.Add(new Template("Pump") { Description = "fresh" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await WipeContentAsync(); + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var resolutions = new List + { + new("Template", "Pump", ResolutionAction.Add, null), + new("SharedScript", "HelperFn", ResolutionAction.Add, null), + }; + result = await importer.ApplyAsync(sessionId, resolutions, user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump")); + Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn")); + } + Assert.Equal(2, result.Added); + Assert.Equal(0, result.Overwritten); + Assert.Equal(0, result.Skipped); + Assert.NotEqual(Guid.Empty, result.BundleImportId); + } + + [Fact] + public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite() + { + // Arrange: seed Pump with Description=new, export, then mutate to + // Description=old. The bundle still carries "new". Overwrite must + // restore the description. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "new" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); + t.Description = "old"; + await ctx.SaveChangesAsync(); + } + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, + user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); + Assert.Equal("new", t.Description); + } + Assert.Equal(1, result.Overwritten); + } + + [Fact] + public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip() + { + // Arrange: identical seed + bundle; Skip resolution should leave + // target unchanged and bump Skipped count. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "stable" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("Template", "Pump", ResolutionAction.Skip, null) }, + user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + // Exactly one Pump still, with Description unchanged. + var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); + Assert.Equal("stable", t.Description); + } + Assert.Equal(1, result.Skipped); + Assert.Equal(0, result.Added); + Assert.Equal(0, result.Overwritten); + } + + [Fact] + public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename() + { + // Arrange: seed X, export, wipe so the Rename target Y doesn't + // collide. Apply Rename X→Y. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("X") { Description = "orig" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await WipeContentAsync(); + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("Template", "X", ResolutionAction.Rename, "Y") }, + user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await ctx.Templates.CountAsync(t => t.Name == "X")); + Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Y")); + } + Assert.Equal(1, result.Renamed); + } + + [Fact] + public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails() + { + // Arrange: seed a template whose script body calls MissingHelper(). + // No SharedScript by that name exists in source or (after wipe) in the + // target, so semantic validation must reject the apply. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("BrokenPump") { Description = "broken" }; + t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await WipeContentAsync(); + + // Act + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await Assert.ThrowsAsync(() => + importer.ApplyAsync(sessionId, + new List { new("Template", "BrokenPump", ResolutionAction.Add, null) }, + user: "bob")); + } + + // Assert — target still wiped (template not committed), AND a + // BundleImportFailed row exists. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await ctx.Templates.CountAsync()); + Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed")); + } + } + + [Fact] + public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row() + { + // The correlation guarantee — every per-entity audit row emitted during + // ApplyAsync must carry the same BundleImportId as the returned result. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); + ctx.Templates.Add(new Template("Pump") { Description = "fresh" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await WipeContentAsync(); + // Snapshot the audit-row ids before the apply so the assertion only + // looks at rows the apply itself emitted (the export wrote a + // BundleExported row too, with no BundleImportId — that's correct, it + // wasn't part of an import). + int beforeMaxId; + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + beforeMaxId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0; + } + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List + { + new("Template", "Pump", ResolutionAction.Add, null), + new("SharedScript", "HelperFn", ResolutionAction.Add, null), + }, + user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var newRows = await ctx.AuditLogEntries.Where(a => a.Id > beforeMaxId).ToListAsync(); + // We expect at least: TemplateCreated + SharedScriptCreated + BundleImported. + Assert.True(newRows.Count >= 3, + $"Expected at least 3 new audit rows, got {newRows.Count}."); + Assert.All(newRows, row => + Assert.Equal(result.BundleImportId, row.BundleImportId)); + } + } + + [Fact] + public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction() + { + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "fresh" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await WipeContentAsync(); + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("Template", "Pump", ResolutionAction.Add, null) }, + user: "bob"); + } + + // Assert: BundleImported row exists, has the right SourceEnvironment in + // its AfterStateJson, and carries the BundleImportId from the result. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImported"); + Assert.NotNull(row); + Assert.Equal("Bundle", row!.EntityType); + Assert.Equal(result.BundleImportId, row.BundleImportId); + Assert.NotNull(row.AfterStateJson); + Assert.Contains("dev", row.AfterStateJson!, StringComparison.Ordinal); + // Summary block in payload. + Assert.Contains("Summary", row.AfterStateJson!, StringComparison.Ordinal); + } + } + + [Fact] + public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction() + { + // Paired with the rollback test — the failure row IS present even + // though every other write was rolled back, AND it carries + // BundleImportId == null (the rolled-back id is intentionally + // disowned from the failure record). + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("BrokenPump") { Description = "broken" }; + t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await WipeContentAsync(); + + // Act + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await Assert.ThrowsAsync(() => + importer.ApplyAsync(sessionId, + new List { new("Template", "BrokenPump", ResolutionAction.Add, null) }, + user: "bob")); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImportFailed"); + Assert.NotNull(row); + Assert.Equal("Bundle", row!.EntityType); + // Correlation MUST be null on the failure row — the rolled-back + // BundleImportId is intentionally disowned. + Assert.Null(row.BundleImportId); + } + } +}