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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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>