Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWritePmcTests.cs
2026-04-26 05:15:52 -04:00

293 lines
12 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Issue #270, plan PR F4-c — <c>pmc_wrpmcrng</c> coverage. The driver-level
/// <c>Writes.AllowPmc</c> kill switch sits on top of the F4-a
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins. PMC bit writes
/// additionally exercise the read-modify-write helper (<c>pmc_wrpmcrng</c> is
/// byte-addressed; the wire never sees a sub-byte write). PMC tags surface
/// <see cref="SecurityClassification.Operate"/> for the server-layer ACL gate.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}