Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs
T
Joseph Doherty b34ec10f8b fix(deploymentmanager): bulk site deploy skips up-to-date instances and un-poisons the shared DbContext (arch-review Low P7-5 + P7-6)
P7-6 (behaviour fix, plan decision 1): DeploySiteAsync redeployed EVERY
deployable instance unconditionally. A redeploy is not idempotent at the site —
it resets static attribute overrides and clears native alarm state by design — so
one site-wide push destroyed live operational state on every unchanged instance
in the batch, which is normally most of them. Bulk deploy now pre-filters with
IStaleInstanceProbe (the same seam the bundle importer and Deployments page use;
its memo timing was fixed by the Medium wave's P7-4): an instance whose freshly
computed revision hash still matches its DeployedConfigSnapshot is reported in the
existing additive SkippedInstances list with reason "up-to-date" and never
flattened, staged or sent. The filter is deliberately conservative — every
uncertainty deploys: only an Enabled instance qualifies, a missing snapshot
disqualifies, a prior record that ShouldQuerySiteBeforeRedeploy flags goes through
prepare so the reconcile that HEALS it still runs, and a null/faulting probe means
"indeterminate". New additive MgmtDeploySiteCommand.Force (default false = the new
filtered behaviour) plus CLI `deploy site --force` restores the unconditional push
for the case central cannot see (restored volume, rebuilt node). Composes with the
Medium wave's P7-2: Disabled wins — force suppresses the up-to-date filter only,
never the Disabled skip.

P7-5 (verified, then fixed): the cascade REPRODUCES. All three bulk phases share
one scoped DbContext, and a failed SaveChanges does not untrack the offending
entity — EF re-attempts it on the next save, including MarkDeploymentFailedAsync's
cleanup write. Measured on a real SQLite-backed ScadaBridgeDbContext: a permanent
save failure on instance 1's post-success side effects failed instances 2, 3 and 4
and left all three deployment records stranded InProgress. MarkDeploymentFailedAsync
now detaches the pending changes (new IDeploymentManagerRepository.DetachPendingChanges)
when the cleanup save fails and retries the Failed write once on the clean tracker,
so later instances save cleanly and the record reaches a terminal status. The
DeployFailed audit row moved outside the status-write try (best-effort, must not be
lost because the status write failed).

Tests: 5 new DeploySiteAsyncTests (skip + reason + no site round-trip; --force
deploys; Disabled skipped under both force values; the three not-enough-to-skip
guards), 2 new IntegrationTests over a real EF context (the poisoned-context
cascade, and the narrow EF tracking fact the fix rests on), CLI --force flag
parse test, and a MgmtDeploySite wire-default test. Proof of failure: both P7-6
behaviour tests and both P7-5 assertions fail against the unfixed code.
2026-08-19 16:41:04 -04:00

466 lines
21 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();
// 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),
CachedCallCommands.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 (17):
// template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle, cached-call.
Assert.Equal(17, 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));
}
}