@@ -0,0 +1,232 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #269, plan PR F4-b — <c>cnc_wrparam</c> coverage. The driver-level
|
||||
/// <c>Writes.AllowParameter</c> kill switch sits on top of the F4-a
|
||||
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins; PARAM tags
|
||||
/// additionally surface <see cref="SecurityClassification.Configure"/> for the
|
||||
/// server-layer ACL gate.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user