Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs
T
2026-09-10 00:51:45 -04:00

452 lines
20 KiB
C#

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();
// Exercise the production root: a separately maintained group list can drift.
private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands;
[Fact]
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
{
var groups = AllCommandGroups().ToList();
var secured = groups.Single(g => g.Name == "secured-write");
Assert.Equal(new[] { "approve", "list", "reject", "submit" }, secured.Subcommands.Select(c => c.Name).Order());
var connection = groups.Single(g => g.Name == "data-connection");
foreach (var name in new[] { "browse", "search", "verify-endpoint", "certs" })
Assert.Contains(connection.Subcommands, c => c.Name == name);
var certs = connection.Subcommands.Single(c => c.Name == "certs");
Assert.Equal(new[] { "list", "remove", "trust" }, certs.Subcommands.Select(c => c.Name).Order());
}
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();
Assert.Equal(20, 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);
}
[Fact]
public void TemplateNativeAlarmSource_HasAddListRemove()
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var group = template.Subcommands.Single(c => c.Name == "native-alarm-source");
var subNames = group.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("add", subNames);
Assert.Contains("list", subNames);
Assert.Contains("remove", subNames);
}
[Fact]
public void Deploy_HasSiteVerb_WithRequiredSiteId()
{
// WP2.5: bulk site deploy. Named `deploy site` to sit alongside
// `deploy instance` / `deploy artifacts` — the group's convention is that
// the verb names the SCOPE of the deploy.
var deploy = DeployCommands.Build(Url, Format, Username, Password);
var site = deploy.Subcommands.Single(c => c.Name == "site");
// --site-id is REQUIRED here, unlike `deploy artifacts` where omitting it
// means fleet-wide. A bulk instance deploy must never be accidentally
// fleet-wide.
Assert.NotEmpty(site.Parse([]).Errors);
Assert.Empty(site.Parse(["--site-id", "3"]).Errors);
}
[Fact]
public void DeploySite_HasForceFlag_DefaultingToTheNonDestructiveBehaviour()
{
// P7-6: bulk deploy skips instances whose config is unchanged, because a
// redeploy resets their static overrides and native alarm state at the
// site. --force is the operator escape hatch (restored volume, rebuilt
// node) and must be a FLAG — absent means skip-unchanged, which is the
// safe default a scripted `deploy site` keeps getting.
var deploy = DeployCommands.Build(Url, Format, Username, Password);
var site = deploy.Subcommands.Single(c => c.Name == "site");
var forceOption = (Option<bool>)site.Options.Single(o => o.Name == "--force");
var withoutForce = site.Parse(["--site-id", "3"]);
Assert.Empty(withoutForce.Errors);
Assert.False(withoutForce.GetValue(forceOption));
// A bare --force (no value) must parse: it is a flag, not a value option.
var withForce = site.Parse(["--site-id", "3", "--force"]);
Assert.Empty(withForce.Errors);
Assert.True(withForce.GetValue(forceOption));
}
[Fact]
public void InstanceNativeAlarmSource_HasSetAndClear()
{
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var group = instance.Subcommands.Single(c => c.Name == "native-alarm-source");
var subNames = group.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("set", subNames);
Assert.Contains("clear", subNames);
}
[Fact]
public void TemplateFolder_HasFullManagementCommandParity()
{
// CLI/management parity follow-up (docs/plans/2026-05-11-templates-folder-hierarchy.md):
// the folder management commands shipped without any CLI surface. Pin the
// 1:1 verb set so a verb cannot silently disappear again.
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var subNames = folder.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("list", subNames);
Assert.Contains("create", subNames);
Assert.Contains("rename", subNames);
Assert.Contains("move", subNames);
Assert.Contains("reorder", subNames);
Assert.Contains("delete", subNames);
}
[Fact]
public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable()
{
// Omitting --parent-id must parse cleanly — that is how a folder is created
// at the tree root (null ParentFolderId on CreateTemplateFolderCommand).
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var create = folder.Subcommands.Single(c => c.Name == "create");
var parentOption = create.Options.Single(o => o.Name == "--parent-id");
var rootParse = create.Parse(new[] { "--name", "Dev" });
Assert.Empty(rootParse.Errors);
Assert.Null(rootParse.GetValue((Option<int?>)parentOption));
var nestedParse = create.Parse(new[] { "--name", "Sub", "--parent-id", "7" });
Assert.Empty(nestedParse.Errors);
Assert.Equal(7, nestedParse.GetValue((Option<int?>)parentOption));
}
[Fact]
public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable()
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var move = folder.Subcommands.Single(c => c.Name == "move");
var parentOption = move.Options.Single(o => o.Name == "--parent-id");
var parse = move.Parse(new[] { "--id", "3" });
Assert.Empty(parse.Errors);
Assert.Null(parse.GetValue((Option<int?>)parentOption));
}
[Theory]
[InlineData("up")]
[InlineData("down")]
public void TemplateFolderReorder_AcceptsUpAndDown(string direction)
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var reorder = folder.Subcommands.Single(c => c.Name == "reorder");
var parse = reorder.Parse(new[] { "--id", "3", "--direction", direction });
Assert.Empty(parse.Errors);
}
[Theory]
[InlineData("sideways")]
// AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only.
// The audit --channel/--kind/--status options USED to share this contract; they
// dropped AcceptOnlyFromAmong in register row 99 precisely because the
// case-sensitivity rejected values the server accepts. --direction keeps it: it is
// a pure client-side literal that never reaches a server-side parse, so there is no
// authoritative check behind it to defer to.
[InlineData("Up")]
public void TemplateFolderReorder_RejectsAnythingElse(string direction)
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var reorder = folder.Subcommands.Single(c => c.Name == "reorder");
var parse = reorder.Parse(new[] { "--id", "3", "--direction", direction });
Assert.NotEmpty(parse.Errors);
}
[Fact]
public void TemplateFolderReorder_RequiresDirection()
{
var template = TemplateCommands.Build(Url, Format, Username, Password);
var folder = template.Subcommands.Single(c => c.Name == "folder");
var reorder = folder.Subcommands.Single(c => c.Name == "reorder");
var parse = reorder.Parse(new[] { "--id", "3" });
Assert.NotEmpty(parse.Errors);
}
// ========================================================================
// Register row 99: the audit filter options must NOT be case-sensitively
// gated client-side.
//
// `audit query --status delivered` used to fail at the CLI parse layer while
// the server would have accepted it — AcceptOnlyFromAmong matches ordinally
// against the capitalised literals. The valid set now lives in the help text
// and the server's strict, case-insensitive check is authoritative (the same
// treatment row 88 gave `deploy status`). These tests fail before that change.
// ========================================================================
public static TheoryData<string, string, string> AuditFilterValues() => new()
{
// subcommand, option, value — lowercase, uppercase and mixed case of a real member.
{ "query", "--status", "delivered" },
{ "query", "--status", "DELIVERED" },
{ "query", "--channel", "apioutbound" },
{ "query", "--kind", "apicall" },
{ "export", "--status", "parked" },
{ "export", "--channel", "notification" },
{ "export", "--kind", "dbwrite" },
};
[Theory]
[MemberData(nameof(AuditFilterValues))]
public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value)
{
var audit = AuditCommands.Build(Url, Format, Username, Password);
var cmd = audit.Subcommands.Single(c => c.Name == subcommand);
// `export` has three required options; supply them so the only thing under
// test is the filter value's own acceptance.
var args = subcommand == "export"
? new[] { "--since", "1h", "--until", "now", "--format", "csv", "--output", "out.csv", option, value }
: new[] { option, value };
var parse = cmd.Parse(args);
Assert.Empty(parse.Errors);
}
[Theory]
[InlineData("csv")]
[InlineData("CSV")]
[InlineData("jsonl")]
public void AuditExport_FormatOption_AcceptsValuesInAnyCase(string format)
{
// The server lowercases --format before matching and returns a 400 naming
// csv/jsonl/parquet for anything else, so the client-side literal gate was
// pure friction (register row 99).
var audit = AuditCommands.Build(Url, Format, Username, Password);
var export = audit.Subcommands.Single(c => c.Name == "export");
var parse = export.Parse(new[]
{
"--since", "1h", "--until", "now", "--format", format, "--output", "out.csv",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide()
{
// Deliberate: the CLI no longer second-guesses the filter vocabulary. A bad
// value must reach the server, which answers 400 naming the valid set —
// one authoritative check instead of two that can drift apart.
var audit = AuditCommands.Build(Url, Format, Username, Password);
var query = audit.Subcommands.Single(c => c.Name == "query");
var parse = query.Parse(new[] { "--status", "NotAStatus" });
Assert.Empty(parse.Errors);
}
[Fact]
public void SiteArea_HasMoveVerb()
{
// CLI/management parity follow-up (docs/plans/2026-05-11-deployment-topology-page-design.md):
// AreaService.MoveAreaAsync had no management command and no CLI verb.
var site = SiteCommands.Build(Url, Format, Username, Password);
var area = site.Subcommands.Single(c => c.Name == "area");
var subNames = area.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("move", subNames);
}
[Fact]
public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable()
{
// Omitting --parent-id is how an area is moved to the site root
// (null NewParentAreaId on MoveAreaCommand).
var site = SiteCommands.Build(Url, Format, Username, Password);
var area = site.Subcommands.Single(c => c.Name == "area");
var move = area.Subcommands.Single(c => c.Name == "move");
var parentOption = move.Options.Single(o => o.Name == "--parent-id");
var rootParse = move.Parse(new[] { "--id", "3" });
Assert.Empty(rootParse.Errors);
Assert.Null(rootParse.GetValue((Option<int?>)parentOption));
var reparentParse = move.Parse(new[] { "--id", "3", "--parent-id", "2" });
Assert.Empty(reparentParse.Errors);
Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption));
}
[Fact]
public void SiteAreaMove_RequiresId()
{
var site = SiteCommands.Build(Url, Format, Username, Password);
var area = site.Subcommands.Single(c => c.Name == "area");
var move = area.Subcommands.Single(c => c.Name == "move");
var parse = move.Parse(Array.Empty<string>());
Assert.NotEmpty(parse.Errors);
}
[Fact]
public void TemplateList_HasSkipAndTakePagingOptions()
{
// arch-review P2: `template list` gained additive --skip/--take offset paging.
var template = TemplateCommands.Build(Url, Format, Username, Password);
var list = template.Subcommands.Single(c => c.Name == "list");
var optionNames = list.Options.Select(o => o.Name).ToList();
Assert.Contains("--skip", optionNames);
Assert.Contains("--take", optionNames);
}
[Fact]
public void InstanceList_HasSkipAndTakePagingOptions()
{
// arch-review P2: `instance list` gained additive --skip/--take offset paging.
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var list = instance.Subcommands.Single(c => c.Name == "list");
var optionNames = list.Options.Select(o => o.Name).ToList();
Assert.Contains("--skip", optionNames);
Assert.Contains("--take", optionNames);
}
[Fact]
public void InstanceList_ParsesTakeOptionValue()
{
// Parse-level check that --take binds to an int value on the list command.
var instance = InstanceCommands.Build(Url, Format, Username, Password);
var list = instance.Subcommands.Single(c => c.Name == "list");
var takeOption = list.Options.Single(o => o.Name == "--take");
var parse = list.Parse(new[] { "--skip", "10", "--take", "5" });
Assert.Empty(parse.Errors);
Assert.Equal(5, parse.GetValue((Option<int?>)takeOption));
}
[Theory]
[InlineData(typeof(GetInstanceCommand))]
[InlineData(typeof(ListSitesCommand))]
[InlineData(typeof(CreateTemplateCommand))]
[InlineData(typeof(SetConnectionBindingsCommand))]
[InlineData(typeof(SetInstanceOverridesCommand))]
[InlineData(typeof(DebugSnapshotCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
[InlineData(typeof(MgmtDeploySiteCommand))]
[InlineData(typeof(QueryAuditLogCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(ImportBundleCommand))]
[InlineData(typeof(AddTemplateNativeAlarmSourceCommand))]
[InlineData(typeof(SetInstanceNativeAlarmSourceOverrideCommand))]
[InlineData(typeof(MoveAreaCommand))]
[InlineData(typeof(ListTemplateFoldersCommand))]
[InlineData(typeof(CreateTemplateFolderCommand))]
[InlineData(typeof(RenameTemplateFolderCommand))]
[InlineData(typeof(MoveTemplateFolderCommand))]
[InlineData(typeof(ReorderTemplateFolderCommand))]
[InlineData(typeof(DeleteTemplateFolderCommand))]
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));
}
}