using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Issue #270, plan PR F4-c — pmc_wrpmcrng coverage. The driver-level /// Writes.AllowPmc kill switch sits on top of the F4-a /// Writes.Enabled + per-tag Writable opt-ins. PMC bit writes /// additionally exercise the read-modify-write helper (pmc_wrpmcrng is /// byte-addressed; the wire never sees a sub-byte write). PMC tags surface /// for the server-layer ACL gate. /// [Trait("Category", "Unit")] public sealed class FocasWritePmcTests { 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); } /// /// Variant that pre-seeds a fake's PMC byte storage (so an RMW test can verify /// the read-side picked up the prior byte before the bit mask). The customiser /// fires once per device — sufficient for the single-device tests below. /// private static FocasDriver NewDriverWithSeededPmc( FocasWritesOptions writes, FocasTagDefinition[] tags, string letter, int pathId, byte[] seed, out FakeFocasClientFactory factory) { factory = new FakeFocasClientFactory { Customise = () => { var c = new FakeFocasClient(); c.PmcByteRanges[(letter.ToUpperInvariant(), pathId)] = (byte[])seed.Clone(); return c; }, }; 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 AllowPmc_false_returns_BadNotWritable_even_with_Enabled_and_Writable() { // F4-c — the granular kill switch defaults off so a redeployed driver with // Writes.Enabled=true still keeps PMC writes locked until the operator team // explicitly opts in. PMC is ladder working memory; defense-in-depth is // critical because a mistargeted bit can move motion or latch a feedhold. var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowPmc = false }, tags: [ new FocasTagDefinition("Coil", Host, "R100", FocasDataType.Byte, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Coil", (sbyte)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. factory.Clients.ShouldBeEmpty(); } [Fact] public async Task AllowPmc_true_byte_write_dispatches_to_typed_WritePmcRangeAsync() { var drv = NewDriver( writes: new FocasWritesOptions { Enabled = true, AllowPmc = true }, tags: [ new FocasTagDefinition("Coil", Host, "R100", FocasDataType.Byte, Writable: true), ], out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Coil", (sbyte)42)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); var log = factory.Clients[0].PmcRangeWriteLog; log.Count.ShouldBe(1); log[0].Letter.ShouldBe("R"); log[0].StartByte.ShouldBe(100); log[0].Bytes.ShouldBe(new byte[] { 42 }); // Generic WriteAsync path is untouched — PMC byte goes through the typed entry point. factory.Clients[0].WriteLog.ShouldBeEmpty(); } [Fact] public async Task PMC_bit_write_set_RMW_preserves_zero_byte_writes_only_target_bit() { // Prior byte = 0b0000_0000; set bit 3 → write byte = 0b0000_1000. var drv = NewDriverWithSeededPmc( writes: new FocasWritesOptions { Enabled = true, AllowPmc = true }, tags: [new FocasTagDefinition("G50_3", Host, "G50.3", FocasDataType.Bit, Writable: true)], letter: "G", pathId: 1, seed: PmcBuffer(byteAddr: 50, value: 0b0000_0000), out var factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("G50_3", true)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); var log = factory.Clients[0].PmcRangeWriteLog.Single(); log.Letter.ShouldBe("G"); log.StartByte.ShouldBe(50); log.Bytes.ShouldBe(new byte[] { 0b0000_1000 }); } [Fact] public async Task PMC_bit_write_set_preserves_other_bits_already_set() { // Prior byte = 0b1111_0000; set bit 0 → write byte = 0b1111_0001. var drv = NewDriverWithSeededPmc( writes: new FocasWritesOptions { Enabled = true, AllowPmc = true }, tags: [new FocasTagDefinition("R50_0", Host, "R50.0", FocasDataType.Bit, Writable: true)], letter: "R", pathId: 1, seed: PmcBuffer(byteAddr: 50, value: 0b1111_0000), out var factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("R50_0", true)], CancellationToken.None); var log = factory.Clients[0].PmcRangeWriteLog.Single(); log.Bytes.ShouldBe(new byte[] { 0b1111_0001 }); } [Fact] public async Task PMC_bit_write_clear_preserves_other_bits() { // Prior byte = 0b1111_1111; clear bit 0 → write byte = 0b1111_1110. var drv = NewDriverWithSeededPmc( writes: new FocasWritesOptions { Enabled = true, AllowPmc = true }, tags: [new FocasTagDefinition("R50_0", Host, "R50.0", FocasDataType.Bit, Writable: true)], letter: "R", pathId: 1, seed: PmcBuffer(byteAddr: 50, value: 0xFF), out var factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("R50_0", false)], CancellationToken.None); var log = factory.Clients[0].PmcRangeWriteLog.Single(); log.Bytes.ShouldBe(new byte[] { 0b1111_1110 }); } [Fact] public async Task Multiple_consecutive_bit_writes_in_same_byte_serialise() { // Each bit write does its own RMW (Read range -> mask -> Write range). Eight // consecutive bit-set writes on R100 starting from 0 must compose to 0xFF. var tags = Enumerable.Range(0, 8) .Select(b => new FocasTagDefinition($"Bit{b}", Host, $"R100.{b}", FocasDataType.Bit, Writable: true)) .ToArray(); var drv = NewDriverWithSeededPmc( writes: new FocasWritesOptions { Enabled = true, AllowPmc = true }, tags: tags, letter: "R", pathId: 1, seed: PmcBuffer(byteAddr: 100, value: 0), out var factory); await drv.InitializeAsync("{}", CancellationToken.None); for (var b = 0; b < 8; b++) await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None); var fake = factory.Clients[0]; // 8 RMW round-trips, each writing the cumulative byte back. fake.PmcRangeWriteLog.Count.ShouldBe(8); fake.PmcByteRanges[("R", 1)][100].ShouldBe((byte)0xFF); // Every write hit the same byte address 100. fake.PmcRangeWriteLog.ShouldAllBe(e => e.StartByte == 100 && e.Bytes.Length == 1); } [Fact] public void Tag_classification_PMC_writable_yields_Operate() { // Server-layer ACL key — PMC tags require WriteOperate group membership // (mirrors MACRO; PARAM is the higher-friction Configure tier). var tag = new FocasTagDefinition( "Coil", Host, "R100.3", FocasDataType.Bit, Writable: true); FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Operate); } [Fact] public void Tag_classification_PMC_non_writable_yields_ViewOnly() { var tag = new FocasTagDefinition( "Coil", Host, "R100.3", FocasDataType.Bit, Writable: false); FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.ViewOnly); } [Fact] public void FocasWritesOptions_default_AllowPmc_is_false() { // Defense in depth: a fresh FocasWritesOptions has every granular kill // switch off. A config row that omits AllowPmc must NOT silently flip PMC // writes on. new FocasWritesOptions().AllowPmc.ShouldBeFalse(); new FocasDriverOptions().Writes.AllowPmc.ShouldBeFalse(); } [Fact] public void Dto_round_trip_preserves_AllowPmc() { // JSON config -> FocasDriverOptions; the Writes.AllowPmc flag must survive // the bootstrapper's deserialize step. Sentinel: a write at a configured // PMC tag should NOT short-circuit to BadNotWritable when AllowPmc=true is // set in the JSON (the unimplemented backend will surface BadCommunicationError // instead). const string jsonAllowed = """ { "Backend": "unimplemented", "Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }], "Tags": [{ "Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193", "Address": "R100", "DataType": "Byte", "Writable": true }], "Writes": { "Enabled": true, "AllowPmc": true } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", jsonAllowed); drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult(); var results = drv.WriteAsync( [new WriteRequest("P", (sbyte)1)], CancellationToken.None).GetAwaiter().GetResult(); // Key assertion: NOT BadNotWritable — that proves the AllowPmc gate didn't short-circuit. results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable); } [Fact] public void Dto_default_omitted_AllowPmc_keeps_safer_default() { // A Writes section with just { Enabled: true } must NOT silently flip the // granular kill switch on. PMC 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": "R100", "DataType": "Byte", "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", (sbyte)1)], CancellationToken.None).GetAwaiter().GetResult(); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); } private static byte[] PmcBuffer(int byteAddr, byte value) { // Allocate enough buffer to hold the byteAddr index, fill the chosen byte. var buf = new byte[byteAddr + 1]; buf[byteAddr] = value; return buf; } }