359
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs
Normal file
359
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #271, plan PR F4-d — <c>cnc_wrunlockparam</c> coverage. Some controllers
|
||||
/// (notably 16i + some 30i firmwares with parameter-protect on) gate
|
||||
/// <c>cnc_wrparam</c> + selected reads behind a connection-level password. The
|
||||
/// driver emits unlock on connect when <c>FocasDeviceOptions.Password</c> is set,
|
||||
/// and on any read/write returning <c>EW_PASSWD</c> -> <c>BadUserAccessDenied</c>
|
||||
/// it re-issues unlock and retries the gated call exactly once.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasUnlockTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
private const string Pwd = "1234";
|
||||
|
||||
private static FocasDriver NewDriver(
|
||||
string? password,
|
||||
FocasWritesOptions writes,
|
||||
FocasTagDefinition[] tags,
|
||||
out FakeFocasClientFactory factory)
|
||||
{
|
||||
factory = new FakeFocasClientFactory();
|
||||
return new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, Password: password)],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = writes,
|
||||
}, "drv-1", factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_set_invokes_UnlockAsync_on_connect()
|
||||
{
|
||||
// First connect attempt happens on the first wire call; trigger one read so
|
||||
// EnsureConnectedAsync runs.
|
||||
var drv = NewDriver(
|
||||
password: Pwd,
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("R100", Host, "R100", FocasDataType.Int16, Writable: false),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Drive one wire call to force the connect path.
|
||||
_ = await drv.ReadAsync(["R100"], CancellationToken.None);
|
||||
|
||||
var fake = factory.Clients.Single();
|
||||
fake.ConnectCount.ShouldBe(1);
|
||||
fake.UnlockCount.ShouldBe(1);
|
||||
fake.LastUnlockPassword.ShouldBe(Pwd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_null_does_NOT_invoke_UnlockAsync()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
password: null,
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("R100", Host, "R100", FocasDataType.Int16, Writable: false),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await drv.ReadAsync(["R100"], CancellationToken.None);
|
||||
|
||||
var fake = factory.Clients.Single();
|
||||
fake.ConnectCount.ShouldBe(1);
|
||||
fake.UnlockCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EW_PASSWD_on_write_with_Password_set_triggers_unlock_and_retries()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
password: Pwd,
|
||||
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: first WriteParameterAsync returns BadUserAccessDenied, second returns Good.
|
||||
// We model this as a fake that flips the write status after the first call.
|
||||
// Use a subclass with one-shot EW_PASSWD then Good.
|
||||
factory.Customise = () => new OneShotPasswdClient(Pwd);
|
||||
|
||||
// Re-init by issuing a fresh write — Customise applies on next Create() call.
|
||||
// The first driver instance already constructed a non-customised client. Build
|
||||
// a fresh driver here so the customised factory wins.
|
||||
var factory2 = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new OneShotPasswdClient(Pwd),
|
||||
};
|
||||
var drv2 = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, Password: Pwd)],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
}, "drv-2", factory2);
|
||||
await drv2.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv2.WriteAsync(
|
||||
[new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var fake = (OneShotPasswdClient)factory2.Clients.Single();
|
||||
// Unlock fires twice: once on connect (Password set) + once on retry.
|
||||
fake.UnlockCount.ShouldBe(2);
|
||||
// Two writes attempted (first returned EW_PASSWD, retry returned Good).
|
||||
fake.ParameterWriteLog.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EW_PASSWD_on_write_without_Password_propagates_BadUserAccessDenied()
|
||||
{
|
||||
// No password configured — the driver must propagate BadUserAccessDenied
|
||||
// immediately rather than attempting a (would-fail) unlock. The retry path
|
||||
// is only meaningful when the deployment has a password to re-emit.
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, Password: null)],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
}, "drv-3", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Seed an EW_PASSWD response on the parameter write — fake returns it as-is.
|
||||
factory.Customise = null; // first Create returns plain FakeFocasClient
|
||||
// Trigger the connect first so Customise can swap behaviour:
|
||||
// simpler: pre-seed status on the existing fake by creating one and registering it.
|
||||
// Use a derived class instead.
|
||||
// -- redo via direct subclass approach:
|
||||
var factory2 = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new AlwaysPasswdClient(),
|
||||
};
|
||||
var drv2 = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, Password: null)],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
}, "drv-3b", factory2);
|
||||
await drv2.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv2.WriteAsync(
|
||||
[new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied);
|
||||
var fake = (AlwaysPasswdClient)factory2.Clients.Single();
|
||||
fake.UnlockCount.ShouldBe(0); // no password -> no unlock attempt at all
|
||||
fake.ParameterWriteLog.Count.ShouldBe(1); // single attempt, no retry
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retry_happens_at_most_once_when_second_attempt_also_returns_EW_PASSWD()
|
||||
{
|
||||
// Recursion guard — the driver retries exactly once. A second EW_PASSWD on
|
||||
// the retry surfaces BadUserAccessDenied unchanged so a wrong password
|
||||
// doesn't loop forever.
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new AlwaysPasswdClient(),
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, Password: Pwd)],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
}, "drv-4", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied);
|
||||
var fake = (AlwaysPasswdClient)factory.Clients.Single();
|
||||
// Connect-time unlock + one retry-time unlock.
|
||||
fake.UnlockCount.ShouldBe(2);
|
||||
// Two write attempts: original + one retry. Never three.
|
||||
fake.ParameterWriteLog.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EW_PASSWD_on_read_also_triggers_unlock_and_retries()
|
||||
{
|
||||
// Some firmwares gate selected reads behind cnc_wrunlockparam too — mirrors the
|
||||
// write retry shape so a session that lost its unlock state mid-flight self-heals.
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new OneShotPasswdClient(Pwd) { GateReads = true },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, Password: Pwd)],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = false },
|
||||
}, "drv-5", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(["Param"], CancellationToken.None);
|
||||
|
||||
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var fake = (OneShotPasswdClient)factory.Clients.Single();
|
||||
// Connect-time unlock + retry unlock = 2.
|
||||
fake.UnlockCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_is_NOT_present_in_FakeFocasClient_WriteLog_invocations()
|
||||
{
|
||||
// Write calls only ever see the address + data type + value — the password
|
||||
// never appears in the per-call wire arguments. This guards against a future
|
||||
// refactor accidentally threading the secret through the per-call surface.
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, Password: "supersecret")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
}, "drv-6", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
var fake = factory.Clients.Single();
|
||||
// None of the captured write-log tuples contains the password string.
|
||||
foreach (var entry in fake.WriteLog.Concat(
|
||||
fake.ParameterWriteLog.Select(p => (p.addr, p.type, p.value))))
|
||||
{
|
||||
entry.ToString()!.ShouldNotContain("supersecret");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DTO_JSON_round_trip_preserves_Password()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "fwlib",
|
||||
"Series": "Sixteen_i",
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.0.0.5:8193", "Password": "1234" }
|
||||
],
|
||||
"Tags": [
|
||||
{ "Name": "Param", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "PARAM:1815", "DataType": "Int32", "Writable": true }
|
||||
]
|
||||
}
|
||||
""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-pwd", json);
|
||||
|
||||
driver.ShouldNotBeNull();
|
||||
// The instance was constructed; we don't expose Password on the driver
|
||||
// surface (that would invert the no-log invariant), but ToString of
|
||||
// FocasDeviceOptions redacts the password — verify that round-trip.
|
||||
var opts = new FocasDeviceOptions("focas://1.2.3.4:8193", Password: "topsecret");
|
||||
opts.ToString().ShouldNotContain("topsecret");
|
||||
opts.ToString().ShouldContain("***");
|
||||
new FocasDeviceOptions("focas://1.2.3.4:8193", Password: null)
|
||||
.ToString().ShouldContain("<null>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test fake — first parameter write returns EW_PASSWD-equivalent
|
||||
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>, second returns Good.
|
||||
/// Optionally extends the same shape to <see cref="ReadAsync"/> for
|
||||
/// read-side tests.
|
||||
/// </summary>
|
||||
private sealed class OneShotPasswdClient : FakeFocasClient
|
||||
{
|
||||
private readonly string _expectedPassword;
|
||||
private bool _firstWriteSeen;
|
||||
private bool _firstReadSeen;
|
||||
|
||||
public OneShotPasswdClient(string expectedPassword)
|
||||
{
|
||||
_expectedPassword = expectedPassword;
|
||||
}
|
||||
|
||||
public bool GateReads { get; set; }
|
||||
|
||||
public override Task<uint> WriteParameterAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
ParameterWriteLog.Add((address, type, value));
|
||||
if (!_firstWriteSeen)
|
||||
{
|
||||
_firstWriteSeen = true;
|
||||
return Task.FromResult(FocasStatusMapper.BadUserAccessDenied);
|
||||
}
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
public override Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (GateReads && !_firstReadSeen)
|
||||
{
|
||||
_firstReadSeen = true;
|
||||
return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadUserAccessDenied));
|
||||
}
|
||||
return Task.FromResult<(object?, uint)>(((object?)0, FocasStatusMapper.Good));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test fake — every parameter write returns
|
||||
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>. Drives the
|
||||
/// "second attempt also fails" recursion-guard test + the no-password
|
||||
/// pass-through test.
|
||||
/// </summary>
|
||||
private sealed class AlwaysPasswdClient : FakeFocasClient
|
||||
{
|
||||
public override Task<uint> WriteParameterAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
ParameterWriteLog.Add((address, type, value));
|
||||
return Task.FromResult(FocasStatusMapper.BadUserAccessDenied);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user