merge(r2): r2-plan05
This commit is contained in:
@@ -149,6 +149,48 @@ public class ScriptCompilerTests
|
||||
Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit()
|
||||
{
|
||||
ScriptCompileVerdictCache.Clear();
|
||||
ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
|
||||
var hitsBefore = ScriptCompileVerdictCache.Hits;
|
||||
var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
|
||||
Assert.Null(error);
|
||||
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface()
|
||||
{
|
||||
// "Notify != null" resolves on ScriptCompileSurface (Notify is a script global,
|
||||
// ScriptCompileSurface.cs:53) but NOT on TriggerCompileSurface — a shared
|
||||
// code-only cache entry would wrongly report the trigger expression clean.
|
||||
ScriptCompileVerdictCache.Clear();
|
||||
Assert.True(new ScriptCompiler().TryCompile("Notify != null", "S").IsSuccess); // warms the SCRIPT-surface entry
|
||||
var error = ValidationService.CheckExpressionSyntax("Notify != null");
|
||||
Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll()
|
||||
{
|
||||
var error = ValidationService.CheckExpressionSyntax(
|
||||
"System.IO.File.Exists(\"x\") && System.Diagnostics.Process.GetProcesses().Length > 0");
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("System.IO", error); // FAILS today: only violations[0] is surfaced
|
||||
Assert.Contains("Process", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckExpressionSyntax_MultipleCompileErrors_ReportsAll()
|
||||
{
|
||||
var error = ValidationService.CheckExpressionSyntax("NoSuchThingA > 1 && NoSuchThingB < 2");
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("NoSuchThingA", error); // FAILS today: only errors[0] is surfaced
|
||||
Assert.Contains("NoSuchThingB", error);
|
||||
}
|
||||
|
||||
// --- Compile-verdict cache: unchanged code compiles once per process ---
|
||||
|
||||
[Fact]
|
||||
@@ -163,6 +205,20 @@ public class ScriptCompilerTests
|
||||
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrAdd_SameCodeDifferentSurface_ComputesSeparateVerdicts()
|
||||
{
|
||||
ScriptCompileVerdictCache.Clear();
|
||||
var a = ScriptCompileVerdictCache.GetOrAdd("SurfaceA", "return 1;", () => (true, null));
|
||||
var hitsAfterA = ScriptCompileVerdictCache.Hits;
|
||||
var b = ScriptCompileVerdictCache.GetOrAdd("SurfaceB", "return 1;", () => (false, "err"));
|
||||
|
||||
Assert.True(a.Ok);
|
||||
Assert.False(b.Ok); // code-only key would return SurfaceA's verdict
|
||||
Assert.Equal(hitsAfterA, ScriptCompileVerdictCache.Hits); // no false cross-surface hit
|
||||
Assert.Equal(2, ScriptCompileVerdictCache.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_CachedFailure_FormatsNameForEachCaller()
|
||||
{
|
||||
|
||||
@@ -462,4 +462,43 @@ public class ValidationServiceTests
|
||||
Assert.DoesNotContain(strictResult.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
|
||||
Assert.DoesNotContain(strictResult.Warnings, w => w.Category == ValidationCategory.AlarmTriggerReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SkipCompilation_SkipsExpressionTriggerSyntaxCheck()
|
||||
{
|
||||
var config = ConfigWithExpressionTriggerScript("this is not C# ((");
|
||||
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
|
||||
Assert.DoesNotContain(gated.Errors, e => e.Message.Contains("failed validation")); // FAILS today
|
||||
var full = new ValidationService().Validate(config);
|
||||
Assert.Contains(full.Errors, e => e.Message.Contains("failed validation")); // deploy gate unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SkipCompilation_StillChecksExpressionAttributeReferences()
|
||||
{
|
||||
// expression "Attributes[\"Ghost\"] != null" referencing a missing attribute:
|
||||
// the reference scan is cheap string work and MUST survive the gate.
|
||||
var config = ConfigWithExpressionTriggerScript("Attributes[\"Ghost\"] != null");
|
||||
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
|
||||
Assert.Contains(gated.Errors, e => e.Message.Contains("Ghost"));
|
||||
}
|
||||
|
||||
private static FlattenedConfiguration ConfigWithExpressionTriggerScript(string expression)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(new { expression });
|
||||
return new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "OnExpr",
|
||||
TriggerType = "Expression",
|
||||
TriggerConfiguration = json
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -1397,6 +1397,61 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
|
||||
{
|
||||
// ApiMethod imported as Add into a target where the name does not exist.
|
||||
// Delete-then-reimport-as-Add is the N3 edge: a node can still hold a
|
||||
// _knownBadMethods / compiled-handler entry under that very name, so Adds
|
||||
// must notify too (over-approximation is safe per the bus contract).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ApiMethods.Add(new ApiMethod("DelmiaRecipeDownload", "return 1;") { TimeoutSeconds = 30 });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: Array.Empty<int>(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
|
||||
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);
|
||||
ms.Position = 0;
|
||||
|
||||
// Delete the target method so the resolution is a genuine Add.
|
||||
ctx.ApiMethods.RemoveRange(await ctx.ApiMethods.ToListAsync());
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ApiMethod", "DelmiaRecipeDownload", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
var notification = Assert.Single(_artifactBus.Received,
|
||||
n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod);
|
||||
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
||||
{
|
||||
|
||||
+166
@@ -1222,4 +1222,170 @@ public sealed class SiteInstanceImportTests : IDisposable
|
||||
|
||||
Assert.Equal(1, result.Added);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// N4 (plan R2-05 T7): warn on overrides of LOCKED template members
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
|
||||
{
|
||||
// Template attribute "SetPoint" IsLocked=true; instance carries an
|
||||
// InstanceAttributeOverride for "SetPoint" — the flattener drops it, so the
|
||||
// import surfaces an advisory warning but the row IS still written.
|
||||
await SeedLockedMemberClosureAsync(lockAttribute: true);
|
||||
|
||||
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||
await WipeSiteClosureAsync();
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var nameMap = new BundleNameMap(
|
||||
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||
Connections: Array.Empty<ConnectionMapping>());
|
||||
|
||||
var result = await ApplyAsync(
|
||||
sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||
},
|
||||
nameMap);
|
||||
|
||||
Assert.Contains(result.Warnings, w =>
|
||||
w.Contains("SetPoint", StringComparison.Ordinal)
|
||||
&& w.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Behaviour unchanged: the override row IS still written (bundle fidelity).
|
||||
await using (var verify = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.True(await ctx.InstanceAttributeOverrides.AnyAsync(o => o.AttributeName == "SetPoint"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
|
||||
{
|
||||
// Template native-alarm-source "NativeSrc" IsLocked=true; instance carries a
|
||||
// NativeAlarmSourceOverride for it. The wizard shows a non-blocking Warning row.
|
||||
await SeedLockedMemberClosureAsync(lockNativeSource: true);
|
||||
|
||||
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
preview = await importer.PreviewAsync(sessionId);
|
||||
}
|
||||
|
||||
Assert.Contains(preview.Items, b =>
|
||||
b.Kind == ConflictKind.Warning && b.EntityType == "Instance"
|
||||
&& b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
|
||||
{
|
||||
// Same shape, but "SetPoint" is NOT locked — no lock warning must appear.
|
||||
await SeedLockedMemberClosureAsync(lockAttribute: false);
|
||||
|
||||
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||
await WipeSiteClosureAsync();
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var nameMap = new BundleNameMap(
|
||||
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||
Connections: Array.Empty<ConnectionMapping>());
|
||||
|
||||
var result = await ApplyAsync(
|
||||
sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||
},
|
||||
nameMap);
|
||||
|
||||
Assert.DoesNotContain(result.Warnings, w =>
|
||||
w.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose
|
||||
/// overrides target the given members; <paramref name="lockAttribute"/> /
|
||||
/// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag.
|
||||
/// The attribute override is always present; the native-source override (and its
|
||||
/// connection) are seeded only when a native source is involved.
|
||||
/// </summary>
|
||||
private async Task SeedLockedMemberClosureAsync(bool lockAttribute = false, bool lockNativeSource = false)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
|
||||
var template = new Template("Pump") { Description = "pump tpl" };
|
||||
template.Attributes.Add(new TemplateAttribute("SetPoint") { Value = "0", IsLocked = lockAttribute });
|
||||
if (lockNativeSource)
|
||||
{
|
||||
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("NativeSrc")
|
||||
{
|
||||
ConnectionName = "OpcUaPrimary",
|
||||
SourceReference = "ns=3;s=Pump.Alarm",
|
||||
IsLocked = true,
|
||||
});
|
||||
}
|
||||
ctx.Templates.Add(template);
|
||||
|
||||
var site = new Site("Plant 1", "plant-1")
|
||||
{
|
||||
NodeAAddress = "akka://site@10.0.0.1:2552",
|
||||
NodeBAddress = "akka://site@10.0.0.2:2552",
|
||||
GrpcNodeAAddress = "10.0.0.1:8083",
|
||||
GrpcNodeBAddress = "10.0.0.2:8083",
|
||||
};
|
||||
ctx.Sites.Add(site);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
DataConnection? conn = null;
|
||||
if (lockNativeSource)
|
||||
{
|
||||
conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
||||
{
|
||||
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
|
||||
FailoverRetryCount = 5,
|
||||
};
|
||||
ctx.DataConnections.Add(conn);
|
||||
}
|
||||
|
||||
var instance = new Instance("Pump-01")
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
SiteId = site.Id,
|
||||
State = InstanceState.Enabled,
|
||||
};
|
||||
instance.AttributeOverrides.Add(new InstanceAttributeOverride("SetPoint") { OverrideValue = "42" });
|
||||
if (lockNativeSource)
|
||||
{
|
||||
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
|
||||
{
|
||||
ConnectionNameOverride = "OpcUaPrimary",
|
||||
SourceReferenceOverride = "ns=3;s=Pump.Alarm2",
|
||||
});
|
||||
}
|
||||
ctx.Instances.Add(instance);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
+179
@@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
@@ -122,6 +124,78 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a template + site + instance whose single alarm override carries the
|
||||
/// given <paramref name="triggerConfigJson"/> as its
|
||||
/// <c>TriggerConfigurationOverride</c>, so an export→load carries the instance
|
||||
/// alarm-override trigger expression through the trust gate.
|
||||
/// </summary>
|
||||
private async Task SeedInstanceAlarmOverrideAsync(string triggerConfigJson)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
|
||||
var template = new Template("Pump") { Description = "pump tpl" };
|
||||
ctx.Templates.Add(template);
|
||||
|
||||
var site = new Site("Plant 1", "plant-1")
|
||||
{
|
||||
NodeAAddress = "akka://site@10.0.0.1:2552",
|
||||
NodeBAddress = "akka://site@10.0.0.2:2552",
|
||||
GrpcNodeAAddress = "10.0.0.1:8083",
|
||||
GrpcNodeBAddress = "10.0.0.2:8083",
|
||||
};
|
||||
ctx.Sites.Add(site);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("Pump-01")
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
SiteId = site.Id,
|
||||
State = InstanceState.Enabled,
|
||||
};
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
|
||||
{
|
||||
TriggerConfigurationOverride = triggerConfigJson,
|
||||
});
|
||||
ctx.Instances.Add(instance);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Exports every seeded site (+ template) closure into a bundle and loads it.</summary>
|
||||
private async Task<Guid> ExportSitesAndLoadAsync()
|
||||
{
|
||||
byte[] bundleBytes;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: true,
|
||||
SiteIds: siteIds);
|
||||
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();
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
using var input = new MemoryStream(bundleBytes, writable: false);
|
||||
return (await importer.LoadAsync(input, passphrase: null)).SessionId;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import()
|
||||
{
|
||||
@@ -406,6 +480,111 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
||||
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks()
|
||||
{
|
||||
// N5 — an instance alarm override's TriggerConfigurationOverride replaces the
|
||||
// template alarm's trigger config in the flattened config and compiles/executes
|
||||
// at the site, so a forbidden API in it must be caught at import review — the
|
||||
// same hard-block severity as every other trust-gate surface.
|
||||
await SeedInstanceAlarmOverrideAsync(
|
||||
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
|
||||
|
||||
var sessionId = await ExportSitesAndLoadAsync();
|
||||
|
||||
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", "Pump", ResolutionAction.Add, null),
|
||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
Assert.NotEmpty(ex.Errors);
|
||||
Assert.Contains(ex.Errors, e =>
|
||||
e.Contains("Process", StringComparison.Ordinal)
|
||||
&& e.Contains("trigger override", StringComparison.Ordinal));
|
||||
|
||||
// Rollback contract: nothing from THIS import was written (the seed rows
|
||||
// pre-exist, but no new import ran — the throw is pre-write at Pass 0).
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker()
|
||||
{
|
||||
await SeedInstanceAlarmOverrideAsync(
|
||||
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
|
||||
|
||||
var sessionId = await ExportSitesAndLoadAsync();
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
preview = await importer.PreviewAsync(sessionId);
|
||||
}
|
||||
|
||||
Assert.Contains(preview.Items, b =>
|
||||
b.Kind == ConflictKind.Blocker && b.EntityType == "Instance"
|
||||
&& b.BlockerReason!.Contains("trust violation", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports()
|
||||
{
|
||||
// A clean expression against the trigger surface imports without hard-block;
|
||||
// the override row is persisted verbatim (bundle fidelity).
|
||||
await SeedInstanceAlarmOverrideAsync(
|
||||
"{\"expression\":\"Attributes[\\\"Temp\\\"] != null\"}");
|
||||
|
||||
var sessionId = await ExportSitesAndLoadAsync();
|
||||
|
||||
// Wipe so the apply exercises a create; re-create the template the instance binds to.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
|
||||
ctx.Instances.RemoveRange(ctx.Instances);
|
||||
ctx.Sites.RemoveRange(ctx.Sites);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var nameMap = new BundleNameMap(
|
||||
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||
Connections: Array.Empty<ConnectionMapping>());
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob", ct: CancellationToken.None, nameMap: nameMap);
|
||||
}
|
||||
|
||||
await using (var verify = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var ovr = await ctx.InstanceAlarmOverrides.SingleAsync();
|
||||
Assert.Equal("HiAlarm", ovr.AlarmCanonicalName);
|
||||
Assert.Contains("Attributes", ovr.TriggerConfigurationOverride!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
|
||||
{
|
||||
|
||||
@@ -38,6 +38,22 @@ public class TransportOptionsValidatorTests
|
||||
Assert.Contains("MaxBundleSizeMb", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaxConcurrentImportSessions_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxConcurrentImportSessions = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage); // FAILS today: validates clean
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeMaxConcurrentImportSessions_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxConcurrentImportSessions = -1 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroPbkdf2Iterations_IsRejected()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user