fix(cli): resolve CLI-008..013 — format validation, exit-code semantics, debug-stream cancellation/disposal, test coverage

This commit is contained in:
Joseph Doherty
2026-05-16 22:04:21 -04:00
parent bc88a36435
commit 404216b4ee
12 changed files with 593 additions and 19 deletions

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

View File

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

View File

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

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

View File

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

View File

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