review(Client.CLI): wrap NodeId parse errors in CommandException for alarm-op commands
Re-review at 7286d320. -011: ack/confirm/enable/disable/shelve now pre-validate --node and
surface a clean CommandException (was a raw FormatException) + tests. -012: refresh stale
test count in docs/Client.CLI.md.
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
|---|---|
|
||||
| Module | `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `7286d320` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 0 |
|
||||
|
||||
@@ -269,3 +269,50 @@ The `FakeOpcUaClientService` already exposes `RaiseDataChanged`, so feeding good
|
||||
and asserting the summary text is straightforward.
|
||||
|
||||
**Resolution:** Resolved 2026-05-23 — added `SubscribeCommandSummaryTests` (covering recursive collection via `FakeOpcUaClientService.AddDiscoveredVariable`, `--duration` auto-exit, summary bucketing for good/bad/never/never-went-bad, and the `--summary-file` write), `CommandRangeValidationTests`, `EventHandlerLifecycleTests`, `InputValidationErrorsTests`, and `LoggerLifecycleTests` to pin the other Low findings; `FakeOpcUaClientService` was extended with `AddDiscoveredVariable` / `RaiseDataChanged` helpers.
|
||||
|
||||
## Re-review 2026-06-19 (commit 7286d320)
|
||||
|
||||
Five new commands were added since the prior review (`76d35d1`): `ack`, `confirm`, `enable`, `disable`, `shelve`. All 10 checklist categories were re-examined against HEAD.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | Client.CLI-011 |
|
||||
| 2 | OtOpcUa conventions | No issues found |
|
||||
| 3 | Concurrency & thread safety | No issues found |
|
||||
| 4 | Error handling & resilience | Client.CLI-011 |
|
||||
| 5 | Security | No issues found |
|
||||
| 6 | Performance & resource management | No issues found |
|
||||
| 7 | Design-document adherence | No issues found |
|
||||
| 8 | Code organization & conventions | No issues found |
|
||||
| 9 | Testing coverage | Client.CLI-011 (new tests added as part of fix) |
|
||||
| 10 | Documentation & comments | Client.CLI-012 |
|
||||
|
||||
### Client.CLI-011
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs / Error handling & resilience |
|
||||
| Location | `Commands/AcknowledgeCommand.cs`, `Commands/ConfirmCommand.cs`, `Commands/EnableCommand.cs`, `Commands/DisableCommand.cs`, `Commands/ShelveCommand.cs` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The five alarm-operation commands (`ack`, `confirm`, `enable`, `disable`, `shelve`) pass `--node` directly to the shared `IOpcUaClientService` methods without first validating the node-id string through `NodeIdParser`. Inside the service, each method calls `NodeId.Parse(conditionNodeId)` before its own `try/catch (ServiceResultException)` block; a malformed value therefore throws a raw exception (not a `CommandException`) that propagates out of `ExecuteAsync` and surfaces as a stack trace, not a clean CLI error. This is exactly the class of issue fixed by Client.CLI-006 for `read`, `write`, `browse`, `subscribe`, and `historyread`, but the five new alarm-op commands were not updated to match.
|
||||
|
||||
**Recommendation:** Call `NodeIdParser.ParseRequired(NodeId)` (discarding the result, since the service accepts a raw string) inside a `catch (FormatException or ArgumentException)` block that rethrows as `CommandException`, mirroring the pattern used by every other command in the module.
|
||||
|
||||
**Resolution:** Resolved 2026-06-19 — all five alarm-op commands now call `NodeIdParser.ParseRequired(NodeId)` at the start of `ExecuteAsync`, catching `FormatException`/`ArgumentException` and rethrowing as `CommandException`. Pinned by `AlarmOpCommandNodeIdValidationTests` (5 new tests, all green). Full suite: 104 tests, 0 failures.
|
||||
|
||||
### Client.CLI-012
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `docs/Client.CLI.md:264` |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `docs/Client.CLI.md` states "The Client CLI has 77 unit tests" but 99 tests existed at HEAD before this re-review, and 104 after the Client.CLI-011 fix. The count was not updated as new test classes were added after the first review.
|
||||
|
||||
**Recommendation:** Update the test count in `docs/Client.CLI.md` to reflect the current 104 tests.
|
||||
|
||||
**Resolution:** Resolved 2026-06-19 — updated test count in `docs/Client.CLI.md` from 77 to 104.
|
||||
|
||||
+1
-1
@@ -261,7 +261,7 @@ Application URI: urn:localhost:OtOpcUa:instance1
|
||||
|
||||
## Testing
|
||||
|
||||
The Client CLI has 77 unit tests covering option parsing, service invocation, output formatting, and cleanup behavior:
|
||||
The Client CLI has 104 unit tests covering option parsing, service invocation, output formatting, and cleanup behavior:
|
||||
|
||||
```bash
|
||||
dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests
|
||||
|
||||
@@ -2,6 +2,7 @@ using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
@@ -43,6 +44,15 @@ public class AcknowledgeCommand : CommandBase
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
try
|
||||
{
|
||||
NodeIdParser.ParseRequired(NodeId);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
throw new CommandException($"Invalid --node value: {ex.Message}");
|
||||
}
|
||||
|
||||
byte[] eventId;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
@@ -43,6 +44,15 @@ public class ConfirmCommand : CommandBase
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
try
|
||||
{
|
||||
NodeIdParser.ParseRequired(NodeId);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
throw new CommandException($"Invalid --node value: {ex.Message}");
|
||||
}
|
||||
|
||||
byte[] eventId;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
@@ -30,6 +32,15 @@ public class DisableCommand : CommandBase
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
try
|
||||
{
|
||||
NodeIdParser.ParseRequired(NodeId);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
throw new CommandException($"Invalid --node value: {ex.Message}");
|
||||
}
|
||||
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
@@ -30,6 +32,15 @@ public class EnableCommand : CommandBase
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
try
|
||||
{
|
||||
NodeIdParser.ParseRequired(NodeId);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
throw new CommandException($"Invalid --node value: {ex.Message}");
|
||||
}
|
||||
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
@@ -45,6 +46,15 @@ public class ShelveCommand : CommandBase
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
try
|
||||
{
|
||||
NodeIdParser.ParseRequired(NodeId);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
throw new CommandException($"Invalid --node value: {ex.Message}");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ShelveKind>(Kind, ignoreCase: true, out var shelveKind))
|
||||
throw new CommandException(
|
||||
$"Invalid --kind value '{Kind}'. Expected one of: OneShot, Timed, Unshelve.");
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
using CliFx.Exceptions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Client.CLI-011: alarm-op commands (ack, confirm, enable, disable, shelve)
|
||||
/// must validate the --node option with a clean CommandException rather than letting a raw
|
||||
/// exception propagate from NodeId.Parse inside the service.
|
||||
/// </summary>
|
||||
public class AlarmOpCommandNodeIdValidationTests
|
||||
{
|
||||
private static string HexOf(byte[] bytes) => Convert.ToHexString(bytes);
|
||||
|
||||
/// <summary>AcknowledgeCommand with an invalid node ID must throw CommandException, not a raw exception.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new AcknowledgeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "not-a-node-id",
|
||||
EventId = HexOf(new byte[] { 0x01 }),
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>ConfirmCommand with an invalid node ID must throw CommandException, not a raw exception.</summary>
|
||||
[Fact]
|
||||
public async Task ConfirmCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ConfirmCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "not-a-node-id",
|
||||
EventId = HexOf(new byte[] { 0x01 }),
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>EnableCommand with an invalid node ID must throw CommandException, not a raw exception.</summary>
|
||||
[Fact]
|
||||
public async Task EnableCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new EnableCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "not-a-node-id"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>DisableCommand with an invalid node ID must throw CommandException, not a raw exception.</summary>
|
||||
[Fact]
|
||||
public async Task DisableCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new DisableCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "not-a-node-id"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>ShelveCommand with an invalid node ID must throw CommandException, not a raw exception.</summary>
|
||||
[Fact]
|
||||
public async Task ShelveCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "not-a-node-id",
|
||||
Kind = "OneShot",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user