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;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Options;
|
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.Entities.Templates;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
@@ -35,12 +39,10 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
private readonly ManifestValidator _manifestValidator;
|
private readonly ManifestValidator _manifestValidator;
|
||||||
private readonly BundleSecretEncryptor _encryptor;
|
private readonly BundleSecretEncryptor _encryptor;
|
||||||
private readonly ArtifactDiff _diff = new();
|
private readonly ArtifactDiff _diff = new();
|
||||||
#pragma warning disable IDE0052 // wired-in dependencies for T17.
|
|
||||||
private readonly EntitySerializer _entitySerializer;
|
private readonly EntitySerializer _entitySerializer;
|
||||||
private readonly IAuditService _auditService;
|
private readonly IAuditService _auditService;
|
||||||
private readonly IAuditCorrelationContext _correlationContext;
|
private readonly IAuditCorrelationContext _correlationContext;
|
||||||
private readonly ScadaLinkDbContext _dbContext;
|
private readonly ScadaLinkDbContext _dbContext;
|
||||||
#pragma warning restore IDE0052
|
|
||||||
private readonly ITemplateEngineRepository _templateRepo;
|
private readonly ITemplateEngineRepository _templateRepo;
|
||||||
private readonly IExternalSystemRepository _externalRepo;
|
private readonly IExternalSystemRepository _externalRepo;
|
||||||
private readonly INotificationRepository _notificationRepo;
|
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 IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
|
||||||
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(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,
|
Guid sessionId,
|
||||||
IReadOnlyList<ImportResolution> resolutions,
|
IReadOnlyList<ImportResolution> resolutions,
|
||||||
string user,
|
string user,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Filled in by T17.
|
ArgumentNullException.ThrowIfNull(resolutions);
|
||||||
throw new NotImplementedException("ApplyAsync is implemented by task T17.");
|
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