feat(transport): wire full SemanticValidator at bundle import time

This commit is contained in:
Joseph Doherty
2026-05-24 06:32:42 -04:00
parent 8e73e60f4a
commit 624cf255a4
5 changed files with 480 additions and 28 deletions

View File

@@ -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

View File

@@ -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,
};
}
}

View File

@@ -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;

View File

@@ -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"));
}
}
}

View File

@@ -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);
}