feat(transport): BundleImporter.ApplyAsync transactional with audit correlation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user