feat(transport): wire full SemanticValidator at bundle import time
This commit is contained in:
@@ -21,7 +21,7 @@ The Transport component provides a file-based, encrypted, environment-agnostic w
|
||||
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
||||
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
||||
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, or a Blocker.
|
||||
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running the pre-deployment semantic validator before committing.
|
||||
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running two-tier semantic validation before committing: a minimal name-resolution scan over the merged target (fails fast on unresolved SharedScript / ExternalSystem identifiers), then the full `SemanticValidator` from `ScadaLink.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
|
||||
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, and `BundleImportCompositionUnresolved` audit events via `IAuditService`.
|
||||
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
|
||||
- Enforce `RequireDesign` on export and `RequireAdmin` on import both at the Razor page layer and inside the service entrypoints (defense in depth).
|
||||
@@ -194,7 +194,7 @@ User (Admin role) ─► uploads bundle
|
||||
▼ (user reviews + resolves conflicts)
|
||||
│
|
||||
ApplyAsync (single EF transaction)
|
||||
· run pre-deployment semantic validator (Template Engine)
|
||||
· run two-tier semantic validation (minimal name scan + full SemanticValidator)
|
||||
· apply resolutions (add / overwrite / skip / rename)
|
||||
· upsert TemplateFolder hierarchy
|
||||
· IAuditService.LogAsync(BundleImported …)
|
||||
@@ -278,7 +278,7 @@ Import flows through the same audited repository methods the UI and CLI use, so
|
||||
|
||||
- **`ScadaLink.Commons`** — Bundle manifest and content DTOs (`BundleManifest`, `ExportSelection`, `ImportPreview`, `ImportResolution`, `ImportResult`, `BundleSession`); transport interface definitions (`IBundleExporter`, `IBundleImporter`, `IBundleSessionStore`, `IAuditCorrelationContext`).
|
||||
- **`ScadaLink.ConfigurationDatabase`** — All repository implementations and `IAuditService` for persistence and per-entity audit emission; `IAuditCorrelationContext` implementation (`AuditCorrelationContext`) registered as a scoped service; EF migration for `BundleImportId`.
|
||||
- **`ScadaLink.TemplateEngine`** — Pre-deployment semantic validator invoked inside `ApplyAsync` before the transaction commits.
|
||||
- **`ScadaLink.TemplateEngine`** — Pre-deployment `SemanticValidator` invoked inside `ApplyAsync` before the transaction commits. The importer builds a single-template `FlattenedConfiguration` directly from each imported `TemplateDto` (no inheritance / composition resolution at design time — the deployment-time flatten revalidates against the full instance graph) and feeds it through the validator alongside a `ResolvedScript` catalog combining in-bundle + pre-existing target `SharedScript`s. Validator errors are aggregated per template and surfaced as a `SemanticValidationException` that rolls back the import transaction.
|
||||
|
||||
## Interactions
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
using ScadaLink.Transport.Encryption;
|
||||
using ScadaLink.Transport.Serialization;
|
||||
|
||||
@@ -63,6 +66,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
private readonly IBundleSessionStore _sessionStore;
|
||||
private readonly IOptions<TransportOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemanticValidator _semanticValidator;
|
||||
|
||||
public BundleImporter(
|
||||
BundleSerializer bundleSerializer,
|
||||
@@ -78,7 +82,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
IInboundApiRepository inboundApiRepo,
|
||||
IAuditService auditService,
|
||||
IAuditCorrelationContext correlationContext,
|
||||
ScadaLinkDbContext dbContext)
|
||||
ScadaLinkDbContext dbContext,
|
||||
SemanticValidator semanticValidator)
|
||||
{
|
||||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||||
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
|
||||
@@ -94,6 +99,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -448,17 +454,20 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// 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
|
||||
/// Semantic validation is two-tier: a minimal name-resolution scan first
|
||||
/// (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>), then — on Pass 1 success — the full
|
||||
/// <see cref="TemplateEngine.Validation.SemanticValidator"/> over each
|
||||
/// imported template scoped to its own single-template
|
||||
/// <c>FlattenedConfiguration</c>. The minimal pass is run AGAINST the
|
||||
/// merged target (incoming-bundle DTOs in memory plus the target DB read
|
||||
/// inside the transaction) so a Skip resolution can legitimately fail
|
||||
/// validation if it would have provided a missing dependency.
|
||||
/// validation if it would have provided a missing dependency. The full
|
||||
/// pass scopes to imported templates only — pre-existing untouched
|
||||
/// templates aren't revalidated so a latent issue elsewhere in the
|
||||
/// catalog doesn't block this import. See <see cref="RunSemanticValidationAsync"/>
|
||||
/// for the per-pass contract.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Audit-row contract: every per-entity write goes through
|
||||
@@ -1511,19 +1520,32 @@ public sealed class BundleImporter : IBundleImporter
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Two-tier semantic validation run before any rows are flushed:
|
||||
/// <list type="number">
|
||||
/// <item><b>Pass 1 — minimal name-resolution scan.</b> Catches the
|
||||
/// import-specific crash surface that the full <c>SemanticValidator</c>
|
||||
/// can't see: identifier-shaped call targets in
|
||||
/// <c>TemplateScript</c> / <c>ApiMethod</c> bodies that resolve to neither
|
||||
/// an in-bundle nor a pre-existing target <c>SharedScript</c> /
|
||||
/// <c>ExternalSystem</c>. Skip-resolved DTOs are excluded from the
|
||||
/// in-bundle name set so a Skip that would have provided a dependency
|
||||
/// surfaces here. Fails fast: if Pass 1 finds errors, Pass 2 is not run.</item>
|
||||
/// <item><b>Pass 2 — full <see cref="SemanticValidator"/>.</b> For each
|
||||
/// template being imported (Add / Overwrite / Rename — not Skip), build a
|
||||
/// per-template <see cref="FlattenedConfiguration"/> directly from the DTO
|
||||
/// (single-template scope — no inheritance / composition resolution, since
|
||||
/// the inheritance chain is reconstructed only at deploy time) and invoke
|
||||
/// the same validator the deployment pipeline uses. Errors from every
|
||||
/// template are aggregated into one list so the operator sees the full
|
||||
/// surface at once. SharedScripts are passed as <see cref="ResolvedScript"/>
|
||||
/// values combining bundle + target so call-target checks resolve in either
|
||||
/// direction.</item>
|
||||
/// </list>
|
||||
/// <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).
|
||||
/// Per-template scoping is intentional: pre-existing target templates that
|
||||
/// haven't been touched by this bundle aren't run through the validator —
|
||||
/// otherwise a latent validation issue on an unrelated template (one the
|
||||
/// operator isn't trying to import) would block the import.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
|
||||
@@ -1533,6 +1555,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// ---- Pass 1: minimal name-resolution scan ----
|
||||
|
||||
// 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
|
||||
@@ -1562,7 +1586,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
}
|
||||
|
||||
// Pre-existing target entries always count as resolvable.
|
||||
foreach (var s in await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false))
|
||||
var preExistingSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||||
foreach (var s in preExistingSharedScripts)
|
||||
{
|
||||
sharedScriptNames.Add(s.Name);
|
||||
}
|
||||
@@ -1604,6 +1629,145 @@ public sealed class BundleImporter : IBundleImporter
|
||||
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
|
||||
}
|
||||
|
||||
// Fail fast — running the full validator over templates that already
|
||||
// failed name resolution would produce duplicate / lower-quality errors
|
||||
// (the missing identifier shows up there as "callee not found" too).
|
||||
if (errors.Count > 0) return errors;
|
||||
|
||||
// ---- Pass 2: full SemanticValidator over imported templates ----
|
||||
|
||||
// Build the shared-script catalog the validator uses to resolve
|
||||
// CallShared targets. Combine in-bundle (non-Skip) + pre-existing
|
||||
// target — same resolution model as Pass 1's name set.
|
||||
var sharedScripts = new List<ResolvedScript>();
|
||||
foreach (var s in content.SharedScripts)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
|
||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||
var name = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
|
||||
? resolution.RenameTo
|
||||
: s.Name;
|
||||
sharedScripts.Add(new ResolvedScript
|
||||
{
|
||||
CanonicalName = name,
|
||||
Code = s.Code,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
});
|
||||
}
|
||||
foreach (var s in preExistingSharedScripts)
|
||||
{
|
||||
// Pre-existing target wins on duplicate name only when the bundle
|
||||
// didn't supply it; otherwise the bundle's version (the one about
|
||||
// to be written) is the right signature surface to validate against.
|
||||
if (sharedScripts.Any(rs => string.Equals(rs.CanonicalName, s.Name, StringComparison.Ordinal))) continue;
|
||||
sharedScripts.Add(new ResolvedScript
|
||||
{
|
||||
CanonicalName = s.Name,
|
||||
Code = s.Code,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var dto in content.Templates)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||
|
||||
var importedName = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
|
||||
? resolution.RenameTo
|
||||
: dto.Name;
|
||||
|
||||
var config = BuildFlattenedConfigForValidation(dto, importedName);
|
||||
var result = _semanticValidator.Validate(config, sharedScripts);
|
||||
foreach (var entry in result.Errors)
|
||||
{
|
||||
errors.Add($"Template '{importedName}': {entry.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="FlattenedConfiguration"/> for a single template DTO
|
||||
/// — the validator's input contract. The bundle DTO carries only the
|
||||
/// template's own attributes / alarms / scripts (no inheritance / no
|
||||
/// composition resolution at design time), so the flattening here is a
|
||||
/// straight 1:1 copy with the alarm on-trigger-script name carried through
|
||||
/// as the canonical name (the same script's bare name, since composed
|
||||
/// modules aren't expanded at import time). This is intentionally narrower
|
||||
/// than the production <c>FlatteningService</c> pipeline, which needs a
|
||||
/// concrete <c>Instance</c> plus site / connection context that doesn't
|
||||
/// exist yet at design time. The deployment-time flatten will revalidate
|
||||
/// against the full graph; this pass catches the same-template-scope
|
||||
/// errors that operators would otherwise only hit at deploy time.
|
||||
/// </summary>
|
||||
private static FlattenedConfiguration BuildFlattenedConfigForValidation(TemplateDto dto, string templateName)
|
||||
{
|
||||
var attributes = new List<ResolvedAttribute>(dto.Attributes.Count);
|
||||
foreach (var a in dto.Attributes)
|
||||
{
|
||||
attributes.Add(new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = a.Name,
|
||||
Value = a.Value,
|
||||
DataType = a.DataType.ToString(),
|
||||
IsLocked = a.IsLocked,
|
||||
Description = a.Description,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
Source = "Template",
|
||||
});
|
||||
}
|
||||
|
||||
var alarms = new List<ResolvedAlarm>(dto.Alarms.Count);
|
||||
foreach (var al in dto.Alarms)
|
||||
{
|
||||
alarms.Add(new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = al.Name,
|
||||
Description = al.Description,
|
||||
PriorityLevel = al.PriorityLevel,
|
||||
IsLocked = al.IsLocked,
|
||||
TriggerType = al.TriggerType.ToString(),
|
||||
TriggerConfiguration = al.TriggerConfiguration,
|
||||
// The bundle carries the on-trigger script by NAME (not id);
|
||||
// at this single-template-scope validation step the bare name
|
||||
// IS the canonical name, so just pass it through.
|
||||
OnTriggerScriptCanonicalName = string.IsNullOrEmpty(al.OnTriggerScriptName) ? null : al.OnTriggerScriptName,
|
||||
Source = "Template",
|
||||
});
|
||||
}
|
||||
|
||||
var scripts = new List<ResolvedScript>(dto.Scripts.Count);
|
||||
foreach (var s in dto.Scripts)
|
||||
{
|
||||
scripts.Add(new ResolvedScript
|
||||
{
|
||||
CanonicalName = s.Name,
|
||||
Code = s.Code,
|
||||
IsLocked = s.IsLocked,
|
||||
TriggerType = s.TriggerType,
|
||||
TriggerConfiguration = s.TriggerConfiguration,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
||||
Source = "Template",
|
||||
});
|
||||
}
|
||||
|
||||
return new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = templateName,
|
||||
TemplateId = 0,
|
||||
SiteId = 0,
|
||||
AreaId = null,
|
||||
Attributes = attributes,
|
||||
Alarms = alarms,
|
||||
Scripts = scripts,
|
||||
Connections = null,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
using ScadaLink.Transport.Encryption;
|
||||
using ScadaLink.Transport.Export;
|
||||
using ScadaLink.Transport.Import;
|
||||
@@ -30,6 +31,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<DependencyResolver>();
|
||||
services.AddScoped<IBundleExporter, BundleExporter>();
|
||||
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
|
||||
// SemanticValidator is a stateless utility used by ApplyAsync; use
|
||||
// TryAdd so a host that already calls AddTemplateEngine() (which
|
||||
// registers the same type as Transient) wins. Either registration
|
||||
// satisfies the BundleImporter constructor.
|
||||
services.TryAddTransient<SemanticValidator>();
|
||||
services.AddScoped<IBundleImporter, BundleImporter>();
|
||||
// Remaining concrete services added in later tasks.
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.ConfigurationDatabase.Services;
|
||||
using ScadaLink.Transport;
|
||||
using ScadaLink.Transport.Import;
|
||||
|
||||
namespace ScadaLink.Transport.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// FU-C — integration tests for the two-tier semantic validation wired into
|
||||
/// <see cref="BundleImporter.ApplyAsync"/>: Pass 1 is the minimal name-
|
||||
/// resolution scan (carried forward from the v1 importer) and Pass 2 is the
|
||||
/// full <c>SemanticValidator</c> over each imported template's
|
||||
/// <c>FlattenedConfiguration</c>. Pass 1 fails fast — Pass 2 only runs when
|
||||
/// Pass 1 succeeds — so the Pass 2 scenarios here are chosen to live entirely
|
||||
/// in alarm shape (alarm JSON is not scanned by Pass 1).
|
||||
/// <para>
|
||||
/// The "invalid call target" test exercises Pass 1 because every
|
||||
/// SemanticValidator call-target rule presupposes the called identifier is
|
||||
/// already known to the script body's surface; an entirely-unknown identifier
|
||||
/// surfaces at Pass 1 first by design. Both tiers throw the same
|
||||
/// <see cref="SemanticValidationException"/> with errors propagated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SemanticValidatorImportTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public SemanticValidatorImportTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"SemanticValidatorImportTests_{Guid.NewGuid()}";
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Export everything currently seeded, wipe the DB, then LoadAsync the
|
||||
/// bundle. Returns the session id. Mirrors the helper in
|
||||
/// <c>BundleImporterApplyTests</c> but exported as a free helper so each
|
||||
/// test can seed its own template shape without sharing fixture state.
|
||||
/// </summary>
|
||||
private async Task<Guid> ExportWipeAndLoadAsync()
|
||||
{
|
||||
byte[] bundleBytes;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: ids,
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
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);
|
||||
|
||||
var stream = await exporter.ExportAsync(selection,
|
||||
user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
bundleBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
// Wipe so the apply is exercising the Add path.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
|
||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
using var input = new MemoryStream(bundleBytes, writable: false);
|
||||
var session = await importer.LoadAsync(input, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SemanticValidator_catches_invalid_call_target_at_import()
|
||||
{
|
||||
// Arrange — template whose script body calls UnknownHelper(): a
|
||||
// PascalCase identifier that doesn't resolve to any SharedScript or
|
||||
// ExternalSystem in the bundle or the target. This is the operator-
|
||||
// facing "invalid call target" surface — the full SemanticValidator's
|
||||
// CallScript/CallShared signature checks live downstream of name
|
||||
// resolution (you can't check arg count against a function that
|
||||
// doesn't exist). Pass 1 catches it first and fails fast.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("ScriptCallsUnknown");
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"init",
|
||||
"var x = UnknownHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
// Act — apply must throw SemanticValidationException carrying the bad
|
||||
// call target by name.
|
||||
SemanticValidationException ex = default!;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "ScriptCallsUnknown", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert — error message names the bad target.
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors,
|
||||
err => err.Contains("UnknownHelper", StringComparison.Ordinal));
|
||||
|
||||
// Rollback — no template row landed.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "ScriptCallsUnknown"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
|
||||
{
|
||||
// Arrange — template with a String attribute Status and a
|
||||
// RangeViolation alarm against it. The full SemanticValidator must
|
||||
// report TriggerOperandType (RangeViolation requires numeric).
|
||||
// Pass 1 doesn't scan alarm JSON, so the error reaches Pass 2.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("TankWithBadAlarm") { Description = "RangeViolation on string attr" };
|
||||
t.Attributes.Add(new TemplateAttribute("Status")
|
||||
{
|
||||
DataType = DataType.String,
|
||||
Value = "OK",
|
||||
});
|
||||
t.Alarms.Add(new TemplateAlarm("BadRange")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"min\":0,\"max\":100}",
|
||||
PriorityLevel = 1,
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
// Act
|
||||
SemanticValidationException ex = default!;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "TankWithBadAlarm", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert — error names the offending alarm and the bad trigger
|
||||
// type so the operator can locate the fix.
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors,
|
||||
err => err.Contains("BadRange", StringComparison.Ordinal)
|
||||
&& err.Contains("RangeViolation", StringComparison.Ordinal));
|
||||
|
||||
// Rollback — no template row landed.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "TankWithBadAlarm"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_bundle_passes_semantic_validation()
|
||||
{
|
||||
// Arrange — clean template that satisfies both passes: one Double
|
||||
// attribute, one ValueMatch alarm on it, one script with no external
|
||||
// call identifiers. ValueMatch doesn't constrain the operand data
|
||||
// type (only RangeViolation / HiLo do), so this template's alarm is
|
||||
// legal.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("CleanPump") { Description = "passes both passes" };
|
||||
t.Attributes.Add(new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
Value = "0",
|
||||
});
|
||||
t.Alarms.Add(new TemplateAlarm("Overspeed")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Speed\",\"value\":100}",
|
||||
PriorityLevel = 1,
|
||||
});
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"tick",
|
||||
"// no external calls"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
// Act — happy-path import.
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "CleanPump", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — template + alarm survived the round-trip.
|
||||
Assert.Equal(1, result.Added);
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "CleanPump"));
|
||||
Assert.True(await ctx.TemplateAlarms.AnyAsync(a => a.Name == "Overspeed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
using ScadaLink.Transport.Encryption;
|
||||
using ScadaLink.Transport.Import;
|
||||
using ScadaLink.Transport.Serialization;
|
||||
@@ -113,7 +114,8 @@ public sealed class BundleImporterLoadTests
|
||||
// a no-provider DbContext so the importer's null check passes;
|
||||
// the in-memory provider isn't worth pulling in for unit tests.
|
||||
dbContext: new ScadaLinkDbContext(
|
||||
new DbContextOptionsBuilder<ScadaLinkDbContext>().Options));
|
||||
new DbContextOptionsBuilder<ScadaLinkDbContext>().Options),
|
||||
semanticValidator: new SemanticValidator());
|
||||
|
||||
return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user