fix(cli): resolve CLI-008..013 — format validation, exit-code semantics, debug-stream cancellation/disposal, test coverage
This commit is contained in:
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";
|
||||
|
||||
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)
|
||||
|
||||
@@ -94,7 +94,8 @@ public static class DebugCommands
|
||||
.WithAutomaticReconnect()
|
||||
.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);
|
||||
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
@@ -192,8 +193,18 @@ public static class DebugCommands
|
||||
}
|
||||
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");
|
||||
return 1;
|
||||
await connection.DisposeAsync();
|
||||
return failure.ExitCode;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -232,7 +243,11 @@ public static class DebugCommands
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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;
|
||||
|
||||
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}"));
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
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 };
|
||||
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
|
||||
// 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(usernameOption);
|
||||
|
||||
Reference in New Issue
Block a user