feat(transport): wire full SemanticValidator at bundle import time
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.ConfigurationDatabase.Services;
|
||||
using ScadaLink.Transport;
|
||||
using ScadaLink.Transport.Import;
|
||||
|
||||
namespace ScadaLink.Transport.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// FU-C — integration tests for the two-tier semantic validation wired into
|
||||
/// <see cref="BundleImporter.ApplyAsync"/>: Pass 1 is the minimal name-
|
||||
/// resolution scan (carried forward from the v1 importer) and Pass 2 is the
|
||||
/// full <c>SemanticValidator</c> over each imported template's
|
||||
/// <c>FlattenedConfiguration</c>. Pass 1 fails fast — Pass 2 only runs when
|
||||
/// Pass 1 succeeds — so the Pass 2 scenarios here are chosen to live entirely
|
||||
/// in alarm shape (alarm JSON is not scanned by Pass 1).
|
||||
/// <para>
|
||||
/// The "invalid call target" test exercises Pass 1 because every
|
||||
/// SemanticValidator call-target rule presupposes the called identifier is
|
||||
/// already known to the script body's surface; an entirely-unknown identifier
|
||||
/// surfaces at Pass 1 first by design. Both tiers throw the same
|
||||
/// <see cref="SemanticValidationException"/> with errors propagated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SemanticValidatorImportTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public SemanticValidatorImportTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"SemanticValidatorImportTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaLinkDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Export everything currently seeded, wipe the DB, then LoadAsync the
|
||||
/// bundle. Returns the session id. Mirrors the helper in
|
||||
/// <c>BundleImporterApplyTests</c> but exported as a free helper so each
|
||||
/// test can seed its own template shape without sharing fixture state.
|
||||
/// </summary>
|
||||
private async Task<Guid> ExportWipeAndLoadAsync()
|
||||
{
|
||||
byte[] bundleBytes;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: ids,
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiKeyIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: false);
|
||||
|
||||
var stream = await exporter.ExportAsync(selection,
|
||||
user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
bundleBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
// Wipe so the apply is exercising the Add path.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
|
||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
using var input = new MemoryStream(bundleBytes, writable: false);
|
||||
var session = await importer.LoadAsync(input, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SemanticValidator_catches_invalid_call_target_at_import()
|
||||
{
|
||||
// Arrange — template whose script body calls UnknownHelper(): a
|
||||
// PascalCase identifier that doesn't resolve to any SharedScript or
|
||||
// ExternalSystem in the bundle or the target. This is the operator-
|
||||
// facing "invalid call target" surface — the full SemanticValidator's
|
||||
// CallScript/CallShared signature checks live downstream of name
|
||||
// resolution (you can't check arg count against a function that
|
||||
// doesn't exist). Pass 1 catches it first and fails fast.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("ScriptCallsUnknown");
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"init",
|
||||
"var x = UnknownHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
// Act — apply must throw SemanticValidationException carrying the bad
|
||||
// call target by name.
|
||||
SemanticValidationException ex = default!;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "ScriptCallsUnknown", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert — error message names the bad target.
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors,
|
||||
err => err.Contains("UnknownHelper", StringComparison.Ordinal));
|
||||
|
||||
// Rollback — no template row landed.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "ScriptCallsUnknown"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
|
||||
{
|
||||
// Arrange — template with a String attribute Status and a
|
||||
// RangeViolation alarm against it. The full SemanticValidator must
|
||||
// report TriggerOperandType (RangeViolation requires numeric).
|
||||
// Pass 1 doesn't scan alarm JSON, so the error reaches Pass 2.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("TankWithBadAlarm") { Description = "RangeViolation on string attr" };
|
||||
t.Attributes.Add(new TemplateAttribute("Status")
|
||||
{
|
||||
DataType = DataType.String,
|
||||
Value = "OK",
|
||||
});
|
||||
t.Alarms.Add(new TemplateAlarm("BadRange")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"min\":0,\"max\":100}",
|
||||
PriorityLevel = 1,
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
// Act
|
||||
SemanticValidationException ex = default!;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "TankWithBadAlarm", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert — error names the offending alarm and the bad trigger
|
||||
// type so the operator can locate the fix.
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors,
|
||||
err => err.Contains("BadRange", StringComparison.Ordinal)
|
||||
&& err.Contains("RangeViolation", StringComparison.Ordinal));
|
||||
|
||||
// Rollback — no template row landed.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "TankWithBadAlarm"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_bundle_passes_semantic_validation()
|
||||
{
|
||||
// Arrange — clean template that satisfies both passes: one Double
|
||||
// attribute, one ValueMatch alarm on it, one script with no external
|
||||
// call identifiers. ValueMatch doesn't constrain the operand data
|
||||
// type (only RangeViolation / HiLo do), so this template's alarm is
|
||||
// legal.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("CleanPump") { Description = "passes both passes" };
|
||||
t.Attributes.Add(new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
Value = "0",
|
||||
});
|
||||
t.Alarms.Add(new TemplateAlarm("Overspeed")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Speed\",\"value\":100}",
|
||||
PriorityLevel = 1,
|
||||
});
|
||||
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
|
||||
"tick",
|
||||
"// no external calls"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportWipeAndLoadAsync();
|
||||
|
||||
// Act — happy-path import.
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "CleanPump", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — template + alarm survived the round-trip.
|
||||
Assert.Equal(1, result.Added);
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "CleanPump"));
|
||||
Assert.True(await ctx.TemplateAlarms.AnyAsync(a => a.Name == "Overspeed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
using ScadaLink.Transport.Encryption;
|
||||
using ScadaLink.Transport.Import;
|
||||
using ScadaLink.Transport.Serialization;
|
||||
@@ -113,7 +114,8 @@ public sealed class BundleImporterLoadTests
|
||||
// a no-provider DbContext so the importer's null check passes;
|
||||
// the in-memory provider isn't worth pulling in for unit tests.
|
||||
dbContext: new ScadaLinkDbContext(
|
||||
new DbContextOptionsBuilder<ScadaLinkDbContext>().Options));
|
||||
new DbContextOptionsBuilder<ScadaLinkDbContext>().Options),
|
||||
semanticValidator: new SemanticValidator());
|
||||
|
||||
return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user