feat(cli,management): close area-move and template-folder CLI parity gaps

Two verified-absent parity gaps between the service layer and the CLI /
ManagementActor command surface, both left as follow-ups by the 2026-05-11
design plans.

(1) area move. AreaService.MoveAreaAsync had existed since the deployment
topology page shipped but was reachable only from the Blazor UI. Adds
MoveAreaCommand(AreaId, NewParentAreaId?) to Commons, a ManagementActor
dispatch arm delegating straight to AreaService.MoveAreaAsync (not-found /
self-parent / descendant-cycle / cross-site / name-collision all surface as
the standard curated ManagementCommandException failure response; the service
writes its own "Move" audit row), and the CLI verb `site area move --id
[--parent-id]`. Omitting --parent-id moves the area to the site root, matching
the command's nullable NewParentAreaId. The command carries the SAME any-of
[Designer, Deployer] gate as CreateArea/UpdateArea/DeleteArea (arch-review C6):
re-parenting is the same structural authoring act, exposed on the same two
surfaces. Placed under the existing `site area` group rather than a new
top-level `area` group, alongside its create/update/delete siblings.

(2) template folder verbs. The five folder management commands have been
handled by ManagementActor since the folder-hierarchy plan, but the promised
CLI surface was never written. Adds `template folder
list|create|rename|move|reorder|delete` mapping 1:1 onto ListTemplateFolders /
CreateTemplateFolder / RenameTemplateFolder / MoveTemplateFolder /
ReorderTemplateFolder / DeleteTemplateFolder. --parent-id is omitted to target
the tree root; --direction takes the lowercase literals up/down, validated at
parse time by AcceptOnlyFromAmong (same case-sensitive contract as the audit
--channel/--kind/--status options).

