feat(transport): BundleImporter.ApplyAsync transactional with audit correlation

This commit is contained in:
Joseph Doherty
2026-05-24 04:55:43 -04:00
parent 90baa4d6d5
commit 2c34f12a6f
3 changed files with 1363 additions and 5 deletions

View File

@@ -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<ImportResult> ApplyAsync(
/// <summary>
/// Writes the bundle's artifacts to the central DB inside a single
/// transaction, threading <c>BundleImportId</c> through every audit row via
/// the scoped <see cref="IAuditCorrelationContext"/>.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <c>SharedScript</c> / <c>ExternalSystem</c>.
/// Wiring the full <see cref="TemplateEngine.Validation.SemanticValidator"/>
/// 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.
/// </para>
/// <para>
/// Audit-row contract: every per-entity write goes through
/// <see cref="IAuditService.LogAsync"/> with the correlation context set to
/// <paramref name="sessionId"/>'s import id, plus a summary
/// <c>BundleImported</c> row inside the transaction. On failure the
/// transaction rolls back and a single <c>BundleImportFailed</c> row is
/// written OUTSIDE the rolled-back scope (correlation cleared first so the
/// row doesn't carry a non-existent import id).
/// </para>
/// </summary>
public async Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> 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<BundleContentDto>(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<int>(),
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;
}
}
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
private sealed class ImportSummary
{
public int Added { get; set; }
public int Overwritten { get; set; }
public int Skipped { get; set; }
public int Renamed { get; set; }
}
/// <summary>
/// Returns the resolution for the given (entityType, name) tuple, defaulting to
/// <see cref="ResolutionAction.Add"/> 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.
/// </summary>
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<TemplateFolderDto> 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<TemplateDto> 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;
}
}
}
}
/// <summary>
/// Builds a <see cref="Template"/> from a bundle DTO, copying attributes /
/// alarms / scripts but deliberately NOT wiring composition edges — those
/// need FK ids resolved against the target DB and the first-cut import
/// covers the field-level data; rebuilding the composition graph belongs
/// in a follow-up task once the target's pre-existing template ids can be
/// joined in. <paramref name="overrideName"/> supports the Rename
/// resolution; pass <c>null</c> to keep the DTO's original name.
/// </summary>
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<SharedScriptDto> 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<ExternalSystemDto> 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<DatabaseConnectionDto> 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<NotificationListDto> 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<SmtpConfigDto> 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<ApiKeyDto> 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<ApiMethodDto> 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,
};
}
/// <summary>
/// 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 <c>DetectBlockersAsync</c>
/// 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.
/// <para>
/// The full <c>TemplateEngine.Validation.SemanticValidator</c> (which
/// requires a <c>FlattenedConfiguration</c> 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).
/// </para>
/// </summary>
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
CancellationToken ct)
{
var errors = new List<string>();
// 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<string>(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<string>(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<string>(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;
}
}

View File

@@ -0,0 +1,34 @@
namespace ScadaLink.Transport.Import;
/// <summary>
/// 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.
/// <para>
/// The exception is caught inside <see cref="BundleImporter.ApplyAsync"/> to
/// roll back the transaction, emit a <c>BundleImportFailed</c> audit row, and
/// re-throw to the caller so the UI can surface the specific errors. It is
/// deliberately distinct from <see cref="InvalidOperationException"/> so the
/// caller can distinguish "your bundle is bad" from "the import infra is bad".
/// </para>
/// </summary>
public sealed class SemanticValidationException : Exception
{
public IReadOnlyList<string> Errors { get; }
public SemanticValidationException(IReadOnlyList<string> errors)
: base(BuildMessage(errors))
{
Errors = errors ?? throw new ArgumentNullException(nameof(errors));
}
private static string BuildMessage(IReadOnlyList<string> errors)
{
if (errors is null || errors.Count == 0)
{
return "Bundle semantic validation failed.";
}
return "Bundle semantic validation failed: " + string.Join("; ", errors);
}
}

View File

@@ -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;
/// <summary>
/// Integration tests for <see cref="BundleImporter.ApplyAsync"/>. Reuses the
/// in-memory host pattern from <see cref="BundleImporterPreviewTests"/> and
/// <c>BundleExporterTests</c>: real repositories, real EF in-memory provider,
/// real Transport pipeline.
/// <para>
/// In-memory EF caveat: <see cref="DbContext.Database.BeginTransactionAsync"/>
/// is a no-op on this provider, so the rollback test depends on ApplyAsync's
/// implementation deferring <c>SaveChangesAsync</c> to a single call just
/// before <c>CommitAsync</c>. The implementation enforces that contract +
/// calls <c>ChangeTracker.Clear()</c> on the catch path to defend against
/// in-memory bleed-through; the rollback test asserts via row counts that the
/// invariant holds.
/// </para>
/// </summary>
public sealed class BundleImporterApplyTests : IDisposable
{
private readonly ServiceProvider _provider;
public BundleImporterApplyTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
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<ScadaLinkDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
_provider = services.BuildServiceProvider();
}
public void Dispose() => _provider.Dispose();
// ---- helpers ----
/// <summary>
/// Exports the entire seeded content as a bundle, then immediately loads it
/// via <see cref="IBundleImporter.LoadAsync"/> and returns the opened
/// session. Used by every test that needs a session to feed
/// <see cref="IBundleImporter.ApplyAsync"/>. Selection is "all templates +
/// all shared scripts" because the tests want the bundle to carry whatever
/// the test seeded.
/// </summary>
private async Task<Guid> ExportAndLoadAsync()
{
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
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<IBundleImporter>();
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<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<IBundleImporter>();
var resolutions = new List<ImportResolution>
{
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<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Skip, null) },
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
// 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<ScadaLinkDbContext>();
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<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "X", ResolutionAction.Rename, "Y") },
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<IBundleImporter>();
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution> { 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<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
beforeMaxId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
}
// Act
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
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<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { 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<ScadaLinkDbContext>();
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<ScadaLinkDbContext>();
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<IBundleImporter>();
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
user: "bob"));
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
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);
}
}
}