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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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`.
|
- 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).
|
- 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)
|
▼ (user reviews + resolves conflicts)
|
||||||
│
|
│
|
||||||
ApplyAsync (single EF transaction)
|
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)
|
· apply resolutions (add / overwrite / skip / rename)
|
||||||
· upsert TemplateFolder hierarchy
|
· upsert TemplateFolder hierarchy
|
||||||
· IAuditService.LogAsync(BundleImported …)
|
· 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.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.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
|
## Interactions
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Interfaces.Transport;
|
using ScadaLink.Commons.Interfaces.Transport;
|
||||||
using ScadaLink.Commons.Types.Transport;
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Commons.Types.Flattening;
|
||||||
using ScadaLink.ConfigurationDatabase;
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.TemplateEngine.Validation;
|
||||||
using ScadaLink.Transport.Encryption;
|
using ScadaLink.Transport.Encryption;
|
||||||
using ScadaLink.Transport.Serialization;
|
using ScadaLink.Transport.Serialization;
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
private readonly IBundleSessionStore _sessionStore;
|
private readonly IBundleSessionStore _sessionStore;
|
||||||
private readonly IOptions<TransportOptions> _options;
|
private readonly IOptions<TransportOptions> _options;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly SemanticValidator _semanticValidator;
|
||||||
|
|
||||||
public BundleImporter(
|
public BundleImporter(
|
||||||
BundleSerializer bundleSerializer,
|
BundleSerializer bundleSerializer,
|
||||||
@@ -78,7 +82,8 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
IInboundApiRepository inboundApiRepo,
|
IInboundApiRepository inboundApiRepo,
|
||||||
IAuditService auditService,
|
IAuditService auditService,
|
||||||
IAuditCorrelationContext correlationContext,
|
IAuditCorrelationContext correlationContext,
|
||||||
ScadaLinkDbContext dbContext)
|
ScadaLinkDbContext dbContext,
|
||||||
|
SemanticValidator semanticValidator)
|
||||||
{
|
{
|
||||||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||||||
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
|
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
|
||||||
@@ -94,6 +99,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||||
|
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -448,17 +454,20 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// later category can resolve name-keyed references to earlier ones.
|
/// later category can resolve name-keyed references to earlier ones.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Semantic validation is the minimal v1 variant: every script-callable
|
/// Semantic validation is two-tier: a minimal name-resolution scan first
|
||||||
/// identifier referenced by the merged target must resolve to either a
|
/// (every script-callable identifier referenced by the merged target must
|
||||||
/// pre-existing or in-bundle <c>SharedScript</c> / <c>ExternalSystem</c>.
|
/// resolve to either a pre-existing or in-bundle <c>SharedScript</c> /
|
||||||
/// Wiring the full <see cref="TemplateEngine.Validation.SemanticValidator"/>
|
/// <c>ExternalSystem</c>), then — on Pass 1 success — the full
|
||||||
/// requires running the flattening pipeline over the merged target, which
|
/// <see cref="TemplateEngine.Validation.SemanticValidator"/> over each
|
||||||
/// isn't reachable from the import path without a fixture — deferred to a
|
/// imported template scoped to its own single-template
|
||||||
/// follow-up; today's check catches the same crash surface the operator
|
/// <c>FlattenedConfiguration</c>. The minimal pass is run AGAINST the
|
||||||
/// would otherwise hit at deploy time. The minimal check is run AGAINST the
|
/// merged target (incoming-bundle DTOs in memory plus the target DB read
|
||||||
/// merged target (incoming-bundle DTOs already in memory, target DB read
|
|
||||||
/// inside the transaction) so a Skip resolution can legitimately fail
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Audit-row contract: every per-entity write goes through
|
/// Audit-row contract: every per-entity write goes through
|
||||||
@@ -1511,19 +1520,32 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal v1 semantic validation: scan every TemplateScript / ApiMethod
|
/// Two-tier semantic validation run before any rows are flushed:
|
||||||
/// body in the (post-merge) target for identifier-shaped references that
|
/// <list type="number">
|
||||||
/// cannot resolve to either a pre-existing or in-bundle SharedScript /
|
/// <item><b>Pass 1 — minimal name-resolution scan.</b> Catches the
|
||||||
/// ExternalSystem. Mirrors the algorithm used by <c>DetectBlockersAsync</c>
|
/// import-specific crash surface that the full <c>SemanticValidator</c>
|
||||||
/// in the preview path, but operates against the actual merge result —
|
/// can't see: identifier-shaped call targets in
|
||||||
/// Skip-resolved DTOs are excluded from the in-bundle name set, so a Skip
|
/// <c>TemplateScript</c> / <c>ApiMethod</c> bodies that resolve to neither
|
||||||
/// that would have provided a dependency surfaces here as an error.
|
/// 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>
|
/// <para>
|
||||||
/// The full <c>TemplateEngine.Validation.SemanticValidator</c> (which
|
/// Per-template scoping is intentional: pre-existing target templates that
|
||||||
/// requires a <c>FlattenedConfiguration</c> built from the central template
|
/// haven't been touched by this bundle aren't run through the validator —
|
||||||
/// graph) is deferred to a follow-up — wiring it into the import path
|
/// otherwise a latent validation issue on an unrelated template (one the
|
||||||
/// without a flattening fixture is non-trivial and the simpler check
|
/// operator isn't trying to import) would block the import.
|
||||||
/// covers the same crash surface (unresolvable callsites at runtime).
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
|
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
|
||||||
@@ -1533,6 +1555,8 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
{
|
{
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
// ---- Pass 1: minimal name-resolution scan ----
|
||||||
|
|
||||||
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
|
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
|
||||||
// Skip-resolved names — those aren't being written, so they can't
|
// Skip-resolved names — those aren't being written, so they can't
|
||||||
// satisfy a downstream reference. Renamed entries register under both
|
// 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.
|
// 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);
|
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.");
|
$"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;
|
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.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ScadaLink.Commons.Interfaces.Transport;
|
using ScadaLink.Commons.Interfaces.Transport;
|
||||||
|
using ScadaLink.TemplateEngine.Validation;
|
||||||
using ScadaLink.Transport.Encryption;
|
using ScadaLink.Transport.Encryption;
|
||||||
using ScadaLink.Transport.Export;
|
using ScadaLink.Transport.Export;
|
||||||
using ScadaLink.Transport.Import;
|
using ScadaLink.Transport.Import;
|
||||||
@@ -30,6 +31,11 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<DependencyResolver>();
|
services.AddScoped<DependencyResolver>();
|
||||||
services.AddScoped<IBundleExporter, BundleExporter>();
|
services.AddScoped<IBundleExporter, BundleExporter>();
|
||||||
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
|
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>();
|
services.AddScoped<IBundleImporter, BundleImporter>();
|
||||||
// Remaining concrete services added in later tasks.
|
// Remaining concrete services added in later tasks.
|
||||||
return services;
|
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.Interfaces.Transport;
|
||||||
using ScadaLink.Commons.Types.Transport;
|
using ScadaLink.Commons.Types.Transport;
|
||||||
using ScadaLink.ConfigurationDatabase;
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.TemplateEngine.Validation;
|
||||||
using ScadaLink.Transport.Encryption;
|
using ScadaLink.Transport.Encryption;
|
||||||
using ScadaLink.Transport.Import;
|
using ScadaLink.Transport.Import;
|
||||||
using ScadaLink.Transport.Serialization;
|
using ScadaLink.Transport.Serialization;
|
||||||
@@ -113,7 +114,8 @@ public sealed class BundleImporterLoadTests
|
|||||||
// a no-provider DbContext so the importer's null check passes;
|
// a no-provider DbContext so the importer's null check passes;
|
||||||
// the in-memory provider isn't worth pulling in for unit tests.
|
// the in-memory provider isn't worth pulling in for unit tests.
|
||||||
dbContext: new ScadaLinkDbContext(
|
dbContext: new ScadaLinkDbContext(
|
||||||
new DbContextOptionsBuilder<ScadaLinkDbContext>().Options));
|
new DbContextOptionsBuilder<ScadaLinkDbContext>().Options),
|
||||||
|
semanticValidator: new SemanticValidator());
|
||||||
|
|
||||||
return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts);
|
return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user