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