diff --git a/docs/plans/2026-05-11-deployment-topology-page-design.md b/docs/plans/2026-05-11-deployment-topology-page-design.md index 268b4a8b..2a01f7f3 100644 --- a/docs/plans/2026-05-11-deployment-topology-page-design.md +++ b/docs/plans/2026-05-11-deployment-topology-page-design.md @@ -138,9 +138,9 @@ Areas can be moved freely (subject to validation). Templates are different becau - `AreaService.UpdateAreaAsync` (stays name-only) - `InstanceService` lifecycle methods (already used by current Instances page) -### CLI / ManagementService parity (optional follow-up) -- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`. -- Add CLI: `cli area move --id X --parent-id Y --username … --password …` (omit `--parent-id` to move to site root). +### CLI / ManagementService parity (optional follow-up) — **DONE 2026-08-01** +- ~~Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.~~ Shipped: `MoveAreaCommand(int AreaId, int? NewParentAreaId)` in `Commons/Messages/Management/SiteCommands.cs`, dispatched by `ManagementActor.HandleMoveArea` (delegates to `AreaService.MoveAreaAsync`; failures surface as the standard curated failure response), gated any-of `[Designer, Deployer]` like the other area mutations. +- ~~Add CLI: `cli area move --id X --parent-id Y …` (omit `--parent-id` to move to site root).~~ Shipped as **`scadabridge site area move --id X [--parent-id Y]`** — the area verbs live under the existing `site area` group, not at the CLI root, so the verb was placed alongside `site area create|update|delete` rather than introducing a second top-level spelling. Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops. diff --git a/docs/plans/2026-05-11-templates-folder-hierarchy.md b/docs/plans/2026-05-11-templates-folder-hierarchy.md index 759b4a0f..1bd5bd11 100644 --- a/docs/plans/2026-05-11-templates-folder-hierarchy.md +++ b/docs/plans/2026-05-11-templates-folder-hierarchy.md @@ -2202,7 +2202,7 @@ Open http://localhost:9000/design/templates (login `multi-role` / `password`). V ## Out of scope (per design) -- CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan). +- ~~CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan).~~ **DONE 2026-08-01** — shipped as `scadabridge template folder list|create|rename|move|reorder|delete`, mapping 1:1 onto `ListTemplateFolders` / `CreateTemplateFolder` / `RenameTemplateFolder` / `MoveTemplateFolder` / `ReorderTemplateFolder` / `DeleteTemplateFolder`. Omitting `--parent-id` on create/move targets the tree root; `reorder --direction` takes the lowercase literals `up` / `down`. - Tree search / filter input. - Sibling reordering via drag-drop (alphabetical sort is fixed). - Root-area context menu (right-click in empty tree space). diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index 70799e5c..20c7219a 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -98,9 +98,12 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b - **CreateTemplateFolder** (`Name`, `ParentFolderId?`): Create a folder, optionally nested under a parent (Design role). - **RenameTemplateFolder** (`FolderId`, `NewName`): Rename a folder; enforces sibling uniqueness (Design role). - **MoveTemplateFolder** (`FolderId`, `NewParentFolderId?`): Move a folder to a new parent (or root); rejects cycles (Design role). +- **ReorderTemplateFolder** (`FolderId`, `Direction`): Swap a folder's `SortOrder` with its previous (`Up`) or next (`Down`) sibling; reordering past either end is a no-op (Design role). - **DeleteTemplateFolder** (`FolderId`): Delete a folder; blocked if the folder contains any subfolders or templates (Design role). - **MoveTemplateToFolder** (`TemplateId`, `NewFolderId?`): Move a template into a folder, or to the root when null (Design role). +The whole folder surface is reachable from the CLI as `template folder list|create|rename|move|reorder|delete` as well as from the Central UI template tree. + ### Template Members - **AddTemplateAttribute** / **UpdateTemplateAttribute** / **DeleteTemplateAttribute**: Manage attributes on a template. @@ -127,6 +130,7 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b - **ListSites** / **GetSite**: Query site definitions. - **CreateSite** / **UpdateSite** / **DeleteSite**: Manage site definitions. - **ListAreas** / **CreateArea** / **UpdateArea** / **DeleteArea**: Manage area hierarchies per site. +- **MoveArea** (`AreaId`, `NewParentAreaId?`): Re-parent an area within its own site; a null `NewParentAreaId` moves it to the site root. Delegates every rule to `AreaService.MoveAreaAsync` — rejects self-parent, descendant-parent (cycle), cross-site parent, and sibling name collision at the target level; the service writes the `"Move"` audit row. Same any-of `[Designer, Deployer]` gate as the other area mutations. CLI: `site area move`. ### Data Connections @@ -221,7 +225,7 @@ Every incoming message carries the authenticated user's identity and roles. The - **Admin** role required for: site management, API key management, role mapping management, scope rule management, system configuration. - **Design** role required for: template authoring (including template member management: attributes, alarms, native alarm sources, scripts, compositions), shared scripts, external system definitions, database connection definitions, notification lists, inbound API method definitions. - **Deployment** role required for: instance management (including instance alarm overrides and native alarm source overrides), deployments, debug view, debug snapshot, parked message queries, site event log queries. Site scoping is enforced for site-scoped Deployment users. -- **Any of Design / Deployment** required for: area management (`CreateAreaCommand` / `UpdateAreaCommand` / `DeleteAreaCommand`). Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies — enforced as an any-of gate (arch-review C6). Administrator is not implicitly included. +- **Any of Design / Deployment** required for: area management (`CreateAreaCommand` / `UpdateAreaCommand` / `MoveAreaCommand` / `DeleteAreaCommand`). Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies — enforced as an any-of gate (arch-review C6). Administrator is not implicitly included. - **Operator** role required for: submitting a secured write (`SubmitSecuredWriteCommand`). - **Verifier** role required for: approving / rejecting a secured write (`ApproveSecuredWriteCommand` / `RejectSecuredWriteCommand`). The no-self-approval rule (`Operator ≠ Verifier`) is enforced in the handler, independent of the role check. - **Any of Operator / Verifier / Administrator** required for: listing / querying secured writes (`ListSecuredWritesCommand`). Read-only, but the history exposes process-sensitive tag values, so it is gated any-of rather than open to every authenticated user (arch-review UA1). Enforced as an any-of gate (`GetRequiredRoles` returns the permitted set; the caller must hold at least one). diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SiteCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SiteCommands.cs index 9e92b79e..e026fe20 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SiteCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SiteCommands.cs @@ -183,6 +183,23 @@ public static class SiteCommands }); group.Add(updateCmd); + // Omitting --parent-id moves the area to the site root, matching + // AreaService.MoveAreaAsync's null newParentAreaId contract. + var moveIdOption = new Option("--id") { Description = "Area ID", Required = true }; + var moveParentOption = new Option("--parent-id") { Description = "New parent area ID; omit to move to the site root" }; + var moveCmd = new Command("move") { Description = "Move an area under a new parent area (or to the site root)" }; + moveCmd.Add(moveIdOption); + moveCmd.Add(moveParentOption); + moveCmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(moveIdOption); + var parentId = result.GetValue(moveParentOption); + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new MoveAreaCommand(id, parentId)); + }); + group.Add(moveCmd); + var deleteIdOption = new Option("--id") { Description = "Area ID", Required = true }; var deleteCmd = new Command("delete") { Description = "Delete an area" }; deleteCmd.Add(deleteIdOption); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs index 3abe7e94..2b883bde 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs @@ -29,11 +29,116 @@ public static class TemplateCommands command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildFolder(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption)); return command; } + /// + /// Builds the template folder subgroup — the CLI surface for the template + /// folder hierarchy management commands (folders are a Central UI organizational + /// device only; they have no effect on template resolution or flattening). + /// Omitting --parent-id on create/move places the folder at the tree root, + /// matching the nullable ParentFolderId on the underlying commands. + /// + private static Command BuildFolder(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var group = new Command("folder") { Description = "Manage template folders" }; + + var listCmd = new Command("list") { Description = "List all template folders" }; + listCmd.SetAction(async (ParseResult result) => + { + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplateFoldersCommand()); + }); + group.Add(listCmd); + + var createNameOption = new Option("--name") { Description = "Folder name", Required = true }; + var createParentOption = new Option("--parent-id") { Description = "Parent folder ID; omit to create at the tree root" }; + var createCmd = new Command("create") { Description = "Create a template folder" }; + createCmd.Add(createNameOption); + createCmd.Add(createParentOption); + createCmd.SetAction(async (ParseResult result) => + { + var name = result.GetValue(createNameOption)!; + var parentId = result.GetValue(createParentOption); + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new CreateTemplateFolderCommand(name, parentId)); + }); + group.Add(createCmd); + + var renameIdOption = new Option("--id") { Description = "Folder ID", Required = true }; + var renameNameOption = new Option("--name") { Description = "New folder name", Required = true }; + var renameCmd = new Command("rename") { Description = "Rename a template folder" }; + renameCmd.Add(renameIdOption); + renameCmd.Add(renameNameOption); + renameCmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(renameIdOption); + var name = result.GetValue(renameNameOption)!; + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new RenameTemplateFolderCommand(id, name)); + }); + group.Add(renameCmd); + + var moveIdOption = new Option("--id") { Description = "Folder ID", Required = true }; + var moveParentOption = new Option("--parent-id") { Description = "New parent folder ID; omit to move to the tree root" }; + var moveCmd = new Command("move") { Description = "Move a template folder under a new parent folder (or to the tree root)" }; + moveCmd.Add(moveIdOption); + moveCmd.Add(moveParentOption); + moveCmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(moveIdOption); + var parentId = result.GetValue(moveParentOption); + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new MoveTemplateFolderCommand(id, parentId)); + }); + group.Add(moveCmd); + + var reorderIdOption = new Option("--id") { Description = "Folder ID", Required = true }; + var reorderDirectionOption = new Option("--direction") + { + Description = "Reorder direction: 'up' swaps with the previous sibling, 'down' with the next", + Required = true + }; + reorderDirectionOption.AcceptOnlyFromAmong("up", "down"); + var reorderCmd = new Command("reorder") { Description = "Reorder a template folder among its siblings" }; + reorderCmd.Add(reorderIdOption); + reorderCmd.Add(reorderDirectionOption); + reorderCmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(reorderIdOption); + // AcceptOnlyFromAmong has already rejected anything but the two literals, + // so this maps a validated value rather than parsing untrusted input. + var direction = result.GetValue(reorderDirectionOption) == "up" + ? ReorderDirection.Up + : ReorderDirection.Down; + + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new ReorderTemplateFolderCommand(id, direction)); + }); + group.Add(reorderCmd); + + var deleteIdOption = new Option("--id") { Description = "Folder ID", Required = true }; + var deleteCmd = new Command("delete") { Description = "Delete a template folder (fails if it contains subfolders or templates)" }; + deleteCmd.Add(deleteIdOption); + deleteCmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(deleteIdOption); + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new DeleteTemplateFolderCommand(id)); + }); + group.Add(deleteCmd); + + return group; + } + private static Command BuildResyncMembers(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID (its derived subtree is included)", Required = true }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index b6e071ff..8ec7c4b9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -431,6 +431,84 @@ scadabridge --url template resync-members --id |--------|----------|-------------| | `--id` | yes | Template ID (its derived subtree is included) | +#### `template folder list` + +List all template folders. Folders are a Central UI organizational device only — they +have no effect on template resolution or flattening. + +```sh +scadabridge --url template folder list +``` + +Takes no options beyond the global ones. + +#### `template folder create` + +Create a template folder. Omit `--parent-id` to create it at the tree root. Sibling +names must be unique (case-insensitive). + +```sh +scadabridge --url template folder create --name [--parent-id ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Folder name | +| `--parent-id` | no | Parent folder ID; omit to create at the tree root | + +#### `template folder rename` + +Rename a template folder. Enforces sibling-name uniqueness. + +```sh +scadabridge --url template folder rename --id --name +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Folder ID | +| `--name` | yes | New folder name | + +#### `template folder move` + +Move a template folder under a new parent folder. Omit `--parent-id` to move it to the +tree root. Rejects cycles (a folder cannot move under one of its own descendants). + +```sh +scadabridge --url template folder move --id [--parent-id ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Folder ID to move | +| `--parent-id` | no | New parent folder ID; omit to move to the tree root | + +#### `template folder reorder` + +Swap a folder's `SortOrder` with an adjacent sibling. `up` swaps with the previous +sibling, `down` with the next; reordering past either end is a no-op. + +```sh +scadabridge --url template folder reorder --id --direction +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Folder ID | +| `--direction` | yes | `up` or `down` (lowercase literals only) | + +#### `template folder delete` + +Delete a template folder. Blocked if the folder still contains any subfolders or templates. + +```sh +scadabridge --url template folder delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Folder ID | + --- ### `instance` — Manage instances @@ -831,6 +909,21 @@ scadabridge --url site area update --id --name | `--id` | yes | Area ID | | `--name` | yes | Area name | +#### `site area move` + +Re-parent an area within its own site. Omit `--parent-id` to move the area to the +site root. Rejected for: self-parent, moving under one of the area's own descendants +(cycle), a parent in a different site, and a sibling name collision at the target level. + +```sh +scadabridge --url site area move --id [--parent-id ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Area ID to move | +| `--parent-id` | no | New parent area ID; omit to move to the site root | + #### `site area delete` Delete an area. Fails if any instances are assigned to it. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SiteCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SiteCommands.cs index 80a8e6c5..4fac1070 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SiteCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SiteCommands.cs @@ -9,3 +9,10 @@ public record ListAreasCommand(int SiteId); public record CreateAreaCommand(int SiteId, string Name, int? ParentAreaId); public record DeleteAreaCommand(int AreaId); public record UpdateAreaCommand(int AreaId, string Name); + +/// +/// Re-parents an area within its own site. NewParentAreaId == null moves the +/// area to the site root. Rejected by the handler for self-parent, descendant-parent +/// (cycle), cross-site parent, and sibling name collision at the target level. +/// +public record MoveAreaCommand(int AreaId, int? NewParentAreaId); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index e16d9b0f..3033e20c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -225,7 +225,8 @@ public class ManagementActor : ReceiveActor // Area management — any-of [Designer, Deployer] (arch-review C6). // Exposed both in the Designer tooling and inside the Central UI // Deployment Topology workflow (RequireDeployment), so both roles qualify. - CreateAreaCommand or UpdateAreaCommand or DeleteAreaCommand => AreaManagers, + CreateAreaCommand or UpdateAreaCommand or MoveAreaCommand + or DeleteAreaCommand => AreaManagers, // Designer operations CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand @@ -362,6 +363,7 @@ public class ManagementActor : ReceiveActor CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username), DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username), UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username), + MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user.Username), // Data Connections ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd), @@ -3116,6 +3118,22 @@ public class ManagementActor : ReceiveActor return area; } + /// + /// Re-parents an area within its site, delegating every validation rule + /// (not-found, self-parent, descendant-parent cycle, cross-site parent, + /// sibling name collision) to AreaService.MoveAreaAsync. A null + /// NewParentAreaId moves the area to the site root. Failures surface + /// as the standard curated failure + /// response, matching the sibling folder/template move handlers. The service + /// writes the "Move" audit row itself, so this handler does not audit again. + /// + private static async Task HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, string user) + { + var svc = sp.GetRequiredService(); + var result = await svc.MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user); + return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); + } + // ======================================================================== // Remote Query handlers // ======================================================================== diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs index 91029ba3..14c6321d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs @@ -151,6 +151,135 @@ public class CommandTreeTests 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)parentOption)); + + var nestedParse = create.Parse(new[] { "--name", "Sub", "--parent-id", "7" }); + Assert.Empty(nestedParse.Errors); + Assert.Equal(7, nestedParse.GetValue((Option)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)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 (same contract as the audit + // --channel / --kind / --status options), so the literals are lowercase only. + [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); + } + + [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)parentOption)); + + var reparentParse = move.Parse(new[] { "--id", "3", "--parent-id", "2" }); + Assert.Empty(reparentParse.Errors); + Assert.Equal(2, reparentParse.GetValue((Option)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()); + Assert.NotEmpty(parse.Errors); + } + [Fact] public void TemplateList_HasSkipAndTakePagingOptions() { @@ -200,6 +329,13 @@ public class CommandTreeTests [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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 41f298b0..7a43e95d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -3429,6 +3429,99 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg(TimeSpan.FromSeconds(5)); } + // ======================================================================== + // MoveArea (CLI/management parity follow-up from the deployment-topology + // plan): re-parenting is the same structural authoring act as create / + // update / delete, so it carries the identical any-of [Designer, Deployer] + // gate, and every validation rule is delegated to AreaService.MoveAreaAsync. + // ======================================================================== + + [Theory] + [InlineData("Designer")] + [InlineData("Deployer")] + public void MoveArea_DesignerOrDeployer_Allowed(string role) + { + var actor = CreateActor(); + actor.Tell(Envelope(new MoveAreaCommand(1, 2), role)); + var resp = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.IsNotType(resp); + } + + [Fact] + public void MoveArea_ViewerOnly_Unauthorized() + { + var actor = CreateActor(); + actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Viewer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void MoveArea_ToNewParent_ReturnsSuccessAndReparentsArea() + { + _services.AddScoped(); + var moving = new Area("Line1") { Id = 3, SiteId = 1, ParentAreaId = 1 }; + var target = new Area("Building2") { Id = 2, SiteId = 1 }; + _templateRepo.GetAreaByIdAsync(3, Arg.Any()).Returns(moving); + _templateRepo.GetAreaByIdAsync(2, Arg.Any()).Returns(target); + _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(new List { new("Building1") { Id = 1, SiteId = 1 }, target, moving }); + + var actor = CreateActor(); + var envelope = Envelope(new MoveAreaCommand(3, 2), "Deployer"); + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + Assert.Equal(2, moving.ParentAreaId); + } + + [Fact] + public void MoveArea_WithNullParent_MovesToSiteRoot() + { + _services.AddScoped(); + var moving = new Area("Line1") { Id = 3, SiteId = 1, ParentAreaId = 1 }; + _templateRepo.GetAreaByIdAsync(3, Arg.Any()).Returns(moving); + _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(new List { new("Building1") { Id = 1, SiteId = 1 }, moving }); + + var actor = CreateActor(); + actor.Tell(Envelope(new MoveAreaCommand(3, null), "Designer")); + + ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Null(moving.ParentAreaId); + } + + [Fact] + public void MoveArea_UnderOwnDescendant_ReturnsError() + { + _services.AddScoped(); + var parent = new Area("Building1") { Id = 1, SiteId = 1 }; + var child = new Area("Line1") { Id = 2, SiteId = 1, ParentAreaId = 1 }; + _templateRepo.GetAreaByIdAsync(1, Arg.Any()).Returns(parent); + _templateRepo.GetAreaByIdAsync(2, Arg.Any()).Returns(child); + _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(new List { parent, child }); + + var actor = CreateActor(); + actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Designer")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("descendants", response.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MoveArea_AreaNotFound_ReturnsError() + { + _services.AddScoped(); + _templateRepo.GetAreaByIdAsync(99, Arg.Any()).Returns((Area?)null); + + var actor = CreateActor(); + actor.Tell(Envelope(new MoveAreaCommand(99, null), "Designer")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("99", response.Error); + } + // ======================================================================== // Remote-query actor dispatch for the OPC UA design-time commands // (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs index efc0d6bd..cd5f55dc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -54,6 +54,9 @@ public class RequiredRoleMatrixTests // ---- Area management: any-of [Designer, Deployer] --------------------------- ["CreateArea"] = AreaManagers, ["UpdateArea"] = AreaManagers, + // Re-parenting an area is the same structural authoring act as create / + // rename / delete, so it carries the identical any-of gate. + ["MoveArea"] = AreaManagers, ["DeleteArea"] = AreaManagers, // ---- Designer-only ----------------------------------------------------------