Auto: focas-f4d — password / unlock parameter

Closes #271
This commit is contained in:
Joseph Doherty
2026-04-26 05:45:13 -04:00
parent d676b4056d
commit 86f3fc2733
16 changed files with 1016 additions and 40 deletions

View File

@@ -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)
{

View 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);
}
}
}

View File

@@ -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;
}
}