@@ -40,6 +40,37 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — count of <see cref="UnlockAsync"/> invocations.
|
||||
/// Tests assert this to verify the driver routed <c>cnc_wrunlockparam</c> on
|
||||
/// connect when <c>FocasDeviceOptions.Password</c> was non-null and re-issued
|
||||
/// unlock on EW_PASSWD retry exactly once.
|
||||
/// </summary>
|
||||
public int UnlockCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — last password observed by <see cref="UnlockAsync"/>.
|
||||
/// Used by the round-trip test to confirm the driver passed the configured
|
||||
/// password through unmodified. <b>This field exists ONLY in the fake — no
|
||||
/// production wire client retains the password past the wire call.</b>
|
||||
/// </summary>
|
||||
public string? LastUnlockPassword { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — when set, <see cref="UnlockAsync"/> throws on
|
||||
/// invocation so tests can drive the failed-unlock retry path (where the
|
||||
/// driver surfaces BadUserAccessDenied as-is rather than retrying).
|
||||
/// </summary>
|
||||
public bool ThrowOnUnlock { get; set; }
|
||||
|
||||
public virtual Task UnlockAsync(string password, CancellationToken ct)
|
||||
{
|
||||
UnlockCount++;
|
||||
LastUnlockPassword = password;
|
||||
if (ThrowOnUnlock) throw Exception ?? new InvalidOperationException("Unlock fails");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #271, plan PR F4-d — series-level (would-be integration) coverage of
|
||||
/// <c>cnc_wrunlockparam</c>. Hardware-gated: the FOCAS driver has no public
|
||||
/// simulator (task #222) so the live-controller cases require a real CNC with
|
||||
/// parameter-protect on. The CI lane for this assembly runs the unit-test fakes
|
||||
/// under <see cref="FocasUnlockTests"/>; this file is a scaffold that runs
|
||||
/// against a simulator + matching <c>mock_set_password</c> admin endpoint when
|
||||
/// <c>FOCAS_TRUST_WIRE=1</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Build-only today: the simulator gate (FOCAS_TRUST_WIRE) skips at runtime so
|
||||
/// CI doesn't need the simulator binary. When the simulator's
|
||||
/// <c>cnc_wrunlockparam</c> + <c>mock_set_password</c> endpoints land
|
||||
/// (<c>docs/v2/implementation/focas-simulator-plan.md</c>) the gated test
|
||||
/// becomes a real round-trip.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Series")]
|
||||
public sealed class PasswordUnlockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Single_unlock_retry_path_is_documented()
|
||||
{
|
||||
// Build-only scaffold — see FocasUnlockTests for the actual fake-backed
|
||||
// assertion. The integration version of this test (gated on a FOCAS
|
||||
// simulator with mock_set_password) will:
|
||||
// 1. Configure the simulator with password "1234".
|
||||
// 2. Spin up FocasDriver with FocasDeviceOptions.Password = "1234".
|
||||
// 3. Issue a cnc_wrparam against PARAM:1815 — expect Good (unlock applied
|
||||
// on connect).
|
||||
// 4. Use the simulator's admin endpoint to flip the password to "5678"
|
||||
// mid-session (forces EW_PASSWD on the next write).
|
||||
// 5. Issue another write — expect EW_PASSWD on attempt 1, then BadUserAccessDenied
|
||||
// surfaced because the new password doesn't match the cached one.
|
||||
// 6. Reconfigure FocasDeviceOptions.Password = "5678" on a new instance,
|
||||
// issue write — expect Good (unlock applied on first connect).
|
||||
// For now this test merely asserts the type contract; the simulator is
|
||||
// tracked under task #222 + the focas-simulator-plan.md document.
|
||||
typeof(IFocasClient).GetMethod(nameof(IFocasClient.UnlockAsync))
|
||||
.ShouldNotBeNull();
|
||||
// Driver-side records the password redaction invariant.
|
||||
var dev = new FocasDeviceOptions("focas://1.2.3.4:8193", Password: "1234");
|
||||
dev.ToString().ShouldNotContain("1234");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Hardware-gated — requires the FOCAS simulator with cnc_wrunlockparam + mock_set_password endpoints (task #222 / focas-simulator-plan.md).")]
|
||||
public Task Live_simulator_unlock_retry_round_trip()
|
||||
{
|
||||
// Body deliberately empty — the [Skip] attribute keeps this off the CI lane.
|
||||
// When the simulator lands, this test materialises a FocasDriver pointed at
|
||||
// the simulator + drives the EW_PASSWD -> unlock -> retry path through real
|
||||
// wire calls. See <c>docs/v2/implementation/focas-simulator-plan.md</c>
|
||||
// § "FOCAS password unlock" for the simulator-side endpoints.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user