452 lines
20 KiB
C#
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));
|
|
}
|
|
}
|