Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs
T
Joseph Doherty 88638d774a 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.
2026-08-01 11:15:24 -04:00

213 lines
10 KiB
C#

using System.Runtime.CompilerServices;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Frozen authorization matrix (arch-review UA2). Every registered management command
/// MUST have an entry in <see cref="Expected"/> giving its required-roles set (any-of
/// semantics) or <c>null</c> when the command is available to any authenticated user.
/// A newly-added command with no entry fails <see cref="EveryRegisteredCommand_IsInTheFrozenTable"/>,
/// forcing a deliberate authorization decision instead of silently defaulting to the
/// read-only (any-authenticated) gate — the class of gap that produced C2/C4.
///
/// The table reflects ACTUAL current behavior of
/// <c>ManagementActor.GetRequiredRoles</c>; it is authored by hand from the switch and
/// cross-checked against <c>docs/requirements/Component-ManagementService.md</c>
/// §Authorization. Do not regenerate it mechanically from the source under test — that
/// would defeat the freeze.
/// </summary>
public class RequiredRoleMatrixTests
{
private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];
private static readonly string[] SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator];
private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
{
// ---- Administrator-only -----------------------------------------------------
["CreateSite"] = [Roles.Administrator],
["UpdateSite"] = [Roles.Administrator],
["DeleteSite"] = [Roles.Administrator],
["ListRoleMappings"] = [Roles.Administrator],
["CreateRoleMapping"] = [Roles.Administrator],
["UpdateRoleMapping"] = [Roles.Administrator],
["DeleteRoleMapping"] = [Roles.Administrator],
["ListApiKeys"] = [Roles.Administrator],
["CreateApiKey"] = [Roles.Administrator],
["DeleteApiKey"] = [Roles.Administrator],
["UpdateApiKey"] = [Roles.Administrator],
["SetApiKeyMethods"] = [Roles.Administrator],
["ListScopeRules"] = [Roles.Administrator],
["AddScopeRule"] = [Roles.Administrator],
["DeleteScopeRule"] = [Roles.Administrator],
["QueryAuditLog"] = [Roles.Administrator],
["UpdateSmtpConfig"] = [Roles.Administrator],
["UpdateSmsConfig"] = [Roles.Administrator],
["TrustServerCert"] = [Roles.Administrator],
["RemoveServerCert"] = [Roles.Administrator],
["ListServerCerts"] = [Roles.Administrator],
// Transport inbound bundle handling mutates cross-cutting config → Admin.
["PreviewBundle"] = [Roles.Administrator],
["ImportBundle"] = [Roles.Administrator],
// ---- 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 ----------------------------------------------------------
["CreateTemplate"] = [Roles.Designer],
["UpdateTemplate"] = [Roles.Designer],
["DeleteTemplate"] = [Roles.Designer],
["ValidateTemplate"] = [Roles.Designer],
["CreateExternalSystem"] = [Roles.Designer],
["UpdateExternalSystem"] = [Roles.Designer],
["DeleteExternalSystem"] = [Roles.Designer],
["CreateExternalSystemMethod"] = [Roles.Designer],
["UpdateExternalSystemMethod"] = [Roles.Designer],
["DeleteExternalSystemMethod"] = [Roles.Designer],
["CreateNotificationList"] = [Roles.Designer],
["UpdateNotificationList"] = [Roles.Designer],
["DeleteNotificationList"] = [Roles.Designer],
["CreateDataConnection"] = [Roles.Designer],
["UpdateDataConnection"] = [Roles.Designer],
["DeleteDataConnection"] = [Roles.Designer],
["MoveDataConnection"] = [Roles.Designer],
["AddTemplateAttribute"] = [Roles.Designer],
["UpdateTemplateAttribute"] = [Roles.Designer],
["DeleteTemplateAttribute"] = [Roles.Designer],
["AddTemplateAlarm"] = [Roles.Designer],
["UpdateTemplateAlarm"] = [Roles.Designer],
["DeleteTemplateAlarm"] = [Roles.Designer],
["AddTemplateNativeAlarmSource"] = [Roles.Designer],
["UpdateTemplateNativeAlarmSource"] = [Roles.Designer],
["DeleteTemplateNativeAlarmSource"] = [Roles.Designer],
["AddTemplateScript"] = [Roles.Designer],
["UpdateTemplateScript"] = [Roles.Designer],
["DeleteTemplateScript"] = [Roles.Designer],
["AddTemplateComposition"] = [Roles.Designer],
["DeleteTemplateComposition"] = [Roles.Designer],
["ResyncInheritedMembers"] = [Roles.Designer],
["CreateSharedScript"] = [Roles.Designer],
["UpdateSharedScript"] = [Roles.Designer],
["DeleteSharedScript"] = [Roles.Designer],
["CreateSharedSchema"] = [Roles.Designer],
["UpdateSharedSchema"] = [Roles.Designer],
["DeleteSharedSchema"] = [Roles.Designer],
["CreateDatabaseConnectionDef"] = [Roles.Designer],
["UpdateDatabaseConnectionDef"] = [Roles.Designer],
["DeleteDatabaseConnectionDef"] = [Roles.Designer],
["CreateApiMethod"] = [Roles.Designer],
["UpdateApiMethod"] = [Roles.Designer],
["DeleteApiMethod"] = [Roles.Designer],
["CreateTemplateFolder"] = [Roles.Designer],
["RenameTemplateFolder"] = [Roles.Designer],
["MoveTemplateFolder"] = [Roles.Designer],
["ReorderTemplateFolder"] = [Roles.Designer],
["DeleteTemplateFolder"] = [Roles.Designer],
["MoveTemplateToFolder"] = [Roles.Designer],
["BrowseNode"] = [Roles.Designer],
["SearchAddressSpace"] = [Roles.Designer],
["VerifyEndpoint"] = [Roles.Designer],
["ExportBundle"] = [Roles.Designer],
// ---- Deployer-only ----------------------------------------------------------
["CreateInstance"] = [Roles.Deployer],
["MgmtDeployInstance"] = [Roles.Deployer],
["MgmtEnableInstance"] = [Roles.Deployer],
["MgmtDisableInstance"] = [Roles.Deployer],
["MgmtDeleteInstance"] = [Roles.Deployer],
["SetConnectionBindings"] = [Roles.Deployer],
["SetInstanceOverrides"] = [Roles.Deployer],
["SetInstanceArea"] = [Roles.Deployer],
["SetInstanceAlarmOverride"] = [Roles.Deployer],
["DeleteInstanceAlarmOverride"] = [Roles.Deployer],
["SetInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
["SetInstanceNativeAlarmSourceOverrides"] = [Roles.Deployer],
["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
["GetDeploymentDiff"] = [Roles.Deployer],
["MgmtDeployArtifacts"] = [Roles.Deployer],
["QueryDeployments"] = [Roles.Deployer],
["RetryParkedMessage"] = [Roles.Deployer],
["DiscardParkedMessage"] = [Roles.Deployer],
["DebugSnapshot"] = [Roles.Deployer],
// ---- Two-person secured write ----------------------------------------------
["SubmitSecuredWrite"] = [Roles.Operator],
["ApproveSecuredWrite"] = [Roles.Verifier],
["RejectSecuredWrite"] = [Roles.Verifier],
["ListSecuredWrites"] = SecuredWriteReaders,
// ---- Read-only: any authenticated user (null gate) -------------------------
["ListTemplates"] = null,
["GetTemplate"] = null,
["GetResolvedTemplateMembers"] = null,
["ListTemplateFolders"] = null,
["ListTemplateNativeAlarmSources"] = null,
["ListInstances"] = null,
["GetInstance"] = null,
["ListInstanceAlarmOverrides"] = null,
["ListInstanceNativeAlarmSourceOverrides"] = null,
["ListSites"] = null,
["GetSite"] = null,
["ListAreas"] = null,
["ListDataConnections"] = null,
["GetDataConnection"] = null,
["ListExternalSystems"] = null,
["GetExternalSystem"] = null,
["ListExternalSystemMethods"] = null,
["GetExternalSystemMethod"] = null,
["ListNotificationLists"] = null,
["GetNotificationList"] = null,
["ListSmtpConfigs"] = null,
["ListSmsConfigs"] = null,
["ListSharedScripts"] = null,
["GetSharedScript"] = null,
["ListSharedSchemas"] = null,
["GetSharedSchema"] = null,
["ListDatabaseConnections"] = null,
["GetDatabaseConnection"] = null,
["ListApiMethods"] = null,
["GetApiMethod"] = null,
["GetHealthSummary"] = null,
["GetSiteHealth"] = null,
["QueryEventLogs"] = null,
["QueryParkedMessages"] = null,
["ReadTagValues"] = null,
// ResolveRoles is intentionally not dispatched (retired two-step flow); it
// falls through to the default null gate. Present here so the frozen table
// still covers the type reflected in the assembly.
["ResolveRoles"] = null,
};
public static IEnumerable<object[]> AllRegisteredCommands()
=> typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract)
.Select(t => new object[] { t });
[Theory, MemberData(nameof(AllRegisteredCommands))]
public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType)
{
var name = commandType.Name[..^"Command".Length];
Assert.True(Expected.ContainsKey(name),
$"Command '{name}' has no entry in the frozen authorization table — add one deliberately.");
var instance = RuntimeHelpers.GetUninitializedObject(commandType);
var actual = ManagementActor.GetRequiredRoles(instance);
var expected = Expected[name];
if (expected is null)
{
Assert.Null(actual);
}
else
{
Assert.NotNull(actual);
Assert.Equal(expected.OrderBy(x => x), actual!.OrderBy(x => x));
}
}
}