360 lines
15 KiB
C#
360 lines
15 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|