Follow-on updates: the frozen authorization matrix gains its MoveArea entry
(reflection-driven, so a missing entry would have failed CI); CommandTreeTests
pins both new verb sets plus the omit-parent-id-means-root parse behaviour and
registry round-trips; ManagementActorTests covers the MoveArea role gate and
the delegate-to-service success/root/cycle/not-found paths; the CLI README and
Component-ManagementService.md document the new surface (the latter also gained
the previously-undocumented ReorderTemplateFolder); both plan docs' follow-up
lines are marked done.
This commit is contained in:
Joseph Doherty
2026-08-01 11:14:48 -04:00
parent e0851e3e17
commit 88638d774a
11 changed files with 482 additions and 6 deletions
@@ -138,9 +138,9 @@ Areas can be moved freely (subject to validation). Templates are different becau
- `AreaService.UpdateAreaAsync` (stays name-only) - `AreaService.UpdateAreaAsync` (stays name-only)
- `InstanceService` lifecycle methods (already used by current Instances page) - `InstanceService` lifecycle methods (already used by current Instances page)
### CLI / ManagementService parity (optional follow-up) ### CLI / ManagementService parity (optional follow-up) — **DONE 2026-08-01**
- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`. - ~~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 --username … --password …` (omit `--parent-id` to move to site root). - ~~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. Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
@@ -2202,7 +2202,7 @@ Open http://localhost:9000/design/templates (login `multi-role` / `password`). V
## Out of scope (per design) ## 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. - Tree search / filter input.
- Sibling reordering via drag-drop (alphabetical sort is fixed). - Sibling reordering via drag-drop (alphabetical sort is fixed).
- Root-area context menu (right-click in empty tree space). - Root-area context menu (right-click in empty tree space).
@@ -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). - **CreateTemplateFolder** (`Name`, `ParentFolderId?`): Create a folder, optionally nested under a parent (Design role).
- **RenameTemplateFolder** (`FolderId`, `NewName`): Rename a folder; enforces sibling uniqueness (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). - **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). - **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). - **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 ### Template Members
- **AddTemplateAttribute** / **UpdateTemplateAttribute** / **DeleteTemplateAttribute**: Manage attributes on a template. - **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. - **ListSites** / **GetSite**: Query site definitions.
- **CreateSite** / **UpdateSite** / **DeleteSite**: Manage site definitions. - **CreateSite** / **UpdateSite** / **DeleteSite**: Manage site definitions.
- **ListAreas** / **CreateArea** / **UpdateArea** / **DeleteArea**: Manage area hierarchies per site. - **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 ### 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. - **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. - **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. - **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`). - **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. - **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). - **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).
@@ -183,6 +183,23 @@ public static class SiteCommands
}); });
group.Add(updateCmd); group.Add(updateCmd);
// Omitting --parent-id moves the area to the site root, matching
// AreaService.MoveAreaAsync's null newParentAreaId contract.
var moveIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
var moveParentOption = new Option<int?>("--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<int>("--id") { Description = "Area ID", Required = true }; var deleteIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete an area" }; var deleteCmd = new Command("delete") { Description = "Delete an area" };
deleteCmd.Add(deleteIdOption); deleteCmd.Add(deleteIdOption);
@@ -29,11 +29,116 @@ public static class TemplateCommands
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildComposition(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)); command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption));
return command; return command;
} }
/// <summary>
/// Builds the <c>template folder</c> 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 <c>--parent-id</c> on create/move places the folder at the tree root,
/// matching the nullable <c>ParentFolderId</c> on the underlying commands.
/// </summary>
private static Command BuildFolder(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string>("--name") { Description = "Folder name", Required = true };
var createParentOption = new Option<int?>("--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<int>("--id") { Description = "Folder ID", Required = true };
var renameNameOption = new Option<string>("--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<int>("--id") { Description = "Folder ID", Required = true };
var moveParentOption = new Option<int?>("--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<int>("--id") { Description = "Folder ID", Required = true };
var reorderDirectionOption = new Option<string>("--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<int>("--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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true }; var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
+93
View File
@@ -431,6 +431,84 @@ scadabridge --url <url> template resync-members --id <int>
|--------|----------|-------------| |--------|----------|-------------|
| `--id` | yes | Template ID (its derived subtree is included) | | `--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 <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 <url> template folder create --name <string> [--parent-id <int>]
```
| 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 <url> template folder rename --id <int> --name <string>
```
| 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 <url> template folder move --id <int> [--parent-id <int>]
```
| 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 <url> template folder reorder --id <int> --direction <up|down>
```
| 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 <url> template folder delete --id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Folder ID |
--- ---
### `instance` — Manage instances ### `instance` — Manage instances
@@ -831,6 +909,21 @@ scadabridge --url <url> site area update --id <int> --name <string>
| `--id` | yes | Area ID | | `--id` | yes | Area ID |
| `--name` | yes | Area name | | `--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 <url> site area move --id <int> [--parent-id <int>]
```
| 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` #### `site area delete`
Delete an area. Fails if any instances are assigned to it. Delete an area. Fails if any instances are assigned to it.
@@ -9,3 +9,10 @@ public record ListAreasCommand(int SiteId);
public record CreateAreaCommand(int SiteId, string Name, int? ParentAreaId); public record CreateAreaCommand(int SiteId, string Name, int? ParentAreaId);
public record DeleteAreaCommand(int AreaId); public record DeleteAreaCommand(int AreaId);
public record UpdateAreaCommand(int AreaId, string Name); public record UpdateAreaCommand(int AreaId, string Name);
/// <summary>
/// Re-parents an area within its own site. <c>NewParentAreaId == null</c> 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.
/// </summary>
public record MoveAreaCommand(int AreaId, int? NewParentAreaId);
@@ -225,7 +225,8 @@ public class ManagementActor : ReceiveActor
// Area management — any-of [Designer, Deployer] (arch-review C6). // Area management — any-of [Designer, Deployer] (arch-review C6).
// Exposed both in the Designer tooling and inside the Central UI // Exposed both in the Designer tooling and inside the Central UI
// Deployment Topology workflow (RequireDeployment), so both roles qualify. // Deployment Topology workflow (RequireDeployment), so both roles qualify.
CreateAreaCommand or UpdateAreaCommand or DeleteAreaCommand => AreaManagers, CreateAreaCommand or UpdateAreaCommand or MoveAreaCommand
or DeleteAreaCommand => AreaManagers,
// Designer operations // Designer operations
CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
@@ -362,6 +363,7 @@ public class ManagementActor : ReceiveActor
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username), CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username),
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username), DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username),
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username), UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user.Username),
// Data Connections // Data Connections
ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd), ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd),
@@ -3116,6 +3118,22 @@ public class ManagementActor : ReceiveActor
return area; return area;
} }
/// <summary>
/// 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 <c>AreaService.MoveAreaAsync</c>. A null
/// <c>NewParentAreaId</c> moves the area to the site root. Failures surface
/// as the standard curated <see cref="ManagementCommandException"/> failure
/// response, matching the sibling folder/template move handlers. The service
/// writes the "Move" audit row itself, so this handler does not audit again.
/// </summary>
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, string user)
{
var svc = sp.GetRequiredService<AreaService>();
var result = await svc.MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ======================================================================== // ========================================================================
// Remote Query handlers // Remote Query handlers
// ======================================================================== // ========================================================================
@@ -151,6 +151,135 @@ public class CommandTreeTests
Assert.Contains("clear", 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 (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<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] [Fact]
public void TemplateList_HasSkipAndTakePagingOptions() public void TemplateList_HasSkipAndTakePagingOptions()
{ {
@@ -200,6 +329,13 @@ public class CommandTreeTests
[InlineData(typeof(ImportBundleCommand))] [InlineData(typeof(ImportBundleCommand))]
[InlineData(typeof(AddTemplateNativeAlarmSourceCommand))] [InlineData(typeof(AddTemplateNativeAlarmSourceCommand))]
[InlineData(typeof(SetInstanceNativeAlarmSourceOverrideCommand))] [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) public void CommandPayloadTypes_ResolveViaRegistry(Type commandType)
{ {
// GetCommandName throws ArgumentException for an unregistered type — the CLI // GetCommandName throws ArgumentException for an unregistered type — the CLI
@@ -3429,6 +3429,99 @@ public class ManagementActorTests : TestKit, IDisposable
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); ExpectMsg<ManagementUnauthorized>(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<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(resp);
}
[Fact]
public void MoveArea_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void MoveArea_ToNewParent_ReturnsSuccessAndReparentsArea()
{
_services.AddScoped<AreaService>();
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<CancellationToken>()).Returns(moving);
_templateRepo.GetAreaByIdAsync(2, Arg.Any<CancellationToken>()).Returns(target);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Area> { 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<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(2, moving.ParentAreaId);
}
[Fact]
public void MoveArea_WithNullParent_MovesToSiteRoot()
{
_services.AddScoped<AreaService>();
var moving = new Area("Line1") { Id = 3, SiteId = 1, ParentAreaId = 1 };
_templateRepo.GetAreaByIdAsync(3, Arg.Any<CancellationToken>()).Returns(moving);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Area> { new("Building1") { Id = 1, SiteId = 1 }, moving });
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(3, null), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Null(moving.ParentAreaId);
}
[Fact]
public void MoveArea_UnderOwnDescendant_ReturnsError()
{
_services.AddScoped<AreaService>();
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<CancellationToken>()).Returns(parent);
_templateRepo.GetAreaByIdAsync(2, Arg.Any<CancellationToken>()).Returns(child);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Area> { parent, child });
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Designer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("descendants", response.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MoveArea_AreaNotFound_ReturnsError()
{
_services.AddScoped<AreaService>();
_templateRepo.GetAreaByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Area?)null);
var actor = CreateActor();
actor.Tell(Envelope(new MoveAreaCommand(99, null), "Designer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("99", response.Error);
}
// ======================================================================== // ========================================================================
// Remote-query actor dispatch for the OPC UA design-time commands // Remote-query actor dispatch for the OPC UA design-time commands
// (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain // (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain
@@ -54,6 +54,9 @@ public class RequiredRoleMatrixTests
// ---- Area management: any-of [Designer, Deployer] --------------------------- // ---- Area management: any-of [Designer, Deployer] ---------------------------
["CreateArea"] = AreaManagers, ["CreateArea"] = AreaManagers,
["UpdateArea"] = 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, ["DeleteArea"] = AreaManagers,
// ---- Designer-only ---------------------------------------------------------- // ---- Designer-only ----------------------------------------------------------