fix(cli): resolve CLI-002..007 — robust response rendering, URL/JSON arg validation, credential env-vars, doc refresh

This commit is contained in:
Joseph Doherty
2026-05-16 20:58:03 -04:00
parent 658b659c0c
commit 738e67acc5
15 changed files with 685 additions and 150 deletions

View File

@@ -2,6 +2,7 @@ using ScadaLink.CLI;
namespace ScadaLink.CLI.Tests;
[Collection("Environment")]
public class CliConfigTests
{
[Fact]

View File

@@ -3,6 +3,7 @@ using ScadaLink.CLI.Commands;
namespace ScadaLink.CLI.Tests;
[Collection("Console")]
public class CommandHelpersTests
{
[Fact]

View File

@@ -0,0 +1,71 @@
using ScadaLink.CLI;
namespace ScadaLink.CLI.Tests;
/// <summary>
/// Regression tests for CLI-006 — credentials could only be supplied via the
/// <c>--password</c> command-line option, which leaks into process listings and
/// shell history. A <c>SCADALINK_PASSWORD</c> / <c>SCADALINK_USERNAME</c> environment
/// fallback gives CI/CD a safer alternative.
/// </summary>
[Collection("Environment")]
public class CredentialResolutionTests
{
[Fact]
public void Load_Password_FromEnvironment()
{
var orig = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
try
{
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", "s3cret");
var config = CliConfig.Load();
Assert.Equal("s3cret", config.Password);
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", orig);
}
}
[Fact]
public void Load_Username_FromEnvironment()
{
var orig = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
try
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", "ci-user");
var config = CliConfig.Load();
Assert.Equal("ci-user", config.Username);
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", orig);
}
}
[Fact]
public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
{
var origUser = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
var origPass = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
try
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", null);
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", null);
var config = CliConfig.Load();
Assert.Null(config.Username);
Assert.Null(config.Password);
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", origUser);
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", origPass);
}
}
}

View File

@@ -0,0 +1,98 @@
using ScadaLink.CLI.Commands;
namespace ScadaLink.CLI.Tests;
/// <summary>
/// Regression tests for CLI-005 — malformed <c>--bindings</c> / <c>--overrides</c> JSON
/// previously threw unhandled exceptions instead of producing a clean validation error.
/// </summary>
public class InstanceArgumentParsingTests
{
[Fact]
public void ParseBindings_ValidJson_ReturnsPairs()
{
var ok = InstanceCommands.TryParseBindings(
"[[\"Speed\", 5], [\"Mode\", 7]]", out var bindings, out var error);
Assert.True(ok);
Assert.Null(error);
Assert.Equal(2, bindings!.Count);
Assert.Equal(("Speed", 5), bindings[0]);
Assert.Equal(("Mode", 7), bindings[1]);
}
[Fact]
public void ParseBindings_MalformedJson_ReturnsErrorNotException()
{
var ok = InstanceCommands.TryParseBindings("not json", out var bindings, out var error);
Assert.False(ok);
Assert.Null(bindings);
Assert.NotNull(error);
}
[Fact]
public void ParseBindings_ShortPair_ReturnsErrorNotException()
{
// A pair with fewer than two elements previously threw ArgumentOutOfRangeException.
var ok = InstanceCommands.TryParseBindings("[[\"Speed\"]]", out var bindings, out var error);
Assert.False(ok);
Assert.Null(bindings);
Assert.NotNull(error);
}
[Fact]
public void ParseBindings_WrongElementTypes_ReturnsErrorNotException()
{
// A non-string name / non-int id previously threw InvalidOperationException.
var ok = InstanceCommands.TryParseBindings("[[5, \"Speed\"]]", out var bindings, out var error);
Assert.False(ok);
Assert.Null(bindings);
Assert.NotNull(error);
}
[Fact]
public void ParseBindings_JsonNull_ReturnsErrorNotException()
{
var ok = InstanceCommands.TryParseBindings("null", out var bindings, out var error);
Assert.False(ok);
Assert.Null(bindings);
Assert.NotNull(error);
}
[Fact]
public void ParseOverrides_ValidJson_ReturnsDictionary()
{
var ok = InstanceCommands.TryParseOverrides(
"{\"Speed\": \"100\", \"Mode\": null}", out var overrides, out var error);
Assert.True(ok);
Assert.Null(error);
Assert.Equal(2, overrides!.Count);
Assert.Equal("100", overrides["Speed"]);
Assert.Null(overrides["Mode"]);
}
[Fact]
public void ParseOverrides_MalformedJson_ReturnsErrorNotException()
{
var ok = InstanceCommands.TryParseOverrides("{bad json", out var overrides, out var error);
Assert.False(ok);
Assert.Null(overrides);
Assert.NotNull(error);
}
[Fact]
public void ParseOverrides_JsonNull_ReturnsErrorNotException()
{
var ok = InstanceCommands.TryParseOverrides("null", out var overrides, out var error);
Assert.False(ok);
Assert.Null(overrides);
Assert.NotNull(error);
}
}

