using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
/// Issue #271, plan PR F4-d — cnc_wrunlockparam coverage. Some controllers
/// (notably 16i + some 30i firmwares with parameter-protect on) gate
/// cnc_wrparam + selected reads behind a connection-level password. The
/// driver emits unlock on connect when FocasDeviceOptions.Password is set,
/// and on any read/write returning EW_PASSWD -> BadUserAccessDenied
/// it re-issues unlock and retries the gated call exactly once.
///
[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("");
}
///
/// Test fake — first parameter write returns EW_PASSWD-equivalent
/// , second returns Good.
/// Optionally extends the same shape to for
/// read-side tests.
///
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 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));
}
}
///
/// Test fake — every parameter write returns
/// . Drives the
/// "second attempt also fails" recursion-guard test + the no-password
/// pass-through test.
///
private sealed class AlwaysPasswdClient : FakeFocasClient
{
public override Task WriteParameterAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
ParameterWriteLog.Add((address, type, value));
return Task.FromResult(FocasStatusMapper.BadUserAccessDenied);
}
}
}