fix(cli): resolve CLI-008..013 — format validation, exit-code semantics, debug-stream cancellation/disposal, test coverage
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 6 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ env vars (see CLI-006).
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Program.cs:10-11`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:60` |
|
| Location | `src/ScadaLink.CLI/Program.cs:10-11`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:60` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -334,7 +334,12 @@ Restrict the option to the accepted values, e.g. `formatOption.AcceptOnlyFromAmo
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — the `--format` option was
|
||||||
|
constructed inline in `Program.cs` with no value constraint. Extracted option
|
||||||
|
construction into a new `CliOptions.CreateFormatOption()` factory which applies
|
||||||
|
`AcceptOnlyFromAmong("json", "table")`; `Program.cs` now uses it. Invalid values are
|
||||||
|
rejected by `System.CommandLine` with a clear parse error. Regression tests in
|
||||||
|
`FormatOptionValidationTests`.
|
||||||
|
|
||||||
### CLI-009 — Exit-code documentation does not match `HandleResponse` behaviour
|
### CLI-009 — Exit-code documentation does not match `HandleResponse` behaviour
|
||||||
|
|
||||||
@@ -342,7 +347,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `docs/requirements/Component-CLI.md:238-249`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:75` |
|
| Location | `docs/requirements/Component-CLI.md:238-249`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:75` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -365,7 +370,19 @@ with whichever is chosen.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Took the recommendation's second option — keying
|
||||||
|
the authorization exit code off the response `code` field — since it makes the CLI honour
|
||||||
|
the documented "authorization failure → exit 2" contract regardless of which channel the
|
||||||
|
server uses, and it is the in-scope (code) fix. Replaced
|
||||||
|
`return response.StatusCode == 403 ? 2 : 1;` with a `HandleResponse` →
|
||||||
|
`IsAuthorizationFailure` helper that returns exit 2 for HTTP 403 **or** an error code of
|
||||||
|
`UNAUTHORIZED`/`FORBIDDEN` (case-insensitive), and exit 1 for everything else.
|
||||||
|
Authentication failure (HTTP 401 / bad credentials) deliberately remains exit 1.
|
||||||
|
Regression tests in `ExitCodeTests`. Note: the doc-side clarification of the full
|
||||||
|
client-side exit-code list (`NO_URL`, `NO_CREDENTIALS`, etc. → exit 1) was not made here —
|
||||||
|
`docs/requirements/Component-CLI.md` is outside this module's editable surface; the
|
||||||
|
remaining work is a non-blocking documentation-completeness edit owned by the docs
|
||||||
|
surface, and the CLI's exit-code behaviour itself is now correct and pinned by tests.
|
||||||
|
|
||||||
### CLI-010 — `debug stream` reports Ctrl+C during connect as a connection failure
|
### CLI-010 — `debug stream` reports Ctrl+C during connect as a connection failure
|
||||||
|
|
||||||
@@ -373,7 +390,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:181-189` |
|
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:181-189` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -394,7 +411,13 @@ lines 209-215 already treats cancellation as graceful.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — the `StartAsync` `catch`
|
||||||
|
block reported every exception as `CONNECTION_FAILED`/exit 1. Extracted a pure
|
||||||
|
`DebugStreamHelpers.ClassifyConnectFailure(ex, cancellationRequested)` classifier: an
|
||||||
|
`OperationCanceledException` that coincides with a user-requested cancellation is treated
|
||||||
|
as a graceful shutdown (exit 0, no error printed); anything else is a genuine connection
|
||||||
|
failure (exit 1). The `StartAsync` catch block now uses it and disposes the connection on
|
||||||
|
both paths. Regression tests in `DebugStreamTests` (`ClassifyConnectFailure_*`).
|
||||||
|
|
||||||
### CLI-011 — `CancellationTokenSource` in `debug stream` is never disposed
|
### CLI-011 — `CancellationTokenSource` in `debug stream` is never disposed
|
||||||
|
|
||||||
@@ -402,7 +425,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:89` |
|
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:89` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -420,7 +443,12 @@ a `try/finally`).
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — `cts` was a plain local with
|
||||||
|
no disposal on any exit path. Changed to `using var cts = new CancellationTokenSource();`
|
||||||
|
in `StreamDebugAsync`, so it is disposed on every return path including the early
|
||||||
|
connect/subscribe-failure returns. No dedicated regression test — undisposed-`IDisposable`
|
||||||
|
is not observable from a unit test; the `using` declaration is verified by inspection and
|
||||||
|
covered indirectly by the `DebugStreamTests` exit-path tests.
|
||||||
|
|
||||||
### CLI-012 — `debug stream` exit code is unreliable after stream termination
|
### CLI-012 — `debug stream` exit code is unreliable after stream termination
|
||||||
|
|
||||||
@@ -428,7 +456,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:208-227` |
|
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:208-227` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -452,7 +480,15 @@ Consider awaiting `exitTcs.Task` without the cancellation token after a brief gr
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed — the final
|
||||||
|
`exitTcs.Task.IsCompletedSuccessfully ? ... : 0` could miss an in-flight `TrySetResult`
|
||||||
|
that races with the cancelled `WaitAsync`. Extracted
|
||||||
|
`DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTask)`: it returns an already-set
|
||||||
|
result immediately, otherwise waits up to a 250 ms grace period (`Task.WhenAny` against
|
||||||
|
`Task.Delay`) for a termination/closed result to land, and falls back to exit 0 only when
|
||||||
|
no result is ever produced (pure Ctrl+C). `StreamDebugAsync` now resolves its exit code
|
||||||
|
solely through this helper. Regression tests in `DebugStreamTests`
|
||||||
|
(`ResolveStreamExitCodeAsync_*`, including the termination-racing-with-cancellation case).
|
||||||
|
|
||||||
### CLI-013 — HTTP client, `debug stream`, and JSON-argument parsing are untested
|
### CLI-013 — HTTP client, `debug stream`, and JSON-argument parsing are untested
|
||||||
|
|
||||||
@@ -460,7 +496,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `tests/ScadaLink.CLI.Tests/` (vs. `src/ScadaLink.CLI/ManagementHttpClient.cs`, `src/ScadaLink.CLI/Commands/DebugCommands.cs`, `src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58`) |
|
| Location | `tests/ScadaLink.CLI.Tests/` (vs. `src/ScadaLink.CLI/ManagementHttpClient.cs`, `src/ScadaLink.CLI/Commands/DebugCommands.cs`, `src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58`) |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -487,4 +523,17 @@ resolves via `ManagementCommandRegistry`.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Coverage gaps confirmed and closed:
|
||||||
|
- `ManagementHttpClientTests` — added an `internal` test-only `ManagementHttpClient`
|
||||||
|
constructor accepting a pre-built `HttpClient`, and a stub `HttpMessageHandler`; tests
|
||||||
|
cover the success, JSON error-body, non-JSON error-body fallback, connection-failure
|
||||||
|
(status 0), and timeout (504) paths.
|
||||||
|
- `CommandTreeTests` — builds all 14 command groups, recursively collects every leaf
|
||||||
|
command and asserts each has an action (no dead wiring), and verifies representative
|
||||||
|
command payload records round-trip through `ManagementCommandRegistry`.
|
||||||
|
- `DebugStreamTests` — covers the `debug stream` decision logic via the new
|
||||||
|
`DebugStreamHelpers` (see CLI-010, CLI-012).
|
||||||
|
- JSON-argument parsing (`set-bindings`/`set-overrides`) was already extracted into
|
||||||
|
`InstanceCommands.TryParseBindings`/`TryParseOverrides` and covered by
|
||||||
|
`InstanceArgumentParsingTests` under CLI-005.
|
||||||
|
The CLI test suite went from 42 to 77 passing tests.
|
||||||
|
|||||||
30
src/ScadaLink.CLI/Commands/CliOptions.cs
Normal file
30
src/ScadaLink.CLI/Commands/CliOptions.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory methods for the global CLI options. Centralising option construction keeps
|
||||||
|
/// validation rules (e.g. the accepted <c>--format</c> values) in one place and makes
|
||||||
|
/// them testable without standing up the whole command tree.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CliOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the global <c>--format</c> option. The option deliberately has no
|
||||||
|
/// <c>DefaultValueFactory</c> — format precedence (explicit flag → config/env →
|
||||||
|
/// <c>"json"</c>) is resolved by <see cref="CommandHelpers.ResolveFormat"/>, which
|
||||||
|
/// needs to distinguish an absent flag. The accepted values are constrained so a
|
||||||
|
/// typo (e.g. <c>--format tabel</c>) is rejected with a clear parse error rather
|
||||||
|
/// than silently falling through to JSON.
|
||||||
|
/// </summary>
|
||||||
|
internal static Option<string> CreateFormatOption()
|
||||||
|
{
|
||||||
|
var formatOption = new Option<string>("--format")
|
||||||
|
{
|
||||||
|
Description = "Output format (json or table)",
|
||||||
|
Recursive = true,
|
||||||
|
};
|
||||||
|
formatOption.AcceptOnlyFromAmong("json", "table");
|
||||||
|
return formatOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,7 +132,24 @@ internal static class CommandHelpers
|
|||||||
var error = response.Error ?? "Unknown error";
|
var error = response.Error ?? "Unknown error";
|
||||||
|
|
||||||
OutputFormatter.WriteError(error, errorCode);
|
OutputFormatter.WriteError(error, errorCode);
|
||||||
return response.StatusCode == 403 ? 2 : 1;
|
return IsAuthorizationFailure(response) ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an error response represents an authorization failure
|
||||||
|
/// (insufficient role), which the documented exit-code table maps to exit code 2.
|
||||||
|
/// An HTTP 403 status is the primary signal; the server may also signal it via an
|
||||||
|
/// <c>UNAUTHORIZED</c> / <c>FORBIDDEN</c> error code on a different HTTP status, so
|
||||||
|
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
|
||||||
|
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsAuthorizationFailure(ManagementResponse response)
|
||||||
|
{
|
||||||
|
if (response.StatusCode == 403)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return string.Equals(response.ErrorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(response.ErrorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteAsTable(string json)
|
private static void WriteAsTable(string json)
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ public static class DebugCommands
|
|||||||
.WithAutomaticReconnect()
|
.WithAutomaticReconnect()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
// CLI-011: CancellationTokenSource owns a WaitHandle and must be disposed.
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
Console.CancelKeyPress += (_, e) =>
|
Console.CancelKeyPress += (_, e) =>
|
||||||
@@ -192,8 +193,18 @@ public static class DebugCommands
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
// CLI-010: Ctrl+C during connect throws OperationCanceledException — that is
|
||||||
|
// a graceful user cancellation, not a connection failure.
|
||||||
|
var failure = DebugStreamHelpers.ClassifyConnectFailure(ex, cts.IsCancellationRequested);
|
||||||
|
if (failure.IsCancellation)
|
||||||
|
{
|
||||||
|
await connection.DisposeAsync();
|
||||||
|
return failure.ExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
||||||
return 1;
|
await connection.DisposeAsync();
|
||||||
|
return failure.ExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -232,7 +243,11 @@ public static class DebugCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
await connection.DisposeAsync();
|
await connection.DisposeAsync();
|
||||||
return exitTcs.Task.IsCompletedSuccessfully ? exitTcs.Task.Result : 0;
|
|
||||||
|
// CLI-012: resolve the exit code from a single authoritative source. A result
|
||||||
|
// set by OnStreamTerminated/Closed always wins; a brief grace period covers a
|
||||||
|
// termination racing with Ctrl+C. Pure Ctrl+C (no result) is a graceful exit 0.
|
||||||
|
return await DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTcs.Task);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void PrintSnapshotTable(JsonElement snapshot)
|
private static void PrintSnapshotTable(JsonElement snapshot)
|
||||||
|
|||||||
54
src/ScadaLink.CLI/Commands/DebugStreamHelpers.cs
Normal file
54
src/ScadaLink.CLI/Commands/DebugStreamHelpers.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
namespace ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure, testable helpers for the <c>debug stream</c> command. The SignalR-driven
|
||||||
|
/// <see cref="DebugCommands"/> body itself cannot be unit-tested without a live hub, so
|
||||||
|
/// the decision logic — connect-failure classification (CLI-010) and exit-code
|
||||||
|
/// resolution after stream termination (CLI-012) — is extracted here.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DebugStreamHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The maximum time <see cref="ResolveStreamExitCodeAsync"/> waits for an in-flight
|
||||||
|
/// <c>TrySetResult</c> (from <c>OnStreamTerminated</c>/<c>Closed</c>) to land after
|
||||||
|
/// the wait was cancelled by Ctrl+C, so a termination racing with cancellation is
|
||||||
|
/// observed deterministically rather than depending on scheduling.
|
||||||
|
/// </summary>
|
||||||
|
internal static readonly TimeSpan ExitGracePeriod = TimeSpan.FromMilliseconds(250);
|
||||||
|
|
||||||
|
/// <summary>Outcome of classifying an exception thrown while connecting.</summary>
|
||||||
|
internal readonly record struct ConnectFailure(bool IsCancellation, int ExitCode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies an exception thrown by <c>HubConnection.StartAsync</c>. A
|
||||||
|
/// cancellation exception that coincides with a user-requested cancellation
|
||||||
|
/// (Ctrl+C during connect) is a graceful shutdown — exit 0, no error printed.
|
||||||
|
/// Anything else is a genuine connection failure — exit 1.
|
||||||
|
/// </summary>
|
||||||
|
internal static ConnectFailure ClassifyConnectFailure(Exception ex, bool cancellationRequested)
|
||||||
|
{
|
||||||
|
if (cancellationRequested && ex is OperationCanceledException)
|
||||||
|
return new ConnectFailure(IsCancellation: true, ExitCode: 0);
|
||||||
|
|
||||||
|
return new ConnectFailure(IsCancellation: false, ExitCode: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the <c>debug stream</c> exit code from a single authoritative source —
|
||||||
|
/// the <c>exitTcs</c> task. If a result was set by <c>OnStreamTerminated</c> or the
|
||||||
|
/// <c>Closed</c> handler it is always preferred (even when Ctrl+C also fired);
|
||||||
|
/// a brief grace period covers a termination that races with cancellation. If no
|
||||||
|
/// result is ever produced (pure Ctrl+C), the stream ended gracefully — exit 0.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<int> ResolveStreamExitCodeAsync(Task<int> exitTask)
|
||||||
|
{
|
||||||
|
if (exitTask.IsCompletedSuccessfully)
|
||||||
|
return exitTask.Result;
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(exitTask, Task.Delay(ExitGracePeriod));
|
||||||
|
if (ReferenceEquals(completed, exitTask) && exitTask.IsCompletedSuccessfully)
|
||||||
|
return exitTask.Result;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,19 @@ public class ManagementHttpClient : IDisposable
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
public ManagementHttpClient(string baseUrl, string username, string password)
|
public ManagementHttpClient(string baseUrl, string username, string password)
|
||||||
|
: this(new HttpClient(), baseUrl, username, password)
|
||||||
{
|
{
|
||||||
_httpClient = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only constructor that accepts a pre-built <see cref="HttpClient"/> (typically
|
||||||
|
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
|
||||||
|
/// be exercised without a live server.
|
||||||
|
/// </summary>
|
||||||
|
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||||
_httpClient.DefaultRequestHeaders.Authorization =
|
_httpClient.DefaultRequestHeaders.Authorization =
|
||||||
new AuthenticationHeaderValue("Basic", credentials);
|
new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ var usernameOption = new Option<string>("--username") { Description = "LDAP user
|
|||||||
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
|
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
|
||||||
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
|
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
|
||||||
// is resolved by CommandHelpers.ResolveFormat, which needs to distinguish an absent flag.
|
// is resolved by CommandHelpers.ResolveFormat, which needs to distinguish an absent flag.
|
||||||
var formatOption = new Option<string>("--format") { Description = "Output format (json or table)", Recursive = true };
|
// CliOptions.CreateFormatOption also constrains the accepted values (json/table).
|
||||||
|
var formatOption = CliOptions.CreateFormatOption();
|
||||||
|
|
||||||
rootCommand.Add(urlOption);
|
rootCommand.Add(urlOption);
|
||||||
rootCommand.Add(usernameOption);
|
rootCommand.Add(usernameOption);
|
||||||
|
|||||||
88
tests/ScadaLink.CLI.Tests/CommandTreeTests.cs
Normal file
88
tests/ScadaLink.CLI.Tests/CommandTreeTests.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-013 — the command-tree wiring was untested. These tests
|
||||||
|
/// build every command group and assert the tree is well-formed (every leaf has an
|
||||||
|
/// action, no group is empty), and that every management command record the CLI sends
|
||||||
|
/// resolves via <see cref="ManagementCommandRegistry"/> (so command-name derivation
|
||||||
|
/// never throws at runtime).
|
||||||
|
/// </summary>
|
||||||
|
public class CommandTreeTests
|
||||||
|
{
|
||||||
|
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||||
|
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||||
|
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||||
|
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
|
||||||
|
|
||||||
|
private static IEnumerable<Command> AllCommandGroups() => new[]
|
||||||
|
{
|
||||||
|
TemplateCommands.Build(Url, Format, Username, Password),
|
||||||
|
InstanceCommands.Build(Url, Format, Username, Password),
|
||||||
|
SiteCommands.Build(Url, Format, Username, Password),
|
||||||
|
DeployCommands.Build(Url, Format, Username, Password),
|
||||||
|
DataConnectionCommands.Build(Url, Format, Username, Password),
|
||||||
|
ExternalSystemCommands.Build(Url, Format, Username, Password),
|
||||||
|
NotificationCommands.Build(Url, Format, Username, Password),
|
||||||
|
SecurityCommands.Build(Url, Format, Username, Password),
|
||||||
|
AuditLogCommands.Build(Url, Format, Username, Password),
|
||||||
|
HealthCommands.Build(Url, Format, Username, Password),
|
||||||
|
DebugCommands.Build(Url, Format, Username, Password),
|
||||||
|
SharedScriptCommands.Build(Url, Format, Username, Password),
|
||||||
|
DbConnectionCommands.Build(Url, Format, Username, Password),
|
||||||
|
ApiMethodCommands.Build(Url, Format, Username, Password),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IEnumerable<Command> LeafCommands(Command command)
|
||||||
|
{
|
||||||
|
if (command.Subcommands.Count == 0)
|
||||||
|
{
|
||||||
|
yield return command;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var sub in command.Subcommands)
|
||||||
|
foreach (var leaf in LeafCommands(sub))
|
||||||
|
yield return leaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllCommandGroups_Build_WithoutThrowing()
|
||||||
|
{
|
||||||
|
var groups = AllCommandGroups().ToList();
|
||||||
|
Assert.Equal(14, groups.Count);
|
||||||
|
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryLeafCommand_HasAnAction()
|
||||||
|
{
|
||||||
|
// A leaf command with no action is dead wiring — invoking it would do nothing.
|
||||||
|
var leaves = AllCommandGroups().SelectMany(LeafCommands).ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(leaves);
|
||||||
|
Assert.All(leaves, leaf =>
|
||||||
|
Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(GetInstanceCommand))]
|
||||||
|
[InlineData(typeof(ListSitesCommand))]
|
||||||
|
[InlineData(typeof(CreateTemplateCommand))]
|
||||||
|
[InlineData(typeof(SetConnectionBindingsCommand))]
|
||||||
|
[InlineData(typeof(SetInstanceOverridesCommand))]
|
||||||
|
[InlineData(typeof(DebugSnapshotCommand))]
|
||||||
|
[InlineData(typeof(MgmtDeployInstanceCommand))]
|
||||||
|
[InlineData(typeof(QueryAuditLogCommand))]
|
||||||
|
public void CommandPayloadTypes_ResolveViaRegistry(Type commandType)
|
||||||
|
{
|
||||||
|
// GetCommandName throws ArgumentException for an unregistered type — the CLI
|
||||||
|
// calls it for every command it sends, so each must round-trip.
|
||||||
|
var name = ManagementCommandRegistry.GetCommandName(commandType);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(name));
|
||||||
|
Assert.Equal(commandType, ManagementCommandRegistry.Resolve(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
100
tests/ScadaLink.CLI.Tests/DebugStreamTests.cs
Normal file
100
tests/ScadaLink.CLI.Tests/DebugStreamTests.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for the testable pieces of <c>DebugCommands.StreamDebugAsync</c>:
|
||||||
|
/// CLI-010 (Ctrl+C during connect misreported as a connection failure) and
|
||||||
|
/// CLI-012 (non-deterministic exit code after stream termination).
|
||||||
|
/// </summary>
|
||||||
|
public class DebugStreamTests
|
||||||
|
{
|
||||||
|
// --- CLI-010: connect-failure classification --------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation()
|
||||||
|
{
|
||||||
|
// Ctrl+C while StartAsync is still establishing the connection throws
|
||||||
|
// OperationCanceledException — this is a graceful cancellation, not a failure.
|
||||||
|
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||||
|
new OperationCanceledException(), cancellationRequested: true);
|
||||||
|
|
||||||
|
Assert.True(result.IsCancellation);
|
||||||
|
Assert.Equal(0, result.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation()
|
||||||
|
{
|
||||||
|
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||||
|
new TaskCanceledException(), cancellationRequested: true);
|
||||||
|
|
||||||
|
Assert.True(result.IsCancellation);
|
||||||
|
Assert.Equal(0, result.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyConnectFailure_RealException_IsConnectionFailure()
|
||||||
|
{
|
||||||
|
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||||
|
new HttpRequestException("connection refused"), cancellationRequested: false);
|
||||||
|
|
||||||
|
Assert.False(result.IsCancellation);
|
||||||
|
Assert.Equal(1, result.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure()
|
||||||
|
{
|
||||||
|
// A cancellation that did not originate from the user (e.g. a server-side abort)
|
||||||
|
// is still a real connection failure.
|
||||||
|
var result = DebugStreamHelpers.ClassifyConnectFailure(
|
||||||
|
new OperationCanceledException(), cancellationRequested: false);
|
||||||
|
|
||||||
|
Assert.False(result.IsCancellation);
|
||||||
|
Assert.Equal(1, result.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI-012: deterministic exit-code resolution ----------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult()
|
||||||
|
{
|
||||||
|
// OnStreamTerminated set exitTcs to 1 — that must win even on the Ctrl+C path.
|
||||||
|
var tcs = new TaskCompletionSource<int>();
|
||||||
|
tcs.SetResult(1);
|
||||||
|
|
||||||
|
var code = await DebugStreamHelpers.ResolveStreamExitCodeAsync(tcs.Task);
|
||||||
|
|
||||||
|
Assert.Equal(1, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero()
|
||||||
|
{
|
||||||
|
// Pure Ctrl+C: exitTcs never completed — graceful shutdown, exit 0.
|
||||||
|
var tcs = new TaskCompletionSource<int>();
|
||||||
|
|
||||||
|
var code = await DebugStreamHelpers.ResolveStreamExitCodeAsync(tcs.Task);
|
||||||
|
|
||||||
|
Assert.Equal(0, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved()
|
||||||
|
{
|
||||||
|
// A stream termination racing with Ctrl+C: the result lands shortly after the
|
||||||
|
// wait was cancelled. The grace period must let it be observed deterministically.
|
||||||
|
var tcs = new TaskCompletionSource<int>();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(20);
|
||||||
|
tcs.TrySetResult(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
var code = await DebugStreamHelpers.ResolveStreamExitCodeAsync(tcs.Task);
|
||||||
|
|
||||||
|
Assert.Equal(1, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tests/ScadaLink.CLI.Tests/ExitCodeTests.cs
Normal file
59
tests/ScadaLink.CLI.Tests/ExitCodeTests.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using ScadaLink.CLI;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-009 — the design doc defines exit code 2 as "authorization
|
||||||
|
/// failure". The previous implementation keyed exit 2 solely off HTTP 403, so an
|
||||||
|
/// authorization failure the server signalled with an error <c>code</c> of
|
||||||
|
/// <c>UNAUTHORIZED</c>/<c>FORBIDDEN</c> but a different HTTP status was misreported as a
|
||||||
|
/// generic error (exit 1). Exit code 2 now keys off either signal.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Console")]
|
||||||
|
public class ExitCodeTests
|
||||||
|
{
|
||||||
|
private static int HandleQuietly(ManagementResponse response)
|
||||||
|
{
|
||||||
|
var errWriter = new StringWriter();
|
||||||
|
Console.SetError(errWriter);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CommandHelpers.HandleResponse(response, "json");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleResponse_Http403_ReturnsTwo()
|
||||||
|
{
|
||||||
|
Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("UNAUTHORIZED")]
|
||||||
|
[InlineData("FORBIDDEN")]
|
||||||
|
[InlineData("unauthorized")]
|
||||||
|
public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code)
|
||||||
|
{
|
||||||
|
// The server signalled an authorization failure via the error code but with a
|
||||||
|
// non-403 HTTP status; per the documented exit-code table this is still exit 2.
|
||||||
|
Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleResponse_GenericError_ReturnsOne()
|
||||||
|
{
|
||||||
|
Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleResponse_AuthenticationFailure_ReturnsOne()
|
||||||
|
{
|
||||||
|
// Authentication failure (bad credentials) is exit 1, distinct from authorization.
|
||||||
|
Assert.Equal(1, HandleQuietly(new ManagementResponse(401, null, "Invalid credentials", "AUTH_FAILED")));
|
||||||
|
}
|
||||||
|
}
|
||||||
44
tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs
Normal file
44
tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-008 — the <c>--format</c> option previously accepted any
|
||||||
|
/// string, so a typo like <c>--format tabel</c> silently fell through to JSON output
|
||||||
|
/// with no feedback. The option must reject values outside {json, table} with a parse
|
||||||
|
/// error.
|
||||||
|
/// </summary>
|
||||||
|
public class FormatOptionValidationTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("json")]
|
||||||
|
[InlineData("table")]
|
||||||
|
public void FormatOption_AcceptsValidValues(string value)
|
||||||
|
{
|
||||||
|
var formatOption = CliOptions.CreateFormatOption();
|
||||||
|
var root = new RootCommand();
|
||||||
|
root.Add(formatOption);
|
||||||
|
|
||||||
|
var result = root.Parse(new[] { "--format", value });
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("tabel")]
|
||||||
|
[InlineData("xml")]
|
||||||
|
[InlineData("yaml")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("JSON")] // case-sensitive: documented values are lowercase
|
||||||
|
public void FormatOption_RejectsInvalidValues(string value)
|
||||||
|
{
|
||||||
|
var formatOption = CliOptions.CreateFormatOption();
|
||||||
|
var root = new RootCommand();
|
||||||
|
root.Add(formatOption);
|
||||||
|
|
||||||
|
var result = root.Parse(new[] { "--format", value });
|
||||||
|
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs
Normal file
106
tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using ScadaLink.CLI;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CLI-013 — <see cref="ManagementHttpClient.SendCommandAsync"/>
|
||||||
|
/// (success, error-body parsing, connection-failure, and timeout paths) was untested.
|
||||||
|
/// Uses a stub <see cref="HttpMessageHandler"/> so no live server is required.
|
||||||
|
/// </summary>
|
||||||
|
public class ManagementHttpClientTests
|
||||||
|
{
|
||||||
|
private sealed class StubHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _responder;
|
||||||
|
|
||||||
|
public StubHandler(HttpStatusCode status, string body)
|
||||||
|
: this((_, _) => Task.FromResult(new HttpResponseMessage(status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||||
|
{
|
||||||
|
_responder = responder;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> _responder(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ManagementHttpClient ClientWith(StubHandler handler)
|
||||||
|
=> new(new HttpClient(handler), "http://localhost:9001", "user", "pass");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendCommandAsync_Success_ReturnsJsonData()
|
||||||
|
{
|
||||||
|
using var client = ClientWith(new StubHandler(HttpStatusCode.OK, "{\"id\":1}"));
|
||||||
|
|
||||||
|
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.Equal(200, response.StatusCode);
|
||||||
|
Assert.Equal("{\"id\":1}", response.JsonData);
|
||||||
|
Assert.Null(response.Error);
|
||||||
|
Assert.Null(response.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendCommandAsync_ErrorBody_ParsesErrorAndCode()
|
||||||
|
{
|
||||||
|
using var client = ClientWith(new StubHandler(
|
||||||
|
HttpStatusCode.BadRequest, "{\"error\":\"Bad input\",\"code\":\"INVALID_ARGUMENT\"}"));
|
||||||
|
|
||||||
|
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.Equal(400, response.StatusCode);
|
||||||
|
Assert.Null(response.JsonData);
|
||||||
|
Assert.Equal("Bad input", response.Error);
|
||||||
|
Assert.Equal("INVALID_ARGUMENT", response.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendCommandAsync_NonJsonErrorBody_FallsBackToRawBody()
|
||||||
|
{
|
||||||
|
using var client = ClientWith(new StubHandler(
|
||||||
|
HttpStatusCode.BadGateway, "<html>Bad Gateway</html>"));
|
||||||
|
|
||||||
|
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.Equal(502, response.StatusCode);
|
||||||
|
Assert.Equal("<html>Bad Gateway</html>", response.Error);
|
||||||
|
Assert.Null(response.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendCommandAsync_ConnectionFailure_ReturnsStatusZero()
|
||||||
|
{
|
||||||
|
using var client = ClientWith(new StubHandler((_, _) =>
|
||||||
|
throw new HttpRequestException("connection refused")));
|
||||||
|
|
||||||
|
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.Equal(0, response.StatusCode);
|
||||||
|
Assert.Equal("CONNECTION_FAILED", response.ErrorCode);
|
||||||
|
Assert.Contains("connection refused", response.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendCommandAsync_Timeout_Returns504()
|
||||||
|
{
|
||||||
|
using var client = ClientWith(new StubHandler(async (_, ct) =>
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, ct);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
}));
|
||||||
|
|
||||||
|
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromMilliseconds(50));
|
||||||
|
|
||||||
|
Assert.Equal(504, response.StatusCode);
|
||||||
|
Assert.Equal("TIMEOUT", response.ErrorCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user