View File

@@ -2,6 +2,7 @@ using ScadaLink.CLI;
namespace ScadaLink.CLI.Tests;
[Collection("Console")]
public class OutputFormatterTests
{
[Fact]

View File

@@ -0,0 +1,73 @@
using ScadaLink.CLI;
using ScadaLink.CLI.Commands;
namespace ScadaLink.CLI.Tests;
/// <summary>
/// Regression tests for CLI-002 (empty success body) and CLI-003 (non-JSON success
/// body) — both previously crashed table rendering with an unhandled exception.
/// </summary>
[Collection("Console")]
public class ResponseRenderingTests
{
[Fact]
public void HandleResponse_EmptyBody_TableFormat_DoesNotThrow_ReturnsZero()
{
// CLI-002: a 200/204 with an empty body must be treated as "succeeded, no output".
var writer = new StringWriter();
Console.SetOut(writer);
try
{
var response = new ManagementResponse(204, "", null, null);
var exitCode = CommandHelpers.HandleResponse(response, "table");
Assert.Equal(0, exitCode);
}
finally
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
}
[Fact]
public void HandleResponse_EmptyBody_JsonFormat_DoesNotThrow_ReturnsZero()
{
var writer = new StringWriter();
Console.SetOut(writer);
try
{
var response = new ManagementResponse(200, " ", null, null);
var exitCode = CommandHelpers.HandleResponse(response, "json");
Assert.Equal(0, exitCode);
}
finally
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
}
[Fact]
public void HandleResponse_NonJsonBody_TableFormat_FallsBackToRaw_ReturnsZero()
{
// CLI-003: a success status with a non-JSON body (e.g. proxy HTML error page)
// must not crash; it should print the raw body verbatim.
var writer = new StringWriter();
Console.SetOut(writer);
try
{
var response = new ManagementResponse(200, "<html>Service Unavailable</html>", null, null);
var exitCode = CommandHelpers.HandleResponse(response, "table");
Assert.Equal(0, exitCode);
Assert.Contains("<html>Service Unavailable</html>", writer.ToString());
}
finally
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
}
}

View File

@@ -0,0 +1,16 @@
namespace ScadaLink.CLI.Tests;
/// <summary>
/// xUnit runs test classes in parallel by default. Several CLI test classes redirect
/// the process-global <see cref="System.Console.Out"/> / <see cref="System.Console.Error"/>,
/// which races when they run concurrently. Tests in this collection run serially.
/// </summary>
[CollectionDefinition("Console")]
public sealed class ConsoleCollection { }
/// <summary>
/// Test classes that mutate process-global environment variables run serially so they
/// do not observe each other's changes.
/// </summary>
[CollectionDefinition("Environment")]
public sealed class EnvironmentCollection { }

View File

@@ -0,0 +1,31 @@
using ScadaLink.CLI.Commands;
namespace ScadaLink.CLI.Tests;
/// <summary>
/// Regression tests for CLI-004 — a malformed <c>--url</c> previously reached
/// <c>new Uri(...)</c> in the <see cref="ScadaLink.CLI.ManagementHttpClient"/> constructor
/// and threw an unhandled <see cref="UriFormatException"/>.
/// </summary>
public class UrlValidationTests
{
[Theory]
[InlineData("http://localhost:9001")]
[InlineData("https://central-host:5000/")]
[InlineData("http://central")]
public void IsValidManagementUrl_AcceptsAbsoluteHttpUrls(string url)
{
Assert.True(CommandHelpers.IsValidManagementUrl(url));
}
[Theory]
[InlineData("localhost:9001")] // no scheme
[InlineData("")] // empty
[InlineData(" ")] // whitespace
[InlineData("not a url")]
[InlineData("ftp://central:21")] // non-http scheme
public void IsValidManagementUrl_RejectsMalformedOrNonHttpUrls(string url)
{
Assert.False(CommandHelpers.IsValidManagementUrl(url));
}
}