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_wrparam coverage. The driver-level /// Writes.AllowParameter kill switch sits on top of the F4-a /// Writes.Enabled + per-tag Writable opt-ins; PARAM tags /// additionally surface for the /// server-layer ACL gate. /// [Trait("Category", "Unit")] public sealed class FocasWriteParameterTests { 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 AllowParameter_false_returns_BadNotWritable_even_with_Enabled_and_Writable() { // F4-b — the granular kill switch defaults off so a redeployed driver with // Writes.Enabled=true still keeps PARAM writes locked until the operator team // explicitly opts in. This is the critical defense-in-depth assertion. var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowParameter = false, AllowMacro = true }, tags: [ new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Param", 42)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); // Wire client was never even constructed because the gate short-circuited // before EnsureConnectedAsync — defense in depth + lower blast radius if the // wire client is misconfigured. factory.Clients.ShouldBeEmpty(); } [Fact] public async Task AllowParameter_true_dispatches_to_typed_WriteParameterAsync() { var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowParameter = true }, tags: [ new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Param", 42)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); var log = factory.Clients[0].ParameterWriteLog; log.Count.ShouldBe(1); log[0].addr.Kind.ShouldBe(FocasAreaKind.Parameter); log[0].addr.Number.ShouldBe(1815); log[0].type.ShouldBe(FocasDataType.Int32); log[0].value.ShouldBe(42); // Generic write log is untouched — PARAM goes through the typed entry point. factory.Clients[0].WriteLog.ShouldBeEmpty(); } [Fact] public async Task ParameterWrite_round_trip_stores_value_visible_to_subsequent_read() { var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowParameter = true }, tags: [ new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Param", 42)], CancellationToken.None); // Round-trip: the next read sees the written value because the fake stores // by canonical address. This exercises the same shape an integration test // against focas-mock would: write -> read returns the written value. factory.Clients[0].Values["PARAM:1815"].ShouldBe(42); } [Fact] public async Task EW_PASSWD_from_simulator_maps_to_BadUserAccessDenied() { // F4-b — when the CNC reports EW_PASSWD (parameter-write switch off / unlock // required) the status code surfaces as BadUserAccessDenied rather than // BadNotWritable. F4-d will land the unlock workflow on top of this surface. var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowParameter = true }, tags: [ new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); // Seed the fake to mimic EW_PASSWD propagation (mapper assigns // BadUserAccessDenied to that EW_* code post-F4-b). factory.Customise = () => { var c = new FakeFocasClient(); c.WriteStatuses["PARAM:1815"] = FocasStatusMapper.BadUserAccessDenied; return c; }; // Re-init now that the customiser is in place. await drv.ShutdownAsync(CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Param", 42)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied); } [Fact] public void StatusMapper_EW_PASSWD_returns_BadUserAccessDenied() { // EW_PASSWD == 11 per FANUC FOCAS docs. Pre-F4-b this mapped to BadNotWritable; // F4-b remaps it so clients can branch on user-vs-driver write rejection. FocasStatusMapper.MapFocasReturn(11).ShouldBe(FocasStatusMapper.BadUserAccessDenied); } [Fact] public void Tag_classification_PARAM_yields_Configure() { // Server-layer ACL key — PARAM: tags require WriteConfigure group membership // (vs MACRO: tags which require WriteOperate). Per the // feedback_acl_at_server_layer memory the driver only declares classification; // DriverNodeManager applies the gate. var tag = new FocasTagDefinition( "Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true); FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Configure); } [Fact] public void Tag_classification_PARAM_non_writable_yields_ViewOnly() { // ViewOnly trumps the kind-based classification when the tag isn't writable. var tag = new FocasTagDefinition( "Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: false); FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.ViewOnly); } [Fact] public void FocasWritesOptions_default_AllowParameter_is_false() { // Defense in depth: a fresh FocasWritesOptions has both granular kill // switches off so a config-DB row that omits AllowParameter doesn't // silently flip parameter writes on. new FocasWritesOptions().AllowParameter.ShouldBeFalse(); new FocasDriverOptions().Writes.AllowParameter.ShouldBeFalse(); } [Fact] public void Dto_round_trip_preserves_AllowParameter() { // JSON config -> FocasDriverOptions; the Writes.AllowParameter flag must // survive the bootstrapper's deserialize step. Use a known-empty Tags config // and a sentinel write to verify the gate reached the driver. const string jsonAllowed = """ { "Backend": "unimplemented", "Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }], "Tags": [{ "Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193", "Address": "PARAM:1815", "DataType": "Int32", "Writable": true }], "Writes": { "Enabled": true, "AllowParameter": true } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", jsonAllowed); drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult(); // Real wire client is the unimplemented one — Connect throws, and the driver // surfaces that as BadCommunicationError. The key is that the result is NOT // BadNotWritable: that proves the AllowParameter gate didn't short-circuit. var results = drv.WriteAsync( [new WriteRequest("P", 42)], CancellationToken.None).GetAwaiter().GetResult(); results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable); } [Fact] public void Dto_default_omitted_AllowParameter_keeps_safer_default() { // A Writes section with just { Enabled: true } must NOT silently flip the // granular kill switches on. PARAM writes should still get BadNotWritable. const string json = """ { "Backend": "unimplemented", "Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }], "Tags": [{ "Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193", "Address": "PARAM:1815", "DataType": "Int32", "Writable": true }], "Writes": { "Enabled": true } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json); drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult(); var results = drv.WriteAsync( [new WriteRequest("P", 42)], CancellationToken.None).GetAwaiter().GetResult(); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); } }