using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Issue #269, plan PR F4-b — cnc_wrmacro coverage. The driver-level /// Writes.AllowMacro kill switch sits on top of the F4-a /// Writes.Enabled + per-tag Writable opt-ins. MACRO tags /// surface (lighter ACL than PARAM) /// because macro writes are the standard HMI-driven recipe / setpoint surface. /// [Trait("Category", "Unit")] public sealed class FocasWriteMacroTests { private const string Host = "focas://10.0.0.5:8193"; private static FocasDriver NewDriver( FocasWritesOptions writes, FocasTagDefinition[] tags, out FakeFocasClientFactory factory) { factory = new FakeFocasClientFactory(); return new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = tags, Probe = new FocasProbeOptions { Enabled = false }, Writes = writes, }, "drv-1", factory); } [Fact] public async Task AllowMacro_false_returns_BadNotWritable_even_with_Enabled_and_Writable() { var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowMacro = false, AllowParameter = true }, tags: [ new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("M500", 99)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); // Gate fires pre-EnsureConnectedAsync so no wire client gets constructed at all. factory.Clients.ShouldBeEmpty(); } [Fact] public async Task AllowMacro_true_dispatches_to_typed_WriteMacroAsync() { var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowMacro = true }, tags: [ new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("M500", 99)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); var log = factory.Clients[0].MacroWriteLog; log.Count.ShouldBe(1); log[0].addr.Kind.ShouldBe(FocasAreaKind.Macro); log[0].addr.Number.ShouldBe(500); log[0].value.ShouldBe(99); // Generic write log untouched — MACRO routes through the typed entry point. factory.Clients[0].WriteLog.ShouldBeEmpty(); } [Fact] public async Task MacroWrite_round_trip_stores_value_visible_to_subsequent_read() { var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowMacro = true }, tags: [ new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("M500", 42)], CancellationToken.None); factory.Clients[0].Values["MACRO:500"].ShouldBe(42); } [Fact] public void Tag_classification_MACRO_yields_Operate() { // Server-layer ACL key — MACRO: tags require WriteOperate (per the // F4-b plan note about lighter authorization for HMI recipe writes). var tag = new FocasTagDefinition( "M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true); FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Operate); } [Fact] public void FocasWritesOptions_default_AllowMacro_is_false() { new FocasWritesOptions().AllowMacro.ShouldBeFalse(); new FocasDriverOptions().Writes.AllowMacro.ShouldBeFalse(); } [Fact] public void Dto_round_trip_preserves_AllowMacro() { const string json = """ { "Backend": "unimplemented", "Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }], "Tags": [{ "Name": "M", "DeviceHostAddress": "focas://10.0.0.5:8193", "Address": "MACRO:500", "DataType": "Int32", "Writable": true }], "Writes": { "Enabled": true, "AllowMacro": true } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json); drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult(); var results = drv.WriteAsync( [new WriteRequest("M", 42)], CancellationToken.None).GetAwaiter().GetResult(); // unimplemented backend — anything except BadNotWritable proves the gate let // the call through (likely BadCommunicationError from the no-op factory). results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable); } [Fact] public void Dto_round_trip_preserves_both_granular_flags() { // Both flags must round-trip independently — a deployment that opts into // PARAM only doesn't accidentally let MACRO writes through, and vice versa. const string json = """ { "Backend": "unimplemented", "Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }], "Writes": { "Enabled": true, "AllowParameter": true, "AllowMacro": true } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json); drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult(); // Both gates open: the driver no longer rejects either kind at the granular // gate. Submit one of each and confirm neither maps to BadNotWritable // (BadNodeIdUnknown when the tag isn't configured is fine — what matters is // we stayed past the per-kind gate). var results = drv.WriteAsync( [ new WriteRequest("ParamUnknown", 0), new WriteRequest("MacroUnknown", 0), ], CancellationToken.None).GetAwaiter().GetResult(); // Both writes hit BadNodeIdUnknown (tag-lookup fails) rather than the // BadNotWritable short-circuit — confirms both flags reached the driver. results.Count.ShouldBe(2); results[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Per_kind_gate_does_not_affect_PMC_writes() { // Defense in depth: AllowParameter / AllowMacro stay locked but PMC writes // (which already worked in F4-a) keep flowing through Writes.Enabled + // per-tag Writable. This guards against regressing the F4-a surface. var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowParameter = false, AllowMacro = false, }, tags: [ new FocasTagDefinition("R100", Host, "R100", FocasDataType.Byte, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("R100", (sbyte)1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); // PMC routes through the generic WriteAsync, not the typed entry points. factory.Clients[0].WriteLog.Count.ShouldBe(1); factory.Clients[0].ParameterWriteLog.ShouldBeEmpty(); factory.Clients[0].MacroWriteLog.ShouldBeEmpty(); } }