refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
[Collection("Environment")]
|
||||
public class CliConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_DefaultFormat_IsJson()
|
||||
{
|
||||
var origUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
|
||||
var origFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", null);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", null);
|
||||
|
||||
var config = CliConfig.Load();
|
||||
|
||||
// DefaultFormat is always "json" unless overridden by config file or env var
|
||||
Assert.Equal("json", config.DefaultFormat);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", origUrl);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", origFormat);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ManagementUrl_FromEnvironment()
|
||||
{
|
||||
var orig = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", "http://central:5000");
|
||||
|
||||
var config = CliConfig.Load();
|
||||
|
||||
Assert.Equal("http://central:5000", config.ManagementUrl);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", orig);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Format_FromEnvironment()
|
||||
{
|
||||
var orig = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", "table");
|
||||
|
||||
var config = CliConfig.Load();
|
||||
|
||||
Assert.Equal("table", config.DefaultFormat);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", orig);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-021 regression: a malformed ~/.scadabridge/config.json must NOT abort the
|
||||
/// CLI before any command runs — Load() must warn (to stderr) and return a
|
||||
/// usable default config so command-line overrides (--url, --username, etc.)
|
||||
/// and env vars can still take effect.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Load_MalformedConfigFile_DoesNotThrow_WarnsAndReturnsDefault()
|
||||
{
|
||||
var tempHome = Path.Combine(Path.GetTempPath(), "scadabridge-cli-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path.Combine(tempHome, ".scadabridge"));
|
||||
File.WriteAllText(
|
||||
Path.Combine(tempHome, ".scadabridge", "config.json"),
|
||||
"{ this is not valid json :: ");
|
||||
|
||||
var origHome = Environment.GetEnvironmentVariable("HOME");
|
||||
var origUserProfile = Environment.GetEnvironmentVariable("USERPROFILE");
|
||||
var origUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
|
||||
var origFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
|
||||
var origUser = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
|
||||
var origPass = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
|
||||
var origStderr = Console.Error;
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("HOME", tempHome);
|
||||
Environment.SetEnvironmentVariable("USERPROFILE", tempHome);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", null);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", null);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", null);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", null);
|
||||
|
||||
var stderrCapture = new StringWriter();
|
||||
Console.SetError(stderrCapture);
|
||||
|
||||
// Must not throw.
|
||||
var config = CliConfig.Load();
|
||||
|
||||
Assert.Equal("json", config.DefaultFormat);
|
||||
Assert.Null(config.ManagementUrl);
|
||||
var stderrText = stderrCapture.ToString();
|
||||
Assert.Contains("warning", stderrText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("config.json", stderrText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetError(origStderr);
|
||||
Environment.SetEnvironmentVariable("HOME", origHome);
|
||||
Environment.SetEnvironmentVariable("USERPROFILE", origUserProfile);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", origUrl);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", origFormat);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", origUser);
|
||||
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", origPass);
|
||||
try { Directory.Delete(tempHome, recursive: true); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
[Collection("Console")]
|
||||
public class CommandHelpersTests
|
||||
{
|
||||
[Fact]
|
||||
public void HandleResponse_Success_JsonFormat_ReturnsZero()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
var response = new ManagementResponse(200, "{\"id\":1,\"name\":\"test\"}", null, null);
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "json");
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("{\"id\":1,\"name\":\"test\"}", writer.ToString());
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
var json = "[{\"Id\":1,\"Name\":\"Alpha\"},{\"Id\":2,\"Name\":\"Beta\"}]";
|
||||
var response = new ManagementResponse(200, json, null, null);
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("Id", output);
|
||||
Assert.Contains("Name", output);
|
||||
Assert.Contains("Alpha", output);
|
||||
Assert.Contains("Beta", output);
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
var json = "{\"Id\":1,\"Name\":\"Alpha\",\"Status\":\"Active\"}";
|
||||
var response = new ManagementResponse(200, json, null, null);
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("Property", output);
|
||||
Assert.Contains("Value", output);
|
||||
Assert.Contains("Id", output);
|
||||
Assert.Contains("Alpha", output);
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
var response = new ManagementResponse(200, "[]", null, null);
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("(no results)", writer.ToString());
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_Error_ReturnsOne()
|
||||
{
|
||||
var errWriter = new StringWriter();
|
||||
Console.SetError(errWriter);
|
||||
|
||||
var response = new ManagementResponse(400, null, "Something failed", "FAIL_CODE");
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "json");
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Contains("Something failed", errWriter.ToString());
|
||||
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_Unauthorized_ReturnsTwo()
|
||||
{
|
||||
var errWriter = new StringWriter();
|
||||
Console.SetError(errWriter);
|
||||
|
||||
var response = new ManagementResponse(403, null, "Access denied", "UNAUTHORIZED");
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "json");
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.Contains("Access denied", errWriter.ToString());
|
||||
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_AuthFailure_ReturnsOne()
|
||||
{
|
||||
var errWriter = new StringWriter();
|
||||
Console.SetError(errWriter);
|
||||
|
||||
var response = new ManagementResponse(401, null, "Invalid credentials", "AUTH_FAILED");
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "json");
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Contains("Invalid credentials", errWriter.ToString());
|
||||
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_ConnectionFailure_ReturnsOne()
|
||||
{
|
||||
var errWriter = new StringWriter();
|
||||
Console.SetError(errWriter);
|
||||
|
||||
var response = new ManagementResponse(0, null, "Connection failed: No such host", "CONNECTION_FAILED");
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "json");
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Contains("Connection failed", errWriter.ToString());
|
||||
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_Timeout_ReturnsOne()
|
||||
{
|
||||
var errWriter = new StringWriter();
|
||||
Console.SetError(errWriter);
|
||||
|
||||
var response = new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "json");
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Contains("timed out", errWriter.ToString());
|
||||
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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();
|
||||
|
||||
// NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in
|
||||
// src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs. When a new command group is added (or one is
|
||||
// removed/renamed), update this array and bump the count assertion in
|
||||
// AllCommandGroups_Build_WithoutThrowing accordingly.
|
||||
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),
|
||||
AuditCommands.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),
|
||||
BundleCommands.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();
|
||||
// CLI-022: bump this count whenever a new top-level command group is
|
||||
// registered in Program.cs. Current registered groups (16):
|
||||
// template, instance, site, deploy, data-connection, external-system,
|
||||
// notification, security, audit-config, audit, health, debug,
|
||||
// shared-script, db-connection, api-method, bundle.
|
||||
Assert.Equal(16, groups.Count);
|
||||
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllCommandGroups_Contains_AuditAndBundle()
|
||||
{
|
||||
// CLI-022: explicit group-presence assertion so the harness does not
|
||||
// silently drift back to excluding new groups. Use names because that
|
||||
// is what users actually type at the prompt.
|
||||
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
|
||||
Assert.Contains("audit", groupNames);
|
||||
Assert.Contains("bundle", groupNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
|
||||
{
|
||||
// CLI-022: pin the audit sub-command surface so a rename / accidental
|
||||
// removal of one of these is caught.
|
||||
var audit = AuditCommands.Build(Url, Format, Username, Password);
|
||||
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
|
||||
Assert.Contains("query", subNames);
|
||||
Assert.Contains("export", subNames);
|
||||
Assert.Contains("verify-chain", subNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleCommandGroup_HasExportPreviewAndImport()
|
||||
{
|
||||
// CLI-022: pin the bundle sub-command surface.
|
||||
var bundle = BundleCommands.Build(Url, Format, Username, Password);
|
||||
var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
|
||||
Assert.Contains("export", subNames);
|
||||
Assert.Contains("preview", subNames);
|
||||
Assert.Contains("import", subNames);
|
||||
}
|
||||
|
||||
[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."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateCompositionDelete_IsKeyedByIdOnly()
|
||||
{
|
||||
// CLI-015: the in-repo README documented `template composition delete` with
|
||||
// --template-id / --instance-name, but the implementation keys deletion by the
|
||||
// composition's own integer ID via a single --id option. Pin the real surface.
|
||||
var template = TemplateCommands.Build(Url, Format, Username, Password);
|
||||
var composition = template.Subcommands.Single(c => c.Name == "composition");
|
||||
var delete = composition.Subcommands.Single(c => c.Name == "delete");
|
||||
|
||||
var optionNames = delete.Options.Select(o => o.Name).ToList();
|
||||
Assert.Contains("--id", optionNames);
|
||||
Assert.DoesNotContain("--template-id", optionNames);
|
||||
Assert.DoesNotContain("--instance-name", optionNames);
|
||||
}
|
||||
|
||||
[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))]
|
||||
[InlineData(typeof(ExportBundleCommand))]
|
||||
[InlineData(typeof(PreviewBundleCommand))]
|
||||
[InlineData(typeof(ImportBundleCommand))]
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for invoking the <c>audit</c> command tree in tests and capturing
|
||||
/// stdout/stderr/exit code.
|
||||
/// </summary>
|
||||
internal static class AuditCommandTestHarness
|
||||
{
|
||||
public static RootCommand BuildRoot()
|
||||
{
|
||||
var url = new Option<string>("--url") { Recursive = true };
|
||||
var username = new Option<string>("--username") { Recursive = true };
|
||||
var password = new Option<string>("--password") { Recursive = true };
|
||||
var format = CliOptions.CreateFormatOption();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(url);
|
||||
root.Add(username);
|
||||
root.Add(password);
|
||||
root.Add(format);
|
||||
root.Add(AuditCommands.Build(url, format, username, password));
|
||||
return root;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses and invokes the command tree, capturing output from both channels the CLI
|
||||
/// uses: System.CommandLine's parser diagnostics flow through the
|
||||
/// <see cref="InvocationConfiguration"/> writers, while command actions write through
|
||||
/// <see cref="Console"/> (consistent with the rest of the CLI). Both are merged into
|
||||
/// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c>
|
||||
/// xUnit collection so the global <see cref="Console"/> redirect is not racy.
|
||||
/// </summary>
|
||||
public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args)
|
||||
{
|
||||
var output = new StringWriter();
|
||||
var error = new StringWriter();
|
||||
|
||||
var originalOut = Console.Out;
|
||||
var originalErr = Console.Error;
|
||||
Console.SetOut(output);
|
||||
Console.SetError(error);
|
||||
int exit;
|
||||
try
|
||||
{
|
||||
exit = root.Parse(args).Invoke(new InvocationConfiguration
|
||||
{
|
||||
Output = output,
|
||||
Error = error,
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalErr);
|
||||
}
|
||||
|
||||
return (exit, output.ToString(), error.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log #23 M8-T1).
|
||||
/// Verifies the parent command exists with its three subcommands and that every leaf
|
||||
/// has an action wired.
|
||||
/// </summary>
|
||||
public class AuditCommandsScaffoldTests
|
||||
{
|
||||
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 Command BuildAudit()
|
||||
=> AuditCommands.Build(Url, Format, Username, Password);
|
||||
|
||||
[Fact]
|
||||
public void Audit_Command_IsNamedAudit()
|
||||
{
|
||||
var audit = BuildAudit();
|
||||
Assert.Equal("audit", audit.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(audit.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_HasThreeSubcommands_QueryExportVerifyChain()
|
||||
{
|
||||
var audit = BuildAudit();
|
||||
var names = audit.Subcommands.Select(c => c.Name).OrderBy(n => n).ToArray();
|
||||
Assert.Equal(new[] { "export", "query", "verify-chain" }, names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_HelpText_ListsAllSubcommands()
|
||||
{
|
||||
var root = new RootCommand();
|
||||
root.Add(BuildAudit());
|
||||
|
||||
var output = new StringWriter();
|
||||
var exit = root.Parse(new[] { "audit", "--help" })
|
||||
.Invoke(new InvocationConfiguration { Output = output });
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
var text = output.ToString();
|
||||
Assert.Contains("query", text);
|
||||
Assert.Contains("export", text);
|
||||
Assert.Contains("verify-chain", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_EveryLeafCommand_HasAnAction()
|
||||
{
|
||||
var audit = BuildAudit();
|
||||
Assert.All(audit.Subcommands, sub =>
|
||||
Assert.True(sub.Action != null, $"Leaf command '{sub.Name}' has no action."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename (Audit Log #23 M8-T7):
|
||||
/// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full
|
||||
/// subcommand tree and emits a stderr deprecation warning, and the renamed group does
|
||||
/// not collide with the distinct <c>audit</c> group from Bundle A.
|
||||
/// </summary>
|
||||
public class AuditConfigDeprecationTests
|
||||
{
|
||||
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 RootCommand BuildRoot()
|
||||
{
|
||||
var root = new RootCommand();
|
||||
root.Add(Url);
|
||||
root.Add(Username);
|
||||
root.Add(Password);
|
||||
root.Add(Format);
|
||||
root.Add(AuditCommands.Build(Url, Format, Username, Password));
|
||||
root.Add(AuditLogCommands.Build(Url, Format, Username, Password));
|
||||
return root;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditConfig_Query_Works()
|
||||
{
|
||||
// The new `audit-config query` name parses cleanly with no errors.
|
||||
var root = BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit-config", "query", "--user", "alice" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
|
||||
{
|
||||
// The deprecated `audit-log` alias still resolves the full subcommand tree...
|
||||
var root = BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit-log", "query", "--user", "alice" });
|
||||
Assert.Empty(parse.Errors);
|
||||
|
||||
// ...and invoking via the old name emits the deprecation warning to stderr.
|
||||
var stderr = new StringWriter();
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(
|
||||
new[] { "audit-log", "query" }, stderr);
|
||||
var warning = stderr.ToString();
|
||||
Assert.Contains("deprecated", warning);
|
||||
Assert.Contains("audit-config", warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForNewName()
|
||||
{
|
||||
// The new `audit-config` name must not trigger the deprecation warning.
|
||||
var stderr = new StringWriter();
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(
|
||||
new[] { "audit-config", "query" }, stderr);
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
|
||||
{
|
||||
var stderr = new StringWriter();
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(
|
||||
new[] { "audit", "query" }, stderr);
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict()
|
||||
{
|
||||
var root = BuildRoot();
|
||||
|
||||
var auditNames = new[] { "audit", "audit-config" };
|
||||
foreach (var name in auditNames)
|
||||
{
|
||||
var match = root.Subcommands.SingleOrDefault(c => c.Name == name);
|
||||
Assert.NotNull(match);
|
||||
}
|
||||
|
||||
// The two groups are distinct objects with distinct subcommand sets:
|
||||
// `audit` has query/export/verify-chain; `audit-config` has only query.
|
||||
var audit = root.Subcommands.Single(c => c.Name == "audit");
|
||||
var auditConfig = root.Subcommands.Single(c => c.Name == "audit-config");
|
||||
Assert.NotSame(audit, auditConfig);
|
||||
Assert.Contains(audit.Subcommands, c => c.Name == "verify-chain");
|
||||
Assert.DoesNotContain(auditConfig.Subcommands, c => c.Name == "verify-chain");
|
||||
|
||||
// `audit-config` carries the deprecated `audit-log` alias; `audit` does not.
|
||||
Assert.Contains(AuditLogCommands.DeprecatedAlias, auditConfig.Aliases);
|
||||
Assert.DoesNotContain(AuditLogCommands.DeprecatedAlias, audit.Aliases);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log #23 M8-T3):
|
||||
/// required-flag enforcement, query-string construction, streaming the response body
|
||||
/// to the output file, and the parquet-not-implemented (501) path.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditExportCommandTests
|
||||
{
|
||||
// ---- CLI parsing: required flags --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
// --output is omitted.
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.Contains("--output", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "csv", "--output", "/tmp/out.csv",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_InvalidFormat_Rejected()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "xml", "--output", "/tmp/out.xml",
|
||||
});
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
// ---- Query string -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_IncludesWindowFormatAndFilters()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var args = new AuditExportArgs
|
||||
{
|
||||
Since = "1h",
|
||||
Until = "2026-05-20T12:00:00Z",
|
||||
Format = "jsonl",
|
||||
Output = "/tmp/x",
|
||||
Channel = new[] { "Notification" },
|
||||
Site = new[] { "site-9" },
|
||||
};
|
||||
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
|
||||
Assert.Equal("jsonl", parsed["format"]);
|
||||
Assert.Equal("Notification", parsed["channel"]);
|
||||
Assert.Equal("site-9", parsed["sourceSiteId"]);
|
||||
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
|
||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var args = new AuditExportArgs
|
||||
{
|
||||
Since = "1h",
|
||||
Until = "2026-05-20T12:00:00Z",
|
||||
Format = "csv",
|
||||
Output = "/tmp/x",
|
||||
Channel = new[] { "ApiOutbound", "DbOutbound" },
|
||||
Kind = new[] { "ApiCall", "DbWrite" },
|
||||
Status = new[] { "Failed", "Parked" },
|
||||
Site = new[] { "site-1", "site-2" },
|
||||
};
|
||||
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
|
||||
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
|
||||
Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind"));
|
||||
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
|
||||
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_OmitsUnsetMultiValueFilters()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var args = new AuditExportArgs
|
||||
{
|
||||
Since = "1h",
|
||||
Until = "0h",
|
||||
Format = "csv",
|
||||
Output = "/tmp/x",
|
||||
};
|
||||
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
|
||||
Assert.Null(parsed["channel"]);
|
||||
Assert.Null(parsed["kind"]);
|
||||
Assert.Null(parsed["status"]);
|
||||
Assert.Null(parsed["sourceSiteId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
|
||||
{
|
||||
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "csv", "--output", "/tmp/out.csv",
|
||||
"--channel", "ApiOutbound", "DbOutbound",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "csv", "--output", "/tmp/out.csv",
|
||||
"--channel", "ApiOutbound", "--channel", "Notification",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_MultiValueChannel_WithOneInvalidName_FailsFast()
|
||||
{
|
||||
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "csv", "--output", "/tmp/out.csv",
|
||||
"--channel", "ApiOutbound", "OutboundApi");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
// ---- Streaming export to file -----------------------------------------
|
||||
|
||||
private sealed class BodyHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly Func<HttpContent> _content;
|
||||
public string? RequestPathAndQuery { get; private set; }
|
||||
|
||||
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
|
||||
{
|
||||
_status = status;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestPathAndQuery = request.RequestUri!.PathAndQuery;
|
||||
return Task.FromResult(new HttpResponseMessage(_status) { Content = _content() });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_Success_StreamsResponseToOutputFile()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.OK,
|
||||
() => new StringContent("{\"eventId\":\"e1\"}\n{\"eventId\":\"e2\"}\n",
|
||||
Encoding.UTF8, "application/x-ndjson"));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "1h", Until = "0h", Format = "jsonl", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.True(File.Exists(path));
|
||||
var content = await File.ReadAllTextAsync(path);
|
||||
Assert.Contains("e1", content);
|
||||
Assert.Contains("e2", content);
|
||||
Assert.Contains("api/audit/export", handler.RequestPathAndQuery);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_Http403_ReturnsExitCode2()
|
||||
{
|
||||
// CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the
|
||||
// documented CLI contract — the legacy bare-1 return masked auth failures
|
||||
// as generic command failures.
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.Forbidden,
|
||||
() => new StringContent("{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}", Encoding.UTF8, "application/json"));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(2, exit);
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-401-{Guid.NewGuid():N}.csv");
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.BadRequest,
|
||||
() => new StringContent("{\"error\":\"nope\",\"code\":\"FORBIDDEN\"}", Encoding.UTF8, "application/json"));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.parquet");
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.NotImplemented,
|
||||
() => new StringContent("Parquet export is not yet supported.", Encoding.UTF8, "text/plain"));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "1h", Until = "0h", Format = "parquet", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.NotEqual(0, exit);
|
||||
// No file should be written on the 501 path.
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
|
||||
{
|
||||
// A ~8 MB body delivered via a streaming HttpContent. The export must copy it to
|
||||
// disk via Stream.CopyToAsync (chunked) — assert the file is written in full and
|
||||
// matches, which proves the streaming copy path works for multi-MB payloads.
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-big-{Guid.NewGuid():N}.csv");
|
||||
const int totalBytes = 8 * 1024 * 1024;
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.OK,
|
||||
() => new StreamContent(new RepeatingStream((byte)'a', totalBytes)));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "7d", Until = "0h", Format = "csv", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Equal(totalBytes, new FileInfo(path).Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A read-only stream that yields <paramref name="length"/> copies of a single byte
|
||||
/// without ever materialising the whole payload — used to simulate a large export
|
||||
/// body so the streaming copy can be exercised without an 8 MB literal.
|
||||
/// </summary>
|
||||
private sealed class RepeatingStream : Stream
|
||||
{
|
||||
private readonly byte _value;
|
||||
private long _remaining;
|
||||
|
||||
public RepeatingStream(byte value, long length)
|
||||
{
|
||||
_value = value;
|
||||
_remaining = length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_remaining <= 0) return 0;
|
||||
var n = (int)Math.Min(count, _remaining);
|
||||
for (var i = 0; i < n; i++) buffer[offset + i] = _value;
|
||||
_remaining -= n;
|
||||
return n;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit query</c> subcommand (Audit Log #23 M8-T2):
|
||||
/// time-spec resolution, query-string construction, formatter selection, error
|
||||
/// handling, and keyset-cursor paging via <c>--all</c>.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditQueryCommandTests
|
||||
{
|
||||
// ---- Time-spec parsing -------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var resolved = AuditQueryHelpers.ResolveTimeSpec("1h", now);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var resolved = AuditQueryHelpers.ResolveTimeSpec("7d", now);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var resolved = AuditQueryHelpers.ResolveTimeSpec("2026-01-02T03:04:05Z", now);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_Garbage_Throws()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
Assert.Throws<FormatException>(() => AuditQueryHelpers.ResolveTimeSpec("not-a-time", now));
|
||||
}
|
||||
|
||||
// ---- Query string construction ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var args = new AuditQueryArgs
|
||||
{
|
||||
Since = "1h",
|
||||
Until = "2026-05-20T12:00:00Z",
|
||||
Channel = new[] { "ApiOutbound" },
|
||||
Kind = new[] { "ApiCallCached" },
|
||||
Status = new[] { "Delivered" },
|
||||
Site = new[] { "site-1" },
|
||||
Target = "weather-api",
|
||||
Actor = "multi-role",
|
||||
CorrelationId = "abc-123",
|
||||
ExecutionId = "def-456",
|
||||
ParentExecutionId = "ghi-789",
|
||||
ErrorsOnly = false,
|
||||
PageSize = 250,
|
||||
};
|
||||
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, afterOccurredAtUtc: null, afterEventId: null);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
|
||||
Assert.Equal("ApiOutbound", parsed["channel"]);
|
||||
Assert.Equal("ApiCallCached", parsed["kind"]);
|
||||
Assert.Equal("Delivered", parsed["status"]);
|
||||
Assert.Equal("site-1", parsed["sourceSiteId"]);
|
||||
// The CLI audit query has no --instance flag, so no instance param is emitted.
|
||||
Assert.Null(parsed["instance"]);
|
||||
Assert.Equal("weather-api", parsed["target"]);
|
||||
Assert.Equal("multi-role", parsed["actor"]);
|
||||
Assert.Equal("abc-123", parsed["correlationId"]);
|
||||
Assert.Equal("def-456", parsed["executionId"]);
|
||||
Assert.Equal("ghi-789", parsed["parentExecutionId"]);
|
||||
Assert.Equal("250", parsed["pageSize"]);
|
||||
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
|
||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var args = new AuditQueryArgs { ErrorsOnly = true };
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
Assert.Equal("Failed", parsed["status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var args = new AuditQueryArgs
|
||||
{
|
||||
Channel = new[] { "ApiOutbound", "DbOutbound" },
|
||||
Status = new[] { "Failed", "Parked" },
|
||||
Site = new[] { "site-1", "site-2" },
|
||||
};
|
||||
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
|
||||
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
|
||||
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
|
||||
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
|
||||
{
|
||||
// --errors-only stays a single-status override: it pins status=Failed and
|
||||
// supersedes any explicit (multi-value) --status selection.
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var args = new AuditQueryArgs
|
||||
{
|
||||
ErrorsOnly = true,
|
||||
Status = new[] { "Delivered", "Parked" },
|
||||
};
|
||||
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
|
||||
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_Cursor_AppendsAfterParameters()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var args = new AuditQueryArgs();
|
||||
var after = DateTimeOffset.Parse("2026-05-20T10:00:00Z");
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, after, "evt-99");
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
Assert.Equal("evt-99", parsed["afterEventId"]);
|
||||
Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_OmitsUnsetFilters()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var args = new AuditQueryArgs { PageSize = 100 };
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
Assert.Null(parsed["channel"]);
|
||||
Assert.Null(parsed["status"]);
|
||||
Assert.Null(parsed["fromUtc"]);
|
||||
Assert.Null(parsed["correlationId"]);
|
||||
Assert.Null(parsed["executionId"]);
|
||||
Assert.Null(parsed["parentExecutionId"]);
|
||||
Assert.Equal("100", parsed["pageSize"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
|
||||
{
|
||||
// --execution-id is a single-value Guid filter — mirrors --correlation-id.
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" };
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
|
||||
{
|
||||
// --parent-execution-id is a single-value Guid filter — mirrors --execution-id.
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var args = new AuditQueryArgs { ParentExecutionId = "22222222-2222-2222-2222-222222222222" };
|
||||
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
Assert.Equal("22222222-2222-2222-2222-222222222222", parsed["parentExecutionId"]);
|
||||
}
|
||||
|
||||
// ---- HTTP execution / paging ------------------------------------------
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<string> _bodies;
|
||||
public List<string> RequestUris { get; } = new();
|
||||
|
||||
public RecordingHandler(params string[] bodies)
|
||||
{
|
||||
_bodies = new Queue<string>(bodies);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestUris.Add(request.RequestUri!.PathAndQuery);
|
||||
var body = _bodies.Count > 0 ? _bodies.Dequeue() : "{\"events\":[],\"nextCursor\":null}";
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
"{\"events\":[{\"eventId\":\"e1\"},{\"eventId\":\"e2\"}],\"nextCursor\":null}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditQueryHelpers.RunQueryAsync(
|
||||
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false,
|
||||
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.Contains("e1", lines[0]);
|
||||
Assert.Contains("e2", lines[1]);
|
||||
Assert.Single(handler.RequestUris);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
"{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}",
|
||||
"{\"events\":[{\"eventId\":\"e2\"}],\"nextCursor\":null}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditQueryHelpers.RunQueryAsync(
|
||||
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: true,
|
||||
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Equal(2, handler.RequestUris.Count);
|
||||
Assert.Contains("afterEventId=e1", handler.RequestUris[1]);
|
||||
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, lines.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
|
||||
{
|
||||
var handler = new RecordingHandler(
|
||||
"{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditQueryHelpers.RunQueryAsync(
|
||||
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false,
|
||||
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Single(handler.RequestUris);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunQuery_ServerError_ReturnsNonZeroExit()
|
||||
{
|
||||
var handler = new ErrorHandler();
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditQueryHelpers.RunQueryAsync(
|
||||
client, new AuditQueryArgs(), fetchAll: false,
|
||||
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunQuery_Http403_ReturnsExitCode2()
|
||||
{
|
||||
// CLI-018: an authorization failure on /api/audit/query (HTTP 403) must
|
||||
// produce exit code 2 per the documented CLI exit-code contract — the
|
||||
// legacy bare-1 return masked auth failures as generic command failures.
|
||||
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditQueryHelpers.RunQueryAsync(
|
||||
client, new AuditQueryArgs(), fetchAll: false,
|
||||
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
||||
{
|
||||
// The server may signal authorization failure via the error code on a
|
||||
// non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
|
||||
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditQueryHelpers.RunQueryAsync(
|
||||
client, new AuditQueryArgs(), fetchAll: false,
|
||||
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
|
||||
{
|
||||
// Authentication / internal errors (non-403, no auth code) must remain
|
||||
// exit code 1 — exit 2 is reserved for authorization failures.
|
||||
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditQueryHelpers.RunQueryAsync(
|
||||
client, new AuditQueryArgs(), fetchAll: false,
|
||||
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
private sealed class ErrorHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"boom\",\"code\":\"INTERNAL\"}"),
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class StatusHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
}
|
||||
|
||||
// ---- CLI parsing -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--bogus", "x");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_FormatTable_IsAccepted()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit", "query", "--format", "table" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ExecutionIdOption_IsAccepted()
|
||||
{
|
||||
// --execution-id is a single-value option — mirrors --correlation-id.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ParentExecutionIdOption_IsAccepted()
|
||||
{
|
||||
// --parent-execution-id is a single-value option — mirrors --execution-id.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "query", "--parent-execution-id", "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
// ---- Enum-name validation (fast-fail) ----------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Query_ChannelWithRealEnumName_IsAccepted()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
|
||||
{
|
||||
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "DbOutbound" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||
{
|
||||
// --channel A --channel B parses as two values.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "query", "--channel", "ApiOutbound", "--channel", "Notification",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_MultiValueChannel_WithOneInvalidName_FailsFast()
|
||||
{
|
||||
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "query", "--channel", "ApiOutbound", "OutboundApi");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
|
||||
{
|
||||
// "OutboundApi" is the old (non-existent) name; the real enum is "ApiOutbound".
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--channel", "OutboundApi");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
Assert.Contains("OutboundApi", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_KindWithInvalidName_FailsFast_NonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--kind", "CachedCall");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_StatusWithInvalidName_FailsFast_NonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--status", "Bogus");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>table</c> output formatter of the <c>scadabridge audit query</c>
|
||||
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
|
||||
/// empty-result-set case, and null-actor handling.
|
||||
/// </summary>
|
||||
public class AuditTableFormatterTests
|
||||
{
|
||||
private static IReadOnlyList<JsonElement> Events(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(e => e.Clone())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_RendersHeaderRow_WithExpectedColumns()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(Events("[]"), output);
|
||||
|
||||
var firstLine = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0];
|
||||
foreach (var col in new[]
|
||||
{
|
||||
"OccurredAtUtc", "Channel", "Kind", "Status",
|
||||
"Target", "Actor", "DurationMs", "HttpStatus",
|
||||
})
|
||||
{
|
||||
Assert.Contains(col, firstLine);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_TruncatesLongTarget_WithEllipsis()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
var longTarget = new string('x', 200);
|
||||
formatter.WritePage(
|
||||
Events($"[{{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
|
||||
$"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"{longTarget}\"," +
|
||||
$"\"actor\":\"multi-role\"}}]"),
|
||||
output);
|
||||
|
||||
var text = output.ToString();
|
||||
Assert.Contains("…", text);
|
||||
// The full untruncated target must not appear verbatim.
|
||||
Assert.DoesNotContain(longTarget, text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(Events("[]"), output);
|
||||
|
||||
var lines = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
// Header only — no data rows. (A header line is always emitted so the
|
||||
// column shape is visible even with zero results.)
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("OccurredAtUtc", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_NullActor_RendersBlank()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(
|
||||
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"InboundApi\"," +
|
||||
"\"kind\":\"ApiCall\",\"status\":\"Delivered\",\"target\":\"key-1\"," +
|
||||
"\"actor\":null}]"),
|
||||
output);
|
||||
|
||||
var lines = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, lines.Length);
|
||||
// The data row must not contain the literal "null" for the actor column.
|
||||
Assert.DoesNotContain("null", lines[1]);
|
||||
Assert.Contains("InboundApi", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(
|
||||
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
|
||||
"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"weather-api\"," +
|
||||
"\"actor\":\"multi-role\",\"durationMs\":42,\"httpStatus\":200}," +
|
||||
"{\"occurredAtUtc\":\"2026-05-20T12:01:00Z\",\"channel\":\"Notification\"," +
|
||||
"\"kind\":\"Send\",\"status\":\"Failed\",\"target\":\"ops-list\"," +
|
||||
"\"actor\":\"scheduler\",\"durationMs\":7}]"),
|
||||
output);
|
||||
|
||||
var lines = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(3, lines.Length);
|
||||
Assert.Contains("weather-api", lines[1]);
|
||||
Assert.Contains("ops-list", lines[2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand (Audit Log #23 M8-T4).
|
||||
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
|
||||
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditVerifyChainCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, output, _) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "verify-chain", "--month", "2026-05");
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("Hash-chain tamper-evidence is not enabled", output);
|
||||
Assert.Contains("Component-AuditLog.md", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyChain_MalformedMonth_ExitsNonZero()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, _) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "verify-chain", "--month", "2026-13");
|
||||
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "verify-chain");
|
||||
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.Contains("--month", err);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-05", true)]
|
||||
[InlineData("2026-01", true)]
|
||||
[InlineData("2026-12", true)]
|
||||
[InlineData("2026-13", false)]
|
||||
[InlineData("2026-00", false)]
|
||||
[InlineData("2026-5", false)]
|
||||
[InlineData("not-a-month", false)]
|
||||
[InlineData("", false)]
|
||||
public void IsValidMonth_ValidatesYyyyMm(string month, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AuditVerifyChainHelpers.IsValidMonth(month));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
|
||||
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
|
||||
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
|
||||
/// streaming helper decodes the base64 string in fixed-size chunks straight into
|
||||
/// a <see cref="FileStream"/>, so peak working set is bounded by the chunk size
|
||||
/// regardless of how large the bundle is.
|
||||
/// </summary>
|
||||
public class BundleCommandsStreamingTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
|
||||
public BundleCommandsStreamingTests()
|
||||
{
|
||||
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_tempPath))
|
||||
{
|
||||
File.Delete(_tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_SmallPayload_RoundTrips()
|
||||
{
|
||||
var bytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
|
||||
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
|
||||
|
||||
Assert.Equal(bytes.Length, written);
|
||||
var roundTripped = File.ReadAllBytes(_tempPath);
|
||||
Assert.Equal(bytes, roundTripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
|
||||
{
|
||||
// Build a payload several chunks wide so the slicing loop runs more than
|
||||
// once, with enough trailing bytes that the final slice is short and
|
||||
// exercises the padding/short-final-chunk path.
|
||||
var size = (BundleCommands.Base64StreamChunkChars / 4 * 3) * 3 + 17;
|
||||
var bytes = new byte[size];
|
||||
for (var i = 0; i < size; i++) bytes[i] = (byte)(i & 0xFF);
|
||||
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
|
||||
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
|
||||
|
||||
Assert.Equal(size, written);
|
||||
var roundTripped = File.ReadAllBytes(_tempPath);
|
||||
Assert.Equal(bytes, roundTripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
|
||||
{
|
||||
var written = BundleCommands.StreamBase64ToFile(string.Empty, _tempPath);
|
||||
|
||||
Assert.Equal(0, written);
|
||||
Assert.True(File.Exists(_tempPath));
|
||||
Assert.Empty(File.ReadAllBytes(_tempPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
|
||||
{
|
||||
// '*' is not a valid base64 character, so TryFromBase64Chars returns
|
||||
// false and the helper throws — the pre-fix code threw FormatException
|
||||
// from Convert.FromBase64String, so the contract is preserved.
|
||||
var invalid = "this is not valid base64 !!!*";
|
||||
|
||||
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_NullBase64_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_EmptyOutputPath_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge notification smtp update</c> subcommand. The command
|
||||
/// gained two optional flags — <c>--tls-mode</c> and <c>--credentials</c> — that plumb
|
||||
/// through to <see cref="UpdateSmtpConfigCommand"/>. These tests pin that the flags
|
||||
/// parse, are genuinely optional (non-breaking), and that <c>--tls-mode</c> rejects
|
||||
/// values outside the canonical {None, StartTLS, SSL} set.
|
||||
/// </summary>
|
||||
public class SmtpUpdateCommandTests
|
||||
{
|
||||
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 Command SmtpUpdateCommand()
|
||||
{
|
||||
var notification = NotificationCommands.Build(Url, Format, Username, Password);
|
||||
var smtp = notification.Subcommands.Single(c => c.Name == "smtp");
|
||||
return smtp.Subcommands.Single(c => c.Name == "update");
|
||||
}
|
||||
|
||||
private static ParseResult ParseUpdate(params string[] args)
|
||||
=> SmtpUpdateCommand().Parse(args);
|
||||
|
||||
[Fact]
|
||||
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
|
||||
{
|
||||
var parse = ParseUpdate(
|
||||
"--id", "1", "--server", "smtp.example.com", "--port", "587",
|
||||
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||
"--tls-mode", "None", "--credentials", "user:pass");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
|
||||
|
||||
Assert.Equal(1, cmd.SmtpConfigId);
|
||||
Assert.Equal("smtp.example.com", cmd.Server);
|
||||
Assert.Equal(587, cmd.Port);
|
||||
Assert.Equal("Basic", cmd.AuthMode);
|
||||
Assert.Equal("noreply@example.com", cmd.FromAddress);
|
||||
Assert.Equal("None", cmd.TlsMode);
|
||||
Assert.Equal("user:pass", cmd.Credentials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
|
||||
{
|
||||
var parse = ParseUpdate(
|
||||
"--id", "2", "--server", "smtp.example.com", "--port", "25",
|
||||
"--auth-mode", "OAuth2", "--from-address", "noreply@example.com");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
|
||||
|
||||
Assert.Equal(2, cmd.SmtpConfigId);
|
||||
Assert.Null(cmd.TlsMode);
|
||||
Assert.Null(cmd.Credentials);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("None")]
|
||||
[InlineData("StartTLS")]
|
||||
[InlineData("SSL")]
|
||||
public void Update_TlsModeOption_AcceptsCanonicalValues(string value)
|
||||
{
|
||||
var parse = ParseUpdate(
|
||||
"--id", "1", "--server", "smtp.example.com", "--port", "587",
|
||||
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||
"--tls-mode", value);
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Bogus")]
|
||||
[InlineData("tls")]
|
||||
[InlineData("none")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
|
||||
public void Update_TlsModeOption_RejectsValuesOutsideCanonicalSet(string value)
|
||||
{
|
||||
var parse = ParseUpdate(
|
||||
"--id", "1", "--server", "smtp.example.com", "--port", "587",
|
||||
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||
"--tls-mode", value);
|
||||
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TlsModeAndCredentials_AreNotRequired()
|
||||
{
|
||||
var update = SmtpUpdateCommand();
|
||||
var tls = update.Options.Single(o => o.Name == "--tls-mode");
|
||||
var creds = update.Options.Single(o => o.Name == "--credentials");
|
||||
|
||||
Assert.False(tls.Required, "--tls-mode must be optional (preserve-if-omitted).");
|
||||
Assert.False(creds.Required, "--credentials must be optional (preserve-if-omitted).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
public class FormatResolutionTests
|
||||
{
|
||||
private static (Option<string> formatOption, RootCommand root) BuildHarness()
|
||||
{
|
||||
var formatOption = new Option<string>("--format") { Recursive = true };
|
||||
var root = new RootCommand();
|
||||
root.Add(formatOption);
|
||||
return (formatOption, root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFormat_ExplicitFlag_OverridesConfig()
|
||||
{
|
||||
var (formatOption, root) = BuildHarness();
|
||||
var result = root.Parse(new[] { "--format", "table" });
|
||||
var config = new CliConfig { DefaultFormat = "json" };
|
||||
|
||||
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||
|
||||
Assert.Equal("table", format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFormat_FlagAbsent_UsesConfigDefaultFormat()
|
||||
{
|
||||
// Regression for CLI-001: when --format is not supplied, the config-file /
|
||||
// env-var DefaultFormat must be honoured instead of always falling back to "json".
|
||||
var (formatOption, root) = BuildHarness();
|
||||
var result = root.Parse(Array.Empty<string>());
|
||||
var config = new CliConfig { DefaultFormat = "table" };
|
||||
|
||||
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||
|
||||
Assert.Equal("table", format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFormat_FlagAbsent_AndNoConfig_DefaultsToJson()
|
||||
{
|
||||
var (formatOption, root) = BuildHarness();
|
||||
var result = root.Parse(Array.Empty<string>());
|
||||
var config = new CliConfig { DefaultFormat = "json" };
|
||||
|
||||
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||
|
||||
Assert.Equal("json", format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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(new ConnectionBinding("Speed", 5), bindings[0]);
|
||||
Assert.Equal(new ConnectionBinding("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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
[Collection("Console")]
|
||||
public class OutputFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void WriteJson_WritesIndentedJson()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
OutputFormatter.WriteJson(new { Name = "test", Value = 42 });
|
||||
|
||||
var output = writer.ToString().Trim();
|
||||
Assert.Contains("\"name\"", output);
|
||||
Assert.Contains("\"value\": 42", output);
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteError_WritesToStdErr()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetError(writer);
|
||||
|
||||
OutputFormatter.WriteError("something went wrong", "ERR_CODE");
|
||||
|
||||
var output = writer.ToString().Trim();
|
||||
Assert.Contains("something went wrong", output);
|
||||
Assert.Contains("ERR_CODE", output);
|
||||
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteTable_RendersHeadersAndRows()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
var headers = new[] { "Id", "Name", "Status" };
|
||||
var rows = new List<string[]>
|
||||
{
|
||||
new[] { "1", "Alpha", "Active" },
|
||||
new[] { "2", "Beta", "Inactive" }
|
||||
};
|
||||
|
||||
OutputFormatter.WriteTable(rows, headers);
|
||||
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("Id", output);
|
||||
Assert.Contains("Name", output);
|
||||
Assert.Contains("Status", output);
|
||||
Assert.Contains("Alpha", output);
|
||||
Assert.Contains("Beta", output);
|
||||
Assert.Contains("Inactive", output);
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteTable_EmptyRows_ShowsHeadersOnly()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
var headers = new[] { "Id", "Name" };
|
||||
OutputFormatter.WriteTable(Array.Empty<string[]>(), headers);
|
||||
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("Id", output);
|
||||
Assert.Contains("Name", output);
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteTable_ColumnWidthsAdjustToContent()
|
||||
{
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
var headers = new[] { "X", "LongColumnName" };
|
||||
var rows = new List<string[]>
|
||||
{
|
||||
new[] { "ShortValue", "Y" }
|
||||
};
|
||||
|
||||
OutputFormatter.WriteTable(rows, headers);
|
||||
|
||||
var lines = writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
// Header line: "X" should be padded to at least "ShortValue" width
|
||||
Assert.True(lines.Length >= 2);
|
||||
// The "X" column header should be padded wider than 1 character
|
||||
var headerLine = lines[0];
|
||||
Assert.True(headerLine.IndexOf("LongColumnName") > 1);
|
||||
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CLI-016 — <c>WriteAsTable</c> previously derived the table
|
||||
/// header set from the first array element only, so any property unique to a later
|
||||
/// element was silently dropped from the rendered table.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class TableHeaderUnionTests
|
||||
{
|
||||
[Fact]
|
||||
public void HandleResponse_TableFormat_HeterogeneousArray_IncludesAllColumns()
|
||||
{
|
||||
// The second element has a "Status" property the first lacks. The pre-fix code
|
||||
// derived headers from items[0] only, so "Status" (and its value "Faulted")
|
||||
// were dropped from the table entirely.
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
try
|
||||
{
|
||||
var json = "[{\"Id\":1,\"Name\":\"Alpha\"},{\"Id\":2,\"Name\":\"Beta\",\"Status\":\"Faulted\"}]";
|
||||
var response = new ManagementResponse(200, json, null, null);
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("Status", output);
|
||||
Assert.Contains("Faulted", output);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_TableFormat_HeterogeneousArray_PreservesFirstSeenColumnOrder()
|
||||
{
|
||||
// Column order must be the first-seen order across all elements: the first
|
||||
// element contributes Id, Name; the second contributes Status after them.
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
try
|
||||
{
|
||||
var json = "[{\"Id\":1,\"Name\":\"Alpha\"},{\"Status\":\"Faulted\",\"Id\":2,\"Name\":\"Beta\"}]";
|
||||
var response = new ManagementResponse(200, json, null, null);
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
var output = writer.ToString();
|
||||
var headerLine = output.Split('\n')[0];
|
||||
Assert.True(headerLine.IndexOf("Id") < headerLine.IndexOf("Name"));
|
||||
Assert.True(headerLine.IndexOf("Name") < headerLine.IndexOf("Status"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleResponse_TableFormat_FirstElementHasExtraColumn_StillRendersAllRows()
|
||||
{
|
||||
// The reverse case: the first element has a property a later element lacks.
|
||||
// The later row must still render (with an empty cell), and all columns kept.
|
||||
var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
|
||||
try
|
||||
{
|
||||
var json = "[{\"Id\":1,\"Name\":\"Alpha\",\"Note\":\"first\"},{\"Id\":2,\"Name\":\"Beta\"}]";
|
||||
var response = new ManagementResponse(200, json, null, null);
|
||||
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("Note", output);
|
||||
Assert.Contains("first", output);
|
||||
Assert.Contains("Beta", output);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.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 { }
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CLI-014. The <c>Update*Command</c> records in Commons carry
|
||||
/// non-nullable "core" fields (e.g. <c>string Name</c>, <c>string Protocol</c>,
|
||||
/// <c>string Script</c>) — an update is a <em>whole-entity replace</em>, not a sparse
|
||||
/// patch. The CLI must therefore mark those core flags as <c>Required</c>: making them
|
||||
/// optional would let an omitted flag send <c>null</c>/empty and silently blank the
|
||||
/// field server-side. These tests pin that contract so the documented surface and the
|
||||
/// implemented surface stay aligned.
|
||||
/// </summary>
|
||||
public class UpdateCommandContractTests
|
||||
{
|
||||
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 Command UpdateCommand(Command group, params string[] path)
|
||||
{
|
||||
var current = group;
|
||||
foreach (var segment in path)
|
||||
current = current.Subcommands.Single(c => c.Name == segment);
|
||||
return current;
|
||||
}
|
||||
|
||||
private static void AssertRequired(Command command, params string[] requiredOptionNames)
|
||||
{
|
||||
foreach (var name in requiredOptionNames)
|
||||
{
|
||||
var option = command.Options.SingleOrDefault(o => o.Name == name);
|
||||
Assert.True(option != null, $"'{command.Name}' is missing expected option '{name}'.");
|
||||
Assert.True(option!.Required, $"'{command.Name}' option '{name}' must be Required (whole-replace contract).");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "update"), "--name");
|
||||
|
||||
[Fact]
|
||||
public void TemplateAttributeUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "attribute", "update"), "--name", "--data-type");
|
||||
|
||||
[Fact]
|
||||
public void TemplateAlarmUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "alarm", "update"), "--name", "--trigger-type", "--priority");
|
||||
|
||||
[Fact]
|
||||
public void TemplateScriptUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "script", "update"), "--name", "--code");
|
||||
|
||||
[Fact]
|
||||
public void SiteUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(SiteCommands.Build(Url, Format, Username, Password), "update"), "--name");
|
||||
|
||||
[Fact]
|
||||
public void DataConnectionUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(DataConnectionCommands.Build(Url, Format, Username, Password), "update"), "--name", "--protocol");
|
||||
|
||||
[Fact]
|
||||
public void ExternalSystemUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(ExternalSystemCommands.Build(Url, Format, Username, Password), "update"), "--name", "--endpoint-url", "--auth-type");
|
||||
|
||||
[Fact]
|
||||
public void NotificationUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(NotificationCommands.Build(Url, Format, Username, Password), "update"), "--name", "--emails");
|
||||
|
||||
[Fact]
|
||||
public void ApiMethodUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(ApiMethodCommands.Build(Url, Format, Username, Password), "update"), "--script");
|
||||
|
||||
[Fact]
|
||||
public void ExternalSystemMethodUpdate_IsGenuinelySparse_CoreFieldsOptional()
|
||||
{
|
||||
// UpdateExternalSystemMethodCommand is the one update record whose fields are
|
||||
// genuinely all-nullable, so its flags are correctly optional. Pin that too so
|
||||
// it is not mistakenly forced to Required.
|
||||
var update = UpdateCommand(ExternalSystemCommands.Build(Url, Format, Username, Password), "method", "update");
|
||||
foreach (var name in new[] { "--name", "--http-method", "--path" })
|
||||
{
|
||||
var option = update.Options.SingleOrDefault(o => o.Name == name);
|
||||
Assert.True(option != null, $"method update is missing option '{name}'.");
|
||||
Assert.False(option!.Required, $"method update option '{name}' should be optional (sparse-patch record).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CLI-004 — a malformed <c>--url</c> previously reached
|
||||
/// <c>new Uri(...)</c> in the <see cref="ZB.MOM.WW.ScadaBridge.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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.CLI/ZB.MOM.WW.ScadaBridge.CLI.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user