From 404216b4eee23f89647d1fa29fd71bf743a9ac42 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 22:04:21 -0400 Subject: [PATCH] =?UTF-8?q?fix(cli):=20resolve=20CLI-008..013=20=E2=80=94?= =?UTF-8?q?=20format=20validation,=20exit-code=20semantics,=20debug-stream?= =?UTF-8?q?=20cancellation/disposal,=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- code-reviews/CLI/findings.md | 75 ++++++++++--- src/ScadaLink.CLI/Commands/CliOptions.cs | 30 +++++ src/ScadaLink.CLI/Commands/CommandHelpers.cs | 19 +++- src/ScadaLink.CLI/Commands/DebugCommands.cs | 21 +++- .../Commands/DebugStreamHelpers.cs | 54 +++++++++ src/ScadaLink.CLI/ManagementHttpClient.cs | 13 ++- src/ScadaLink.CLI/Program.cs | 3 +- tests/ScadaLink.CLI.Tests/CommandTreeTests.cs | 88 +++++++++++++++ tests/ScadaLink.CLI.Tests/DebugStreamTests.cs | 100 +++++++++++++++++ tests/ScadaLink.CLI.Tests/ExitCodeTests.cs | 59 ++++++++++ .../FormatOptionValidationTests.cs | 44 ++++++++ .../ManagementHttpClientTests.cs | 106 ++++++++++++++++++ 12 files changed, 593 insertions(+), 19 deletions(-) create mode 100644 src/ScadaLink.CLI/Commands/CliOptions.cs create mode 100644 src/ScadaLink.CLI/Commands/DebugStreamHelpers.cs create mode 100644 tests/ScadaLink.CLI.Tests/CommandTreeTests.cs create mode 100644 tests/ScadaLink.CLI.Tests/DebugStreamTests.cs create mode 100644 tests/ScadaLink.CLI.Tests/ExitCodeTests.cs create mode 100644 tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs create mode 100644 tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs diff --git a/code-reviews/CLI/findings.md b/code-reviews/CLI/findings.md index c0570e8..f11efce 100644 --- a/code-reviews/CLI/findings.md +++ b/code-reviews/CLI/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 6 | +| Open findings | 0 | ## Summary @@ -318,7 +318,7 @@ env vars (see CLI-006). |--|--| | Severity | Low | | Category | Code organization & conventions | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.CLI/Program.cs:10-11`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:60` | **Description** @@ -334,7 +334,12 @@ Restrict the option to the accepted values, e.g. `formatOption.AcceptOnlyFromAmo **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 @@ -342,7 +347,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Documentation & comments | -| Status | Open | +| Status | Resolved | | Location | `docs/requirements/Component-CLI.md:238-249`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:75` | **Description** @@ -365,7 +370,19 @@ with whichever is chosen. **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 @@ -373,7 +390,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Error handling & resilience | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:181-189` | **Description** @@ -394,7 +411,13 @@ lines 209-215 already treats cancellation as graceful. **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 @@ -402,7 +425,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Performance & resource management | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:89` | **Description** @@ -420,7 +443,12 @@ a `try/finally`). **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 @@ -428,7 +456,7 @@ _Unresolved._ |--|--| | Severity | Low | | Category | Concurrency & thread safety | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:208-227` | **Description** @@ -452,7 +480,15 @@ Consider awaiting `exitTcs.Task` without the cancellation token after a brief gr **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 @@ -460,7 +496,7 @@ _Unresolved._ |--|--| | Severity | Low | | 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`) | **Description** @@ -487,4 +523,17 @@ resolves via `ManagementCommandRegistry`. **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. diff --git a/src/ScadaLink.CLI/Commands/CliOptions.cs b/src/ScadaLink.CLI/Commands/CliOptions.cs new file mode 100644 index 0000000..dfc60b5 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/CliOptions.cs @@ -0,0 +1,30 @@ +using System.CommandLine; + +namespace ScadaLink.CLI.Commands; + +/// +/// Factory methods for the global CLI options. Centralising option construction keeps +/// validation rules (e.g. the accepted --format values) in one place and makes +/// them testable without standing up the whole command tree. +/// +internal static class CliOptions +{ + /// + /// Creates the global --format option. The option deliberately has no + /// DefaultValueFactory — format precedence (explicit flag → config/env → + /// "json") is resolved by , which + /// needs to distinguish an absent flag. The accepted values are constrained so a + /// typo (e.g. --format tabel) is rejected with a clear parse error rather + /// than silently falling through to JSON. + /// + internal static Option CreateFormatOption() + { + var formatOption = new Option("--format") + { + Description = "Output format (json or table)", + Recursive = true, + }; + formatOption.AcceptOnlyFromAmong("json", "table"); + return formatOption; + } +} diff --git a/src/ScadaLink.CLI/Commands/CommandHelpers.cs b/src/ScadaLink.CLI/Commands/CommandHelpers.cs index eb393c7..6d6dc9b 100644 --- a/src/ScadaLink.CLI/Commands/CommandHelpers.cs +++ b/src/ScadaLink.CLI/Commands/CommandHelpers.cs @@ -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; + } + + /// + /// 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 + /// UNAUTHORIZED / FORBIDDEN error code on a different HTTP status, so + /// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials + /// — is deliberately not treated as authorization failure; it is exit 1.) + /// + 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) diff --git a/src/ScadaLink.CLI/Commands/DebugCommands.cs b/src/ScadaLink.CLI/Commands/DebugCommands.cs index b6bcc70..e080df7 100644 --- a/src/ScadaLink.CLI/Commands/DebugCommands.cs +++ b/src/ScadaLink.CLI/Commands/DebugCommands.cs @@ -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(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) diff --git a/src/ScadaLink.CLI/Commands/DebugStreamHelpers.cs b/src/ScadaLink.CLI/Commands/DebugStreamHelpers.cs new file mode 100644 index 0000000..845e428 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/DebugStreamHelpers.cs @@ -0,0 +1,54 @@ +namespace ScadaLink.CLI.Commands; + +/// +/// Pure, testable helpers for the debug stream command. The SignalR-driven +/// 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. +/// +internal static class DebugStreamHelpers +{ + /// + /// The maximum time waits for an in-flight + /// TrySetResult (from OnStreamTerminated/Closed) to land after + /// the wait was cancelled by Ctrl+C, so a termination racing with cancellation is + /// observed deterministically rather than depending on scheduling. + /// + internal static readonly TimeSpan ExitGracePeriod = TimeSpan.FromMilliseconds(250); + + /// Outcome of classifying an exception thrown while connecting. + internal readonly record struct ConnectFailure(bool IsCancellation, int ExitCode); + + /// + /// Classifies an exception thrown by HubConnection.StartAsync. 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. + /// + 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); + } + + /// + /// Resolves the debug stream exit code from a single authoritative source — + /// the exitTcs task. If a result was set by OnStreamTerminated or the + /// Closed 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. + /// + internal static async Task ResolveStreamExitCodeAsync(Task 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; + } +} diff --git a/src/ScadaLink.CLI/ManagementHttpClient.cs b/src/ScadaLink.CLI/ManagementHttpClient.cs index 3026024..515bf42 100644 --- a/src/ScadaLink.CLI/ManagementHttpClient.cs +++ b/src/ScadaLink.CLI/ManagementHttpClient.cs @@ -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('/') + "/") }; + } + + /// + /// Test-only constructor that accepts a pre-built (typically + /// over a stub ) so the request/response handling can + /// be exercised without a live server. + /// + 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); diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs index 3cb5e2a..08da7d7 100644 --- a/src/ScadaLink.CLI/Program.cs +++ b/src/ScadaLink.CLI/Program.cs @@ -9,7 +9,8 @@ var usernameOption = new Option("--username") { Description = "LDAP user var passwordOption = new Option("--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("--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); diff --git a/tests/ScadaLink.CLI.Tests/CommandTreeTests.cs b/tests/ScadaLink.CLI.Tests/CommandTreeTests.cs new file mode 100644 index 0000000..387ddc9 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/CommandTreeTests.cs @@ -0,0 +1,88 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; +using ScadaLink.Commons.Messages.Management; + +namespace ScadaLink.CLI.Tests; + +/// +/// 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 (so command-name derivation +/// never throws at runtime). +/// +public class CommandTreeTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option Format = CliOptions.CreateFormatOption(); + + private static IEnumerable 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 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)); + } +} diff --git a/tests/ScadaLink.CLI.Tests/DebugStreamTests.cs b/tests/ScadaLink.CLI.Tests/DebugStreamTests.cs new file mode 100644 index 0000000..ebd36ee --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/DebugStreamTests.cs @@ -0,0 +1,100 @@ +using System.Threading.Tasks; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests; + +/// +/// Regression tests for the testable pieces of DebugCommands.StreamDebugAsync: +/// CLI-010 (Ctrl+C during connect misreported as a connection failure) and +/// CLI-012 (non-deterministic exit code after stream termination). +/// +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(); + 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(); + + 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(); + _ = Task.Run(async () => + { + await Task.Delay(20); + tcs.TrySetResult(1); + }); + + var code = await DebugStreamHelpers.ResolveStreamExitCodeAsync(tcs.Task); + + Assert.Equal(1, code); + } +} diff --git a/tests/ScadaLink.CLI.Tests/ExitCodeTests.cs b/tests/ScadaLink.CLI.Tests/ExitCodeTests.cs new file mode 100644 index 0000000..b3aadef --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/ExitCodeTests.cs @@ -0,0 +1,59 @@ +using ScadaLink.CLI; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests; + +/// +/// 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 code of +/// UNAUTHORIZED/FORBIDDEN but a different HTTP status was misreported as a +/// generic error (exit 1). Exit code 2 now keys off either signal. +/// +[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"))); + } +} diff --git a/tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs b/tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs new file mode 100644 index 0000000..4330101 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/FormatOptionValidationTests.cs @@ -0,0 +1,44 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests; + +/// +/// Regression tests for CLI-008 — the --format option previously accepted any +/// string, so a typo like --format tabel silently fell through to JSON output +/// with no feedback. The option must reject values outside {json, table} with a parse +/// error. +/// +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); + } +} diff --git a/tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs b/tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs new file mode 100644 index 0000000..a03ea01 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/ManagementHttpClientTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Text; +using ScadaLink.CLI; + +namespace ScadaLink.CLI.Tests; + +/// +/// Regression tests for CLI-013 — +/// (success, error-body parsing, connection-failure, and timeout paths) was untested. +/// Uses a stub so no live server is required. +/// +public class ManagementHttpClientTests +{ + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func> _responder; + + public StubHandler(HttpStatusCode status, string body) + : this((_, _) => Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + })) + { + } + + public StubHandler(Func> responder) + { + _responder = responder; + } + + protected override Task 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, "Bad Gateway")); + + var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + + Assert.Equal(502, response.StatusCode); + Assert.Equal("Bad Gateway", 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); + } +}