diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index dacdab5..a2c8c48 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -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 diff --git a/src/ScadaLink.Transport/Import/BundleImporter.cs b/src/ScadaLink.Transport/Import/BundleImporter.cs index 881af70..e5bb362 100644 --- a/src/ScadaLink.Transport/Import/BundleImporter.cs +++ b/src/ScadaLink.Transport/Import/BundleImporter.cs @@ -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 _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)); } /// @@ -448,17 +454,20 @@ public sealed class BundleImporter : IBundleImporter /// later category can resolve name-keyed references to earlier ones. /// /// - /// 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 SharedScript / ExternalSystem. - /// Wiring the full - /// 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 SharedScript / + /// ExternalSystem), then — on Pass 1 success — the full + /// over each + /// imported template scoped to its own single-template + /// FlattenedConfiguration. 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 + /// for the per-pass contract. /// /// /// Audit-row contract: every per-entity write goes through @@ -1511,19 +1520,32 @@ public sealed class BundleImporter : IBundleImporter } /// - /// 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 DetectBlockersAsync - /// 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: + /// + /// Pass 1 — minimal name-resolution scan. Catches the + /// import-specific crash surface that the full SemanticValidator + /// can't see: identifier-shaped call targets in + /// TemplateScript / ApiMethod bodies that resolve to neither + /// an in-bundle nor a pre-existing target SharedScript / + /// ExternalSystem. 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. + /// Pass 2 — full . For each + /// template being imported (Add / Overwrite / Rename — not Skip), build a + /// per-template 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 + /// values combining bundle + target so call-target checks resolve in either + /// direction. + /// /// - /// The full TemplateEngine.Validation.SemanticValidator (which - /// requires a FlattenedConfiguration 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. /// /// private async Task> RunSemanticValidationAsync( @@ -1533,6 +1555,8 @@ public sealed class BundleImporter : IBundleImporter { var errors = new List(); + // ---- 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(); + 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; } + + /// + /// Builds a 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 FlatteningService pipeline, which needs a + /// concrete Instance 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. + /// + private static FlattenedConfiguration BuildFlattenedConfigForValidation(TemplateDto dto, string templateName) + { + var attributes = new List(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(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(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, + }; + } } diff --git a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs index af88309..d35bcc9 100644 --- a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs @@ -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(); services.AddScoped(); services.AddSingleton(); + // 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(); services.AddScoped(); // Remaining concrete services added in later tasks. return services; diff --git a/tests/ScadaLink.Transport.IntegrationTests/SemanticValidatorImportTests.cs b/tests/ScadaLink.Transport.IntegrationTests/SemanticValidatorImportTests.cs new file mode 100644 index 0000000..0199746 --- /dev/null +++ b/tests/ScadaLink.Transport.IntegrationTests/SemanticValidatorImportTests.cs @@ -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; + +/// +/// FU-C — integration tests for the two-tier semantic validation wired into +/// : Pass 1 is the minimal name- +/// resolution scan (carried forward from the v1 importer) and Pass 2 is the +/// full SemanticValidator over each imported template's +/// FlattenedConfiguration. 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). +/// +/// 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 +/// with errors propagated. +/// +/// +public sealed class SemanticValidatorImportTests : IDisposable +{ + private readonly ServiceProvider _provider; + + public SemanticValidatorImportTests() + { + var services = new ServiceCollection(); + services.AddSingleton( + new ConfigurationBuilder().AddInMemoryCollection().Build()); + + var dbName = $"SemanticValidatorImportTests_{Guid.NewGuid()}"; + services.AddDbContext(opts => opts + .UseInMemoryDatabase(dbName) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddTransport(); + + _provider = services.BuildServiceProvider(); + } + + public void Dispose() => _provider.Dispose(); + + /// + /// Export everything currently seeded, wipe the DB, then LoadAsync the + /// bundle. Returns the session id. Mirrors the helper in + /// BundleImporterApplyTests but exported as a free helper so each + /// test can seed its own template shape without sharing fixture state. + /// + private async Task ExportWipeAndLoadAsync() + { + byte[] bundleBytes; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var ids = await ctx.Templates.Select(t => t.Id).ToListAsync(); + var selection = new ExportSelection( + TemplateIds: ids, + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiKeyIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + 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(); + 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(); + 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(); + 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(); + ex = await Assert.ThrowsAsync(() => + importer.ApplyAsync(sessionId, + new List + { + 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(); + 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(); + 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(); + ex = await Assert.ThrowsAsync(() => + importer.ApplyAsync(sessionId, + new List + { + 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(); + 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(); + 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(); + result = await importer.ApplyAsync(sessionId, + new List + { + 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(); + Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "CleanPump")); + Assert.True(await ctx.TemplateAlarms.AnyAsync(a => a.Name == "Overspeed")); + } + } +} diff --git a/tests/ScadaLink.Transport.Tests/Import/BundleImporterLoadTests.cs b/tests/ScadaLink.Transport.Tests/Import/BundleImporterLoadTests.cs index 24ce25e..ca34c25 100644 --- a/tests/ScadaLink.Transport.Tests/Import/BundleImporterLoadTests.cs +++ b/tests/ScadaLink.Transport.Tests/Import/BundleImporterLoadTests.cs @@ -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().Options)); + new DbContextOptionsBuilder().Options), + semanticValidator: new SemanticValidator()); return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts); }