diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md index b2bd9835..9bacc5b2 100644 --- a/docs/requirements/Component-TemplateEngine.md +++ b/docs/requirements/Component-TemplateEngine.md @@ -143,7 +143,7 @@ The `FlatteningService` resolves native alarm sources alongside alarms, emitting - **Inheritance**: resolution walks the chain base → derived; a derived-level source wins over the base unless the base level locked it. - **Composition**: a composed module's sources are path-qualified to the canonical name `[ModuleInstanceName].[Name]`, subject to the same naming-collision checks as other members. Because `SourceReference` is a raw connection address (not an attribute path), composition performs **no attribute-reference rewriting** on it. -- **Instance overrides**: `InstanceNativeAlarmSourceOverride` applies its non-null fields (`ConnectionNameOverride`, `SourceReferenceOverride`, `ConditionFilterOverride`) over the inherited/composed result and sets `Source = Override`. +- **Instance overrides**: `InstanceNativeAlarmSourceOverride` applies its non-null fields (`ConnectionNameOverride`, `SourceReferenceOverride`, `ConditionFilterOverride`) over the inherited/composed result and sets `Source = Override`. The resolved `ResolvedNativeAlarmSource` carries an `IsLocked` flag; a **locked** source is left untouched by any instance override (the flattener skips it, exactly mirroring the locked-attribute and locked-alarm rules). The lock is enforced at two points: the `FlatteningService` ignores overrides on a locked source, and the `ManagementActor` `SetInstanceNativeAlarmSourceOverride` handler rejects the command outright (and also rejects an override whose canonical name does not resolve, preventing dangling overrides). ## Inheritance Resolve — Authoring View diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs index 74e6d9f9..b9fb2efd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs @@ -146,6 +146,12 @@ public sealed record ResolvedNativeAlarmSource public string? ConditionFilter { get; init; } /// Gets the source of this binding: "Template", "Inherited", "Composed", or "Override". public string Source { get; init; } = "Template"; + /// + /// Gets whether this native alarm source is locked at the template level. A locked + /// source cannot be repointed by an instance override — mirrors the attribute/alarm + /// lock semantics. + /// + public bool IsLocked { get; init; } } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index d209787a..b3164b9d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -885,6 +885,27 @@ public class ManagementActor : ReceiveActor await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); var repo = sp.GetRequiredService(); + // Enforce template-level locks (and reject dangling overrides) before persisting. + // Flatten the instance (script compilation skipped — a lock check does not need it) + // and look up the resolved source by canonical name: a locked source cannot be + // overridden, and an unresolved name would create a dead override the flattener + // silently drops. + var pipeline = sp.GetRequiredService(); + var flattenResult = await pipeline.FlattenAndValidateAsync( + cmd.InstanceId, default, validateScripts: false); + if (flattenResult.IsFailure) + throw new ManagementCommandException( + $"Cannot set native alarm source override: the instance could not be flattened ({flattenResult.Error})."); + + var resolvedSource = flattenResult.Value.Configuration.NativeAlarmSources + .FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal)); + if (resolvedSource == null) + throw new ManagementCommandException( + $"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden."); + if (resolvedSource.IsLocked) + throw new ManagementCommandException( + $"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden."); + var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName); if (existing == null) { diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs index 5a685113..a49d64fe 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs @@ -718,7 +718,8 @@ public class FlatteningService ConnectionName = binding.ConnectionName, SourceReference = binding.SourceReference, ConditionFilter = binding.ConditionFilter, - Source = source + Source = source, + IsLocked = binding.IsLocked || lockedNames.Contains(binding.Name) }; if (binding.IsLocked) @@ -811,6 +812,9 @@ public class FlatteningService if (!sources.TryGetValue(ovr.SourceCanonicalName, out var existing)) continue; // Cannot add new bindings via overrides. + if (existing.IsLocked) + continue; // Locked at the template level — instance override is ignored. + sources[ovr.SourceCanonicalName] = existing with { ConnectionName = ovr.ConnectionNameOverride ?? existing.ConnectionName, diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 256d8426..02a35321 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -12,6 +12,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.TemplateEngine; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; @@ -232,6 +234,39 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Pump1", response.JsonData); } + [Fact] + public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError() + { + // #05-T13: the ManagementActor must reject an override targeting a source + // that is locked at the template level, before persisting anything. + var pipeline = Substitute.For(); + var flattened = new FlattenedConfiguration + { + NativeAlarmSources = new List + { + new() { CanonicalName = "Pressure", IsLocked = true, Source = "Template" } + } + }; + pipeline.FlattenAndValidateAsync(10, Arg.Any(), Arg.Any()) + .Returns(Result.Success( + new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success()))); + _services.AddScoped(_ => pipeline); + + var actor = CreateActor(); + var envelope = Envelope( + new SetInstanceNativeAlarmSourceOverrideCommand(10, "Pressure", "OtherOpc", null, null), + "Deployer"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase); + // The override must NOT be persisted. + _templateRepo.DidNotReceive().AddInstanceNativeAlarmSourceOverrideAsync( + Arg.Any()); + } + // ======================================================================== // 5. Error handling test -- service throws exception // ======================================================================== @@ -2479,7 +2514,21 @@ public class ManagementActorTests : TestKit, IDisposable [Fact] public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess() { - // No prior override → Add path. + // No prior override → Add path. The source resolves and is UNLOCKED, so the + // lock guard (#05-T13) lets the override through. + var pipeline = Substitute.For(); + var flattened = new FlattenedConfiguration + { + NativeAlarmSources = new List + { + new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" } + } + }; + pipeline.FlattenAndValidateAsync(1, Arg.Any(), Arg.Any()) + .Returns(Result.Success( + new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success()))); + _services.AddScoped(_ => pipeline); + var actor = CreateActor(); var envelope = Envelope( new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null), diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs index 9eb2f31c..803b7bfd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs @@ -789,6 +789,32 @@ public class FlatteningServiceTests Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source); } + [Fact] + public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride() + { + // #05-T13: a native alarm source locked at the template level must not be + // repointed by an instance override — the resolved source keeps the template + // values and its Source stays "Template" (mirrors attribute/alarm lock rules). + var template = CreateTemplate(1, "Base"); + template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure") + { ConnectionName = "Opc", SourceReference = "ns=2;s=DEFAULT", IsLocked = true }); + var instance = CreateInstance(); + instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Pressure") + { ConnectionNameOverride = "OtherOpc", SourceReferenceOverride = "ns=2;s=Tank07" }); + + var result = _sut.Flatten(instance, [template], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + + Assert.True(result.IsSuccess); + var src = result.Value.NativeAlarmSources[0]; + Assert.Equal("Opc", src.ConnectionName); // override ignored + Assert.Equal("ns=2;s=DEFAULT", src.SourceReference); // override ignored + Assert.Equal("Template", src.Source); + Assert.True(src.IsLocked); + } + // ── MV-4: ElementDataType carried through flatten/override ──────────── [Fact]