merge(r2): r2-plan05

This commit is contained in:
Joseph Doherty
2026-07-13 11:08:27 -04:00
15 changed files with 772 additions and 55 deletions
@@ -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
}
]
};
}
}
@@ -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()
{
@@ -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();
}
}
@@ -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()
{