From 89f401d484bb346c68eea33ac630a354bda6b458 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:04:02 +0000 Subject: [PATCH 1/9] Initial plan From c91488bb0550fe5d3b49101f0c53e073d49d7899 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:10:51 +0000 Subject: [PATCH 2/9] Add step metadata catalog and update workflow schema Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- examples/workflows/ad-joiner-complete.psd1 | 42 ++-- examples/workflows/ad-leaver-offboarding.psd1 | 30 ++- .../workflows/ad-mover-department-change.psd1 | 35 ++-- .../workflows/joiner-ensureentitlement.psd1 | 14 +- examples/workflows/joiner-with-onfailure.psd1 | 14 +- .../Private/Get-IdleStepMetadataCatalog.ps1 | 181 ++++++++++++++++++ .../Private/Test-IdleWorkflowSchema.ps1 | 14 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 29 ++- .../Public/Get-IdleStepMetadataCatalog.ps1 | 67 +++++++ 9 files changed, 340 insertions(+), 86 deletions(-) create mode 100644 src/IdLE.Core/Private/Get-IdleStepMetadataCatalog.ps1 create mode 100644 src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 diff --git a/examples/workflows/ad-joiner-complete.psd1 b/examples/workflows/ad-joiner-complete.psd1 index 91bdd138..231fe447 100644 --- a/examples/workflows/ad-joiner-complete.psd1 +++ b/examples/workflows/ad-joiner-complete.psd1 @@ -3,9 +3,9 @@ LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Create AD user account' - Type = 'IdLE.Step.CreateIdentity' - With = @{ + Name = 'Create AD user account' + Type = 'IdLE.Step.CreateIdentity' + With = @{ IdentityKey = 'newuser' Attributes = @{ SamAccountName = 'newuser' @@ -21,34 +21,31 @@ # If omitted, defaults to 'Identity'. Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Create') }, @{ - Name = 'Set Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Set Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'newuser@contoso.local' Name = 'Department' Value = 'IT' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Set Title' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Set Title' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'newuser@contoso.local' Name = 'Title' Value = 'Software Engineer' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Grant base access group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Grant base access group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'newuser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -58,12 +55,11 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') }, @{ - Name = 'Grant IT department group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Grant IT department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'newuser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -73,17 +69,15 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') }, @{ - Name = 'Move to active users OU' - Type = 'IdLE.Step.MoveIdentity' - With = @{ + Name = 'Move to active users OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ IdentityKey = 'newuser@contoso.local' TargetContainer = 'OU=Active,OU=Users,DC=contoso,DC=local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Move') } ) } diff --git a/examples/workflows/ad-leaver-offboarding.psd1 b/examples/workflows/ad-leaver-offboarding.psd1 index 7c0969b7..4496ee04 100644 --- a/examples/workflows/ad-leaver-offboarding.psd1 +++ b/examples/workflows/ad-leaver-offboarding.psd1 @@ -3,46 +3,42 @@ LifecycleEvent = 'Leaver' Steps = @( @{ - Name = 'Disable user account' - Type = 'IdLE.Step.DisableIdentity' - With = @{ + Name = 'Disable user account' + Type = 'IdLE.Step.DisableIdentity' + With = @{ IdentityKey = 'leavinguser@contoso.local' # Provider alias references the provider hashtable key set by the host. # The alias name is flexible and chosen when injecting providers. Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Disable') }, @{ - Name = 'Update Description with termination date' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Update Description with termination date' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'leavinguser@contoso.local' Name = 'Description' Value = 'Terminated 2026-01-18' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Move to Leavers OU' - Type = 'IdLE.Step.MoveIdentity' - With = @{ + Name = 'Move to Leavers OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ IdentityKey = 'leavinguser@contoso.local' TargetContainer = 'OU=Leavers,OU=Disabled,DC=contoso,DC=local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Move') }, @{ - Name = 'Delete user account (opt-in required)' - Type = 'IdLE.Step.DeleteIdentity' - With = @{ + Name = 'Delete user account (opt-in required)' + Type = 'IdLE.Step.DeleteIdentity' + With = @{ IdentityKey = 'leavinguser@contoso.local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Delete') - Condition = @{ + Condition = @{ Exists = @{ Path = 'Input.AllowDelete' } diff --git a/examples/workflows/ad-mover-department-change.psd1 b/examples/workflows/ad-mover-department-change.psd1 index c6843099..63b5e318 100644 --- a/examples/workflows/ad-mover-department-change.psd1 +++ b/examples/workflows/ad-mover-department-change.psd1 @@ -3,9 +3,9 @@ LifecycleEvent = 'Mover' Steps = @( @{ - Name = 'Update Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Update Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'existinguser@contoso.local' Name = 'Department' Value = 'Sales' @@ -13,23 +13,21 @@ # Examples: 'Identity', 'SourceAD', 'TargetAD', 'SystemX', etc. Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Update Title' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Update Title' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'existinguser@contoso.local' Name = 'Title' Value = 'Sales Manager' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Revoke old IT department group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Revoke old IT department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'existinguser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -39,12 +37,11 @@ State = 'Absent' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') }, @{ - Name = 'Grant Sales department group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Grant Sales department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'existinguser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -54,17 +51,15 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') }, @{ - Name = 'Move to Sales OU' - Type = 'IdLE.Step.MoveIdentity' - With = @{ + Name = 'Move to Sales OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ IdentityKey = 'existinguser@contoso.local' TargetContainer = 'OU=Sales,OU=Users,DC=contoso,DC=local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Move') } ) } diff --git a/examples/workflows/joiner-ensureentitlement.psd1 b/examples/workflows/joiner-ensureentitlement.psd1 index 14347ad8..c1c7f8e2 100644 --- a/examples/workflows/joiner-ensureentitlement.psd1 +++ b/examples/workflows/joiner-ensureentitlement.psd1 @@ -3,16 +3,14 @@ LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT'; Provider = 'Identity' } - RequiresCapabilities = 'IdLE.Identity.Attribute.Ensure' + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT'; Provider = 'Identity' } }, @{ - Name = 'Assign demo group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' }; State = 'Present'; Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + Name = 'Assign demo group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' }; State = 'Present'; Provider = 'Identity' } } ) } diff --git a/examples/workflows/joiner-with-onfailure.psd1 b/examples/workflows/joiner-with-onfailure.psd1 index 660602b9..4cf8c4a0 100644 --- a/examples/workflows/joiner-with-onfailure.psd1 +++ b/examples/workflows/joiner-with-onfailure.psd1 @@ -10,20 +10,19 @@ With = @{ Message = 'Starting Joiner workflow with OnFailure handling' } } @{ - Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'user1' Name = 'Department' Value = 'IT' Provider = 'Identity' } - RequiresCapabilities = 'IdLE.Identity.Attribute.Ensure' } @{ - Name = 'Assign demo group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Assign demo group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1' Entitlement = @{ Kind = 'Group' @@ -33,7 +32,6 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') } ) diff --git a/src/IdLE.Core/Private/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Get-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..501bbcbe --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,181 @@ +function Get-IdleStepMetadataCatalog { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + # Metadata catalog maps Step.Type -> metadata hashtable. + # + # Trust boundary: + # - The metadata catalog is a host-provided extension point, similar to StepRegistry. + # - It is not loaded from workflow configuration. + # - Workflows are data-only and must not contain executable code. + # + # Security / secure defaults: + # - Only data-only metadata (hashtables with scalar/array values) are supported. + # - ScriptBlock values are intentionally rejected to avoid arbitrary code execution. + + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # Helper: Resolve a function from a module without requiring global command exports. + function Resolve-IdleModuleFunction { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $FunctionName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleName + ) + + # 1) Global discovery (optional; supports hosts that import modules globally) + $cmd = Get-Command -Name $FunctionName -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + return $cmd + } + + # 2) Module-scoped discovery (supports nested modules that are not globally exported) + $module = Get-Module -Name $ModuleName -All | Select-Object -First 1 + if ($null -eq $module) { + return $null + } + + if ($null -ne $module.ExportedCommands -and $module.ExportedCommands.ContainsKey($FunctionName)) { + return $module.ExportedCommands[$FunctionName] + } + + return $null + } + + # Helper: Validate and merge metadata from a hashtable source. + function Merge-IdleStepMetadata { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Target, + + [Parameter(Mandatory)] + [hashtable] $Source, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceName + ) + + foreach ($key in $Source.Keys) { + if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { + throw [System.ArgumentException]::new("$SourceName contains an empty step type key.", 'Providers') + } + + $value = $Source[$key] + + if ($value -isnot [hashtable]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$key' must be a hashtable (metadata object).", + 'Providers' + ) + } + + # Validate metadata shape (data-only, no ScriptBlocks). + foreach ($metaKey in $value.Keys) { + $metaValue = $value[$metaKey] + + if ($metaValue -is [scriptblock]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$key' contains a ScriptBlock in metadata key '$metaKey'. ScriptBlocks are not allowed (data-only boundary).", + 'Providers' + ) + } + + # Validate RequiredCapabilities if present. + if ($metaKey -eq 'RequiredCapabilities') { + if ($null -ne $metaValue) { + if ($metaValue -is [string]) { + # Single capability - validate format. + $cap = ([string]$metaValue).Trim() + if (-not [string]::IsNullOrWhiteSpace($cap) -and $cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$key' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", + 'Providers' + ) + } + } + elseif ($metaValue -is [System.Collections.IEnumerable] -and $metaValue -isnot [string]) { + # Array of capabilities - validate each. + foreach ($c in $metaValue) { + if ($null -ne $c) { + $cap = ([string]$c).Trim() + if (-not [string]::IsNullOrWhiteSpace($cap) -and $cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$key' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", + 'Providers' + ) + } + } + } + } + else { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$key' has invalid RequiredCapabilities value. Expected string or string array.", + 'Providers' + ) + } + } + } + } + + # Merge (host metadata overrides built-in). + $Target[[string]$key] = $value + } + } + + # 1) Copy host-provided StepMetadata (optional). + # We support two shapes for compatibility: + # - Providers.StepMetadata (hashtable) + # - Providers['StepMetadata'] (hashtable) + $hostMetadata = $null + + if ($null -ne $Providers) { + if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepMetadata')) { + $hostMetadata = $Providers['StepMetadata'] + } + elseif ($Providers.PSObject.Properties.Name -contains 'StepMetadata') { + $hostMetadata = $Providers.StepMetadata + } + } + + if ($null -ne $hostMetadata) { + if ($hostMetadata -isnot [hashtable]) { + throw [System.ArgumentException]::new('Providers.StepMetadata must be a hashtable that maps Step.Type to a metadata object (hashtable).', 'Providers') + } + + Merge-IdleStepMetadata -Target $catalog -Source $hostMetadata -SourceName 'Providers.StepMetadata' + } + + # 2) Register built-in step metadata if available. + # + # Built-in step metadata comes from first-party step packs (e.g. IdLE.Steps.Common). + # They may be loaded as nested modules by the IdLE meta module. + $builtInFunction = Resolve-IdleModuleFunction -FunctionName 'Get-IdleStepMetadataCatalog' -ModuleName 'IdLE.Steps.Common' + if ($null -ne $builtInFunction) { + $builtInMetadata = & $builtInFunction + if ($null -ne $builtInMetadata -and $builtInMetadata -is [hashtable]) { + # Built-in metadata is loaded first, then host metadata overrides it. + $tempCatalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + Merge-IdleStepMetadata -Target $tempCatalog -Source $builtInMetadata -SourceName 'Built-in StepMetadata' + + # Now apply host overrides. + foreach ($key in $tempCatalog.Keys) { + if (-not $catalog.ContainsKey($key)) { + $catalog[$key] = $tempCatalog[$key] + } + } + } + } + + return $catalog +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index a245bf93..b9e2445d 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -42,9 +42,12 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RequiresCapabilities') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') foreach ($k in $step.Keys) { - if ($allowedStepKeys -notcontains $k) { + if ($k -eq 'RequiresCapabilities') { + $errors.Add("$stepPath contains 'RequiresCapabilities' which is no longer supported. Step capabilities are now declared in step metadata. Remove 'RequiresCapabilities' from the workflow definition.") + } + elseif ($allowedStepKeys -notcontains $k) { $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") } } @@ -95,9 +98,12 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RequiresCapabilities') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') foreach ($k in $step.Keys) { - if ($allowedStepKeys -notcontains $k) { + if ($k -eq 'RequiresCapabilities') { + $errors.Add("$stepPath contains 'RequiresCapabilities' which is no longer supported. Step capabilities are now declared in step metadata. Remove 'RequiresCapabilities' from the workflow definition.") + } + elseif ($allowedStepKeys -notcontains $k) { $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") } } diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 1f8f7a7b..178a9312 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -425,7 +425,11 @@ function New-IdlePlanObject { [Parameter(Mandatory)] [ValidateNotNull()] - [object] $PlanningContext + [object] $PlanningContext, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $StepMetadataCatalog ) if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { @@ -487,9 +491,21 @@ function New-IdlePlanObject { } } + # Derive RequiresCapabilities from StepMetadataCatalog instead of workflow. $requiresCaps = @() - if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { - $requiresCaps = ConvertTo-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName + if ($StepMetadataCatalog.ContainsKey($stepType)) { + $metadata = $StepMetadataCatalog[$stepType] + if ($null -ne $metadata -and $metadata -is [hashtable] -and $metadata.ContainsKey('RequiredCapabilities')) { + $requiresCaps = ConvertTo-IdleRequiredCapabilities -Value $metadata['RequiredCapabilities'] -StepName $stepName + } + } + else { + # Step.Type exists in StepRegistry but has no metadata entry - fail fast. + throw [System.ArgumentException]::new( + ("Workflow step '{0}' references Step.Type '{1}' which has no StepMetadata entry. " + + "Host must provide Providers.StepMetadata['{1}'] = @{{ RequiredCapabilities = ... }}." -f $stepName, $stepType), + 'Providers' + ) } $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { @@ -572,13 +588,16 @@ function New-IdlePlanObject { Workflow = $workflow } + # Load StepMetadataCatalog (trusted extension point). + $stepMetadataCatalog = Get-IdleStepMetadataCatalog -Providers $Providers + $workflowOnFailureSteps = Get-IdleOptionalPropertyValue -Object $workflow -Name 'OnFailureSteps' # Normalize primary and OnFailure steps. # IMPORTANT: # ConvertTo-IdleWorkflowSteps may return an empty array that would otherwise collapse to $null on assignment. - $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext) - $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext) + $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog) + $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog) # Fail-fast capability validation (includes OnFailureSteps). $allStepsForCapabilities = @() diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..94fe3102 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,67 @@ +function Get-IdleStepMetadataCatalog { + <# + .SYNOPSIS + Returns metadata for built-in IdLE step types. + + .DESCRIPTION + This function provides a metadata catalog mapping Step.Type to metadata objects. + Each metadata object contains RequiredCapabilities (array of capability identifiers). + + The metadata is used during plan building to derive required provider capabilities + for each step, removing the need to declare RequiresCapabilities in workflow definitions. + + .OUTPUTS + Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). + + .EXAMPLE + $metadata = Get-IdleStepMetadataCatalog + $metadata['IdLE.Step.DisableIdentity'].RequiredCapabilities + # Returns: @('IdLE.Identity.Disable', 'IdLE.Identity.Read') + #> + [CmdletBinding()] + param() + + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # IdLE.Step.EmitEvent - no provider capabilities required (writes to event sink only) + $catalog['IdLE.Step.EmitEvent'] = @{ + RequiredCapabilities = @() + } + + # IdLE.Step.CreateIdentity - requires identity creation capability + $catalog['IdLE.Step.CreateIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Create') + } + + # IdLE.Step.DisableIdentity - requires identity disable capability + $catalog['IdLE.Step.DisableIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Disable', 'IdLE.Identity.Read') + } + + # IdLE.Step.EnableIdentity - requires identity enable capability + $catalog['IdLE.Step.EnableIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Enable', 'IdLE.Identity.Read') + } + + # IdLE.Step.DeleteIdentity - requires identity delete capability + $catalog['IdLE.Step.DeleteIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Delete') + } + + # IdLE.Step.MoveIdentity - requires identity move capability + $catalog['IdLE.Step.MoveIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Move') + } + + # IdLE.Step.EnsureAttribute - requires identity attribute ensure capability + $catalog['IdLE.Step.EnsureAttribute'] = @{ + RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') + } + + # IdLE.Step.EnsureEntitlement - requires entitlement list and grant/revoke capabilities + $catalog['IdLE.Step.EnsureEntitlement'] = @{ + RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + } + + return $catalog +} From 16e458db273d4e84d31353f8f01bd50ccb0facd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:18:34 +0000 Subject: [PATCH 3/9] Update tests for metadata-driven capabilities Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 1 + src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 1 + tests/New-IdlePlan.Capabilities.Tests.ps1 | 273 ++++++++++++++++--- 3 files changed, 230 insertions(+), 45 deletions(-) diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 0001cbee..f527d2df 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -8,6 +8,7 @@ PowerShellVersion = '7.0' FunctionsToExport = @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', 'Invoke-IdleStepEnsureEntitlement', diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 365d5dd9..5211dfbd 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -24,6 +24,7 @@ if (Test-Path -Path $PublicPath) { } Export-ModuleMember -Function @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', 'Invoke-IdleStepEnsureEntitlement', diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index da48845b..8303683d 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -5,12 +5,12 @@ BeforeAll { Describe 'New-IdlePlan - required provider capabilities' { - It 'fails fast when a step requires capabilities that no provider advertises' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities.psd1' + It 'rejects workflows containing RequiresCapabilities' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-legacy.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Capability Validation' + Name = 'Joiner - Legacy Workflow' LifecycleEvent = 'Joiner' Steps = @( @{ @@ -29,23 +29,57 @@ Describe 'New-IdlePlan - required provider capabilities' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'MissingCapabilities: IdLE\.Identity\.Disable' - $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' + $_.Exception.Message | Should -Match 'RequiresCapabilities' + $_.Exception.Message | Should -Match 'no longer supported' } } - It 'allows planning when a provider advertises the required capabilities' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities-ok.psd1' + It 'fails fast when a step type has no metadata entry' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-no-metadata.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Capability Validation OK' + Name = 'Joiner - Missing Metadata' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - RequiresCapabilities = @('IdLE.Identity.Disable') + Name = 'Unknown step' + Type = 'Custom.Step.Unknown' + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + # Provide a custom StepRegistry for the unknown step type + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'no StepMetadata entry' + $_.Exception.Message | Should -Match 'Custom.Step.Unknown' + } + } + + It 'derives capabilities from built-in step metadata' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-builtin.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Built-in Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' } ) } @@ -55,7 +89,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $provider = [pscustomobject]@{ Name = 'IdentityProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable') + return @('IdLE.Identity.Disable', 'IdLE.Identity.Read') } -Force $providers = @{ @@ -66,27 +100,21 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Be @('IdLE.Identity.Disable') + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Read' } - It 'fails fast when an OnFailure step requires capabilities that no provider advertises' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities.psd1' + It 'fails fast when required capabilities are missing' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-missing-caps.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - OnFailure Capability Validation' + Name = 'Joiner - Missing Capabilities' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Primary step' - Type = 'IdLE.Step.Primary' - } - ) - OnFailureSteps = @( - @{ - Name = 'Containment' - Type = 'IdLE.Step.Containment' - RequiresCapabilities = @('IdLE.Identity.Disable') + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' } ) } @@ -99,29 +127,69 @@ Describe 'New-IdlePlan - required provider capabilities' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'MissingCapabilities: IdLE\.Identity\.Disable' - $_.Exception.Message | Should -Match 'AffectedSteps: Containment' + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' } } - It 'includes OnFailureSteps capability requirements in successful planning' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities-ok.psd1' + It 'allows host metadata to override built-in metadata' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-override.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - OnFailure Capability Validation OK' + Name = 'Joiner - Override Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Override') + } -Force + + $providers = @{ + IdentityProvider = $provider + StepMetadata = @{ + 'IdLE.Step.DisableIdentity' = @{ + RequiredCapabilities = @('Custom.Capability.Override') + } + } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Be @('Custom.Capability.Override') + } + + It 'validates OnFailureSteps capabilities from metadata' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - OnFailure Metadata' LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'Primary step' - Type = 'IdLE.Step.Primary' + Type = 'IdLE.Step.EmitEvent' + With = @{ Message = 'Primary' } } ) OnFailureSteps = @( @{ - Name = 'Containment' - Type = 'IdLE.Step.Containment' - RequiresCapabilities = @('IdLE.Identity.Disable') + Name = 'Containment' + Type = 'IdLE.Step.DisableIdentity' } ) } @@ -131,7 +199,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $provider = [pscustomobject]@{ Name = 'IdentityProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable') + return @('IdLE.Identity.Disable', 'IdLE.Identity.Read') } -Force $providers = @{ @@ -142,22 +210,22 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.OnFailureSteps.Count | Should -Be 1 - $plan.OnFailureSteps[0].RequiresCapabilities | Should -Be @('IdLE.Identity.Disable') + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Read' } - It 'validates entitlement capabilities for EnsureEntitlement steps' { + It 'validates entitlement capabilities from metadata' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-entitlements.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Entitlement Capability Validation' + Name = 'Joiner - Entitlement Metadata' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Ensure group membership' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group' }; State = 'Present' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + Name = 'Ensure group membership' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group' }; State = 'Present' } } ) } @@ -170,12 +238,13 @@ Describe 'New-IdlePlan - required provider capabilities' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'MissingCapabilities: IdLE\.Entitlement\.Grant, IdLE\.Entitlement\.List' + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Entitlement' } $provider = [pscustomobject]@{ Name = 'EntProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') } -Force $providers = @{ Entitlement = $provider } @@ -184,6 +253,120 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Be @('IdLE.Entitlement.Grant', 'IdLE.Entitlement.List') + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.List' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Grant' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Revoke' + } + + It 'rejects metadata with ScriptBlock values' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-scriptblock.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - ScriptBlock Test' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = @{ + RequiredCapabilities = { 'Dynamic.Capability' } + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'ScriptBlock' + } + } + + It 'rejects invalid metadata shapes' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-invalid.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Invalid Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = 'not-a-hashtable' + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'must be a hashtable' + } + } + + It 'rejects invalid capability identifiers' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-invalid-cap.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Invalid Capability' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = @{ + RequiredCapabilities = @('Invalid Capability With Spaces') + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'invalid capability' + } } } From 4cdf24d2880373a0b771923e8fb7055807dbc6a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:46:07 +0000 Subject: [PATCH 4/9] Fix metadata catalog function naming conflict Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- ...s1 => Resolve-IdleStepMetadataCatalog.ps1} | 46 +++++++++---------- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) rename src/IdLE.Core/Private/{Get-IdleStepMetadataCatalog.ps1 => Resolve-IdleStepMetadataCatalog.ps1} (90%) diff --git a/src/IdLE.Core/Private/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 similarity index 90% rename from src/IdLE.Core/Private/Get-IdleStepMetadataCatalog.ps1 rename to src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 501bbcbe..e2bdf1b5 100644 --- a/src/IdLE.Core/Private/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -1,4 +1,4 @@ -function Get-IdleStepMetadataCatalog { +function Resolve-IdleStepMetadataCatalog { [CmdletBinding()] param( [Parameter()] @@ -133,7 +133,28 @@ function Get-IdleStepMetadataCatalog { } } - # 1) Copy host-provided StepMetadata (optional). + # 1) Register built-in step metadata if available. + # + # Built-in step metadata comes from first-party step packs (e.g. IdLE.Steps.Common). + # They may be loaded as nested modules by the IdLE meta module. + $builtInFunction = Resolve-IdleModuleFunction -FunctionName 'Get-IdleStepMetadataCatalog' -ModuleName 'IdLE.Steps.Common' + if ($null -ne $builtInFunction) { + # Ensure we're calling the function from IdLE.Steps.Common, not recursing into ourselves. + # Check both ModuleName and Source properties as they may vary. + $functionModule = $builtInFunction.ModuleName + if ($null -eq $functionModule) { + $functionModule = $builtInFunction.Source + } + + if ($functionModule -eq 'IdLE.Steps.Common') { + $builtInMetadata = & $builtInFunction + if ($null -ne $builtInMetadata -and $builtInMetadata -is [hashtable]) { + Merge-IdleStepMetadata -Target $catalog -Source $builtInMetadata -SourceName 'Built-in StepMetadata' + } + } + } + + # 2) Copy host-provided StepMetadata (optional) - this overrides built-in. # We support two shapes for compatibility: # - Providers.StepMetadata (hashtable) # - Providers['StepMetadata'] (hashtable) @@ -156,26 +177,5 @@ function Get-IdleStepMetadataCatalog { Merge-IdleStepMetadata -Target $catalog -Source $hostMetadata -SourceName 'Providers.StepMetadata' } - # 2) Register built-in step metadata if available. - # - # Built-in step metadata comes from first-party step packs (e.g. IdLE.Steps.Common). - # They may be loaded as nested modules by the IdLE meta module. - $builtInFunction = Resolve-IdleModuleFunction -FunctionName 'Get-IdleStepMetadataCatalog' -ModuleName 'IdLE.Steps.Common' - if ($null -ne $builtInFunction) { - $builtInMetadata = & $builtInFunction - if ($null -ne $builtInMetadata -and $builtInMetadata -is [hashtable]) { - # Built-in metadata is loaded first, then host metadata overrides it. - $tempCatalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - Merge-IdleStepMetadata -Target $tempCatalog -Source $builtInMetadata -SourceName 'Built-in StepMetadata' - - # Now apply host overrides. - foreach ($key in $tempCatalog.Keys) { - if (-not $catalog.ContainsKey($key)) { - $catalog[$key] = $tempCatalog[$key] - } - } - } - } - return $catalog } diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 178a9312..a54f11d4 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -589,7 +589,7 @@ function New-IdlePlanObject { } # Load StepMetadataCatalog (trusted extension point). - $stepMetadataCatalog = Get-IdleStepMetadataCatalog -Providers $Providers + $stepMetadataCatalog = Resolve-IdleStepMetadataCatalog -Providers $Providers $workflowOnFailureSteps = Get-IdleOptionalPropertyValue -Object $workflow -Name 'OnFailureSteps' From f7142efdb44cf976de6e6e88f1be3a3c76f00e7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:53:59 +0000 Subject: [PATCH 5/9] test: Add StepMetadata for custom step types in test files - Added New-IdleTestStepMetadata helper function to _testHelpers.ps1 - Updated Export-IdlePlan.Tests.ps1 to add metadata for 'EnsureMailbox' - Updated Invoke-IdlePlan.Retry.Tests.ps1 to add metadata for 'IdLE.Step.Transient' and 'IdLE.Step.NonTransient' - Updated Invoke-IdlePlan.Tests.ps1 to add metadata for multiple step types - Updated ModuleSurface.Tests.ps1 to add metadata for 'IdLE.Step.Primary' and 'IdLE.Step.Containment' - Updated New-IdlePlan.Tests.ps1 to add metadata for step types in OnFailureSteps tests - Fixed instances where New-IdlePlan was called without -Providers parameter All affected tests now pass. --- tests/Export-IdlePlan.Tests.ps1 | 10 +++++- tests/Invoke-IdlePlan.Retry.Tests.ps1 | 11 ++++-- tests/Invoke-IdlePlan.Tests.ps1 | 38 ++++++++++++++++----- tests/ModuleSurface.Tests.ps1 | 4 ++- tests/New-IdlePlan.Tests.ps1 | 21 ++++++++++-- tests/_testHelpers.ps1 | 49 +++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 14 deletions(-) diff --git a/tests/Export-IdlePlan.Tests.ps1 b/tests/Export-IdlePlan.Tests.ps1 index 13f59240..d8bc5c52 100644 --- a/tests/Export-IdlePlan.Tests.ps1 +++ b/tests/Export-IdlePlan.Tests.ps1 @@ -38,7 +38,15 @@ Describe 'Export-IdlePlan' { -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` -DesiredState ([ordered]@{ department = 'IT' }) - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'EnsureMailbox' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('EnsureMailbox') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $expectedPath = Join-Path $PSScriptRoot 'fixtures/plan-export/expected/plan-export.json' $expectedJson = Get-Content -Path $expectedPath -Raw -Encoding utf8 diff --git a/tests/Invoke-IdlePlan.Retry.Tests.ps1 b/tests/Invoke-IdlePlan.Retry.Tests.ps1 index 46a92f2f..229e70e5 100644 --- a/tests/Invoke-IdlePlan.Retry.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Retry.Tests.ps1 @@ -4,6 +4,9 @@ BeforeDiscovery { } BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + # Create a dedicated, ephemeral test module that exports the step handlers. # This avoids global scope pollution while ensuring the engine can resolve # handler names deterministically via module-qualified command names. @@ -108,13 +111,15 @@ Describe 'Invoke-IdlePlan - safe retries for transient failures (fail-fast)' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Transient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestTransientStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Transient') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -142,13 +147,15 @@ Describe 'Invoke-IdlePlan - safe retries for transient failures (fail-fast)' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.NonTransient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestNonTransientStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NonTransient') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 2402efc0..dab5cf41 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -162,14 +162,16 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.EnsureAttributes') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -211,13 +213,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $sinkEvents = [System.Collections.Generic.List[object]]::new() $sinkObject = [pscustomobject]@{} @@ -247,13 +251,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $sink = { param($e) } { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw @@ -272,13 +278,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = { param($Context, $Step) } } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw } @@ -302,6 +310,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.EmitEvent') } $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -328,7 +337,6 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ @@ -336,7 +344,10 @@ Describe 'Invoke-IdlePlan' { 'IdLE.Step.NeverRuns' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.NeverRuns', 'IdLE.Step.OnFailure1') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -376,7 +387,6 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ @@ -384,7 +394,10 @@ Describe 'Invoke-IdlePlan' { 'IdLE.Step.OnFailureFail' = 'Invoke-IdleTestFailStep' 'IdLE.Step.OnFailureOk' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.OnFailureFail', 'IdLE.Step.OnFailureOk') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -415,14 +428,16 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Ok' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Ok', 'IdLE.Step.OnFailure1') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -525,6 +540,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') } { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*AuthSessionBroker*' @@ -557,6 +573,7 @@ Describe 'Invoke-IdlePlan' { 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' 'IdLE.Step.Noop' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession', 'IdLE.Step.Noop') } # Should not throw because the AcquireAuthSession step is NotApplicable @@ -604,6 +621,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') AuthSessionBroker = $broker } @@ -654,6 +672,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') AuthSessionBroker = $broker } @@ -703,6 +722,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') AuthSessionBroker = $broker } @@ -728,13 +748,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Legacy' = 'Invoke-IdleTestLegacyStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Legacy') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 8c05b5be..0e0f7ee3 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -82,14 +82,16 @@ Describe 'Module manifests and public surface' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Primary' = 'Invoke-IdleSurfaceTestNoopStep' 'IdLE.Step.Containment' = 'Invoke-IdleSurfaceTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Primary', 'IdLE.Step.Containment') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers diff --git a/tests/New-IdlePlan.Tests.ps1 b/tests/New-IdlePlan.Tests.ps1 index ddcea388..66c27ef2 100644 --- a/tests/New-IdlePlan.Tests.ps1 +++ b/tests/New-IdlePlan.Tests.ps1 @@ -19,7 +19,15 @@ Describe 'New-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.EnsureAttributes') + } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $plan | Should -Not -BeNullOrEmpty $plan.PSTypeNames | Should -Contain 'IdLE.Plan' @@ -69,7 +77,16 @@ Describe 'New-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.NeverApplicable' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.Containment', 'IdLE.Step.NeverApplicable') + } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $plan | Should -Not -BeNullOrEmpty $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index cf9069d4..df12357d 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -44,3 +44,52 @@ function Get-ModuleManifestPaths { Select-Object -ExpandProperty FullName } +function New-IdleTestStepMetadata { + <# + .SYNOPSIS + Creates test step metadata for custom step types used in tests. + + .DESCRIPTION + Helper function to create StepMetadata entries for test-specific step types. + By default, creates metadata with no required capabilities. + + .PARAMETER StepTypes + Array of step type names to create metadata for. + + .PARAMETER RequiredCapabilities + Hashtable mapping step types to their required capabilities. + + .EXAMPLE + $metadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.Primary') + + .EXAMPLE + $metadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Custom') -RequiredCapabilities @{ + 'IdLE.Step.Custom' = @('Custom.Capability') + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]] $StepTypes, + + [Parameter()] + [hashtable] $RequiredCapabilities = @{} + ) + + $metadata = @{} + foreach ($stepType in $StepTypes) { + $caps = if ($RequiredCapabilities.ContainsKey($stepType)) { + $RequiredCapabilities[$stepType] + } + else { + @() + } + + $metadata[$stepType] = @{ + RequiredCapabilities = $caps + } + } + + return $metadata +} + From 54cbdf2a7b187b78b02a09bb6e95bd75f10528c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:43:44 +0000 Subject: [PATCH 6/9] Refactor: reduce nesting and remove duplication in validation code Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Resolve-IdleStepMetadataCatalog.ps1 | 151 +++++++++++------- .../Private/Test-IdleWorkflowSchema.ps1 | 46 +++--- 2 files changed, 123 insertions(+), 74 deletions(-) diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index e2bdf1b5..899f4fd8 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -51,6 +51,72 @@ function Resolve-IdleStepMetadataCatalog { return $null } + # Helper: Validate a single capability identifier format. + function Test-IdleCapabilityIdentifier { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Capability, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [string] $SourceName + ) + + $cap = $Capability.Trim() + if ([string]::IsNullOrWhiteSpace($cap)) { + return + } + + if ($cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", + 'Providers' + ) + } + } + + # Helper: Validate RequiredCapabilities value. + function Test-IdleRequiredCapabilities { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [string] $SourceName + ) + + if ($null -eq $Value) { + return + } + + if ($Value -is [string]) { + Test-IdleCapabilityIdentifier -Capability $Value -StepType $StepType -SourceName $SourceName + return + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + foreach ($c in $Value) { + if ($null -ne $c) { + Test-IdleCapabilityIdentifier -Capability ([string]$c) -StepType $StepType -SourceName $SourceName + } + } + return + } + + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", + 'Providers' + ) + } + # Helper: Validate and merge metadata from a hashtable source. function Merge-IdleStepMetadata { [CmdletBinding()] @@ -91,40 +157,8 @@ function Resolve-IdleStepMetadataCatalog { ) } - # Validate RequiredCapabilities if present. if ($metaKey -eq 'RequiredCapabilities') { - if ($null -ne $metaValue) { - if ($metaValue -is [string]) { - # Single capability - validate format. - $cap = ([string]$metaValue).Trim() - if (-not [string]::IsNullOrWhiteSpace($cap) -and $cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$key' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", - 'Providers' - ) - } - } - elseif ($metaValue -is [System.Collections.IEnumerable] -and $metaValue -isnot [string]) { - # Array of capabilities - validate each. - foreach ($c in $metaValue) { - if ($null -ne $c) { - $cap = ([string]$c).Trim() - if (-not [string]::IsNullOrWhiteSpace($cap) -and $cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$key' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", - 'Providers' - ) - } - } - } - } - else { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$key' has invalid RequiredCapabilities value. Expected string or string array.", - 'Providers' - ) - } - } + Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceName } } @@ -133,18 +167,35 @@ function Resolve-IdleStepMetadataCatalog { } } + # Helper: Get host-provided StepMetadata if available. + function Get-IdleHostStepMetadata { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + if ($null -eq $Providers) { + return $null + } + + if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepMetadata')) { + return $Providers['StepMetadata'] + } + + if ($Providers.PSObject.Properties.Name -contains 'StepMetadata') { + return $Providers.StepMetadata + } + + return $null + } + # 1) Register built-in step metadata if available. - # - # Built-in step metadata comes from first-party step packs (e.g. IdLE.Steps.Common). - # They may be loaded as nested modules by the IdLE meta module. $builtInFunction = Resolve-IdleModuleFunction -FunctionName 'Get-IdleStepMetadataCatalog' -ModuleName 'IdLE.Steps.Common' + if ($null -ne $builtInFunction) { - # Ensure we're calling the function from IdLE.Steps.Common, not recursing into ourselves. - # Check both ModuleName and Source properties as they may vary. - $functionModule = $builtInFunction.ModuleName - if ($null -eq $functionModule) { - $functionModule = $builtInFunction.Source - } + $functionModule = $builtInFunction.ModuleName ?? $builtInFunction.Source if ($functionModule -eq 'IdLE.Steps.Common') { $builtInMetadata = & $builtInFunction @@ -154,20 +205,8 @@ function Resolve-IdleStepMetadataCatalog { } } - # 2) Copy host-provided StepMetadata (optional) - this overrides built-in. - # We support two shapes for compatibility: - # - Providers.StepMetadata (hashtable) - # - Providers['StepMetadata'] (hashtable) - $hostMetadata = $null - - if ($null -ne $Providers) { - if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepMetadata')) { - $hostMetadata = $Providers['StepMetadata'] - } - elseif ($Providers.PSObject.Properties.Name -contains 'StepMetadata') { - $hostMetadata = $Providers.StepMetadata - } - } + # 2) Merge host-provided StepMetadata (optional) - this overrides built-in. + $hostMetadata = Get-IdleHostStepMetadata -Providers $Providers if ($null -ne $hostMetadata) { if ($hostMetadata -isnot [hashtable]) { diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index b9e2445d..d7870f7b 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -8,6 +8,32 @@ function Test-IdleWorkflowSchema { # Strict validation: collect all schema violations and return them as a list. $errors = [System.Collections.Generic.List[string]]::new() + # Helper: Validate step keys and detect disallowed keys. + function Test-IdleWorkflowStepKeys { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Step, + + [Parameter(Mandatory)] + [string] $StepPath, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') + foreach ($k in $Step.Keys) { + if ($k -eq 'RequiresCapabilities') { + $ErrorList.Add("$StepPath contains 'RequiresCapabilities' which is not allowed. Step capabilities are declared in step metadata.") + } + elseif ($allowedStepKeys -notcontains $k) { + $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") + } + } + } + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { @@ -42,15 +68,7 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') - foreach ($k in $step.Keys) { - if ($k -eq 'RequiresCapabilities') { - $errors.Add("$stepPath contains 'RequiresCapabilities' which is no longer supported. Step capabilities are now declared in step metadata. Remove 'RequiresCapabilities' from the workflow definition.") - } - elseif ($allowedStepKeys -notcontains $k) { - $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") - } - } + Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $errors if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { $errors.Add("Missing or empty required key '$stepPath.Name'.") @@ -98,15 +116,7 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') - foreach ($k in $step.Keys) { - if ($k -eq 'RequiresCapabilities') { - $errors.Add("$stepPath contains 'RequiresCapabilities' which is no longer supported. Step capabilities are now declared in step metadata. Remove 'RequiresCapabilities' from the workflow definition.") - } - elseif ($allowedStepKeys -notcontains $k) { - $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") - } - } + Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $errors if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { $errors.Add("Missing or empty required key '$stepPath.Name'.") From 9a53348e0df20e5d6dd8788a8823e61eabea1192 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:45:33 +0000 Subject: [PATCH 7/9] Update test to match simplified error message Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/New-IdlePlan.Capabilities.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index 8303683d..b5ebe379 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -30,7 +30,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } catch { $_.Exception.Message | Should -Match 'RequiresCapabilities' - $_.Exception.Message | Should -Match 'no longer supported' + $_.Exception.Message | Should -Match 'not allowed' } } From 86e5f72c57f4fffc303dd10da284614a0bbb5b8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:19:34 +0000 Subject: [PATCH 8/9] Remove RequiresCapabilities validation and move test fixtures Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Test-IdleWorkflowSchema.ps1 | 5 +- tests/New-IdlePlan.Capabilities.Tests.ps1 | 173 +----------------- tests/fixtures/workflows/joiner-builtin.psd1 | 10 + .../workflows/joiner-entitlements.psd1 | 11 ++ .../workflows/joiner-invalid-cap.psd1 | 10 + tests/fixtures/workflows/joiner-invalid.psd1 | 10 + .../workflows/joiner-missing-caps.psd1 | 10 + .../workflows/joiner-no-metadata.psd1 | 10 + .../fixtures/workflows/joiner-onfailure.psd1 | 17 ++ tests/fixtures/workflows/joiner-override.psd1 | 10 + .../workflows/joiner-scriptblock.psd1 | 10 + 11 files changed, 109 insertions(+), 167 deletions(-) create mode 100644 tests/fixtures/workflows/joiner-builtin.psd1 create mode 100644 tests/fixtures/workflows/joiner-entitlements.psd1 create mode 100644 tests/fixtures/workflows/joiner-invalid-cap.psd1 create mode 100644 tests/fixtures/workflows/joiner-invalid.psd1 create mode 100644 tests/fixtures/workflows/joiner-missing-caps.psd1 create mode 100644 tests/fixtures/workflows/joiner-no-metadata.psd1 create mode 100644 tests/fixtures/workflows/joiner-onfailure.psd1 create mode 100644 tests/fixtures/workflows/joiner-override.psd1 create mode 100644 tests/fixtures/workflows/joiner-scriptblock.psd1 diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index d7870f7b..2365cde5 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -25,10 +25,7 @@ function Test-IdleWorkflowSchema { $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') foreach ($k in $Step.Keys) { - if ($k -eq 'RequiresCapabilities') { - $ErrorList.Add("$StepPath contains 'RequiresCapabilities' which is not allowed. Step capabilities are declared in step metadata.") - } - elseif ($allowedStepKeys -notcontains $k) { + if ($allowedStepKeys -notcontains $k) { $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") } } diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index b5ebe379..0fab4e4e 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -1,54 +1,13 @@ BeforeAll { . (Join-Path $PSScriptRoot '_testHelpers.ps1') Import-IdleTestModule + $fixturesPath = Join-Path $PSScriptRoot 'fixtures/workflows' } Describe 'New-IdlePlan - required provider capabilities' { - It 'rejects workflows containing RequiresCapabilities' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-legacy.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Legacy Workflow' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - RequiresCapabilities = @('IdLE.Identity.Disable') - } - ) -} -'@ - - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'RequiresCapabilities' - $_.Exception.Message | Should -Match 'not allowed' - } - } - It 'fails fast when a step type has no metadata entry' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-no-metadata.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Missing Metadata' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Unknown step' - Type = 'Custom.Step.Unknown' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -70,20 +29,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'derives capabilities from built-in step metadata' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-builtin.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Built-in Metadata' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -105,20 +51,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'fails fast when required capabilities are missing' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-missing-caps.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Missing Capabilities' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-missing-caps.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -134,20 +67,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'allows host metadata to override built-in metadata' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-override.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Override Metadata' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-override.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -173,27 +93,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'validates OnFailureSteps capabilities from metadata' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - OnFailure Metadata' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Primary step' - Type = 'IdLE.Step.EmitEvent' - With = @{ Message = 'Primary' } - } - ) - OnFailureSteps = @( - @{ - Name = 'Containment' - Type = 'IdLE.Step.DisableIdentity' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-onfailure.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -215,21 +115,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'validates entitlement capabilities from metadata' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-entitlements.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Entitlement Metadata' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Ensure group membership' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group' }; State = 'Present' } - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-entitlements.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -259,20 +145,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'rejects metadata with ScriptBlock values' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-scriptblock.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - ScriptBlock Test' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Test step' - Type = 'Custom.Step.Test' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-scriptblock.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -297,20 +170,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'rejects invalid metadata shapes' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-invalid.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Invalid Metadata' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Test step' - Type = 'Custom.Step.Test' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-invalid.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -333,20 +193,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } It 'rejects invalid capability identifiers' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-invalid-cap.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Invalid Capability' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Test step' - Type = 'Custom.Step.Test' - } - ) -} -'@ + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-invalid-cap.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' diff --git a/tests/fixtures/workflows/joiner-builtin.psd1 b/tests/fixtures/workflows/joiner-builtin.psd1 new file mode 100644 index 00000000..9bd614dd --- /dev/null +++ b/tests/fixtures/workflows/joiner-builtin.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Built-in Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-entitlements.psd1 b/tests/fixtures/workflows/joiner-entitlements.psd1 new file mode 100644 index 00000000..226f0457 --- /dev/null +++ b/tests/fixtures/workflows/joiner-entitlements.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Joiner - Entitlement Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Ensure group membership' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group' }; State = 'Present' } + } + ) +} diff --git a/tests/fixtures/workflows/joiner-invalid-cap.psd1 b/tests/fixtures/workflows/joiner-invalid-cap.psd1 new file mode 100644 index 00000000..cc353561 --- /dev/null +++ b/tests/fixtures/workflows/joiner-invalid-cap.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Invalid Capability' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-invalid.psd1 b/tests/fixtures/workflows/joiner-invalid.psd1 new file mode 100644 index 00000000..fefeefaf --- /dev/null +++ b/tests/fixtures/workflows/joiner-invalid.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Invalid Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-missing-caps.psd1 b/tests/fixtures/workflows/joiner-missing-caps.psd1 new file mode 100644 index 00000000..51a45f1c --- /dev/null +++ b/tests/fixtures/workflows/joiner-missing-caps.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Missing Capabilities' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-no-metadata.psd1 b/tests/fixtures/workflows/joiner-no-metadata.psd1 new file mode 100644 index 00000000..c80faa7f --- /dev/null +++ b/tests/fixtures/workflows/joiner-no-metadata.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Missing Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Unknown step' + Type = 'Custom.Step.Unknown' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-onfailure.psd1 b/tests/fixtures/workflows/joiner-onfailure.psd1 new file mode 100644 index 00000000..65b17a8e --- /dev/null +++ b/tests/fixtures/workflows/joiner-onfailure.psd1 @@ -0,0 +1,17 @@ +@{ + Name = 'Joiner - OnFailure Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Primary step' + Type = 'IdLE.Step.EmitEvent' + With = @{ Message = 'Primary' } + } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-override.psd1 b/tests/fixtures/workflows/joiner-override.psd1 new file mode 100644 index 00000000..f6a1d240 --- /dev/null +++ b/tests/fixtures/workflows/joiner-override.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Override Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-scriptblock.psd1 b/tests/fixtures/workflows/joiner-scriptblock.psd1 new file mode 100644 index 00000000..2373222e --- /dev/null +++ b/tests/fixtures/workflows/joiner-scriptblock.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - ScriptBlock Test' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} From 467b55118607244282b0e50673b2355944453e8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:51:49 +0000 Subject: [PATCH 9/9] Address code review feedback: improve validation and fix contracts Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Resolve-IdleStepMetadataCatalog.ps1 | 85 ++++++++++++++----- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 2 +- .../Public/Get-IdleStepMetadataCatalog.ps1 | 4 +- tests/New-IdlePlan.Capabilities.Tests.ps1 | 6 +- 4 files changed, 71 insertions(+), 26 deletions(-) diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 899f4fd8..37b20ffc 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -32,20 +32,18 @@ function Resolve-IdleStepMetadataCatalog { [string] $ModuleName ) - # 1) Global discovery (optional; supports hosts that import modules globally) - $cmd = Get-Command -Name $FunctionName -ErrorAction SilentlyContinue - if ($null -ne $cmd) { - return $cmd - } - - # 2) Module-scoped discovery (supports nested modules that are not globally exported) + # 1) Module-scoped discovery (prefer the specified module to avoid name shadowing) $module = Get-Module -Name $ModuleName -All | Select-Object -First 1 - if ($null -eq $module) { - return $null + if ($null -ne $module -and + $null -ne $module.ExportedCommands -and + $module.ExportedCommands.ContainsKey($FunctionName)) { + return $module.ExportedCommands[$FunctionName] } - if ($null -ne $module.ExportedCommands -and $module.ExportedCommands.ContainsKey($FunctionName)) { - return $module.ExportedCommands[$FunctionName] + # 2) Global discovery (fallback; supports hosts that import modules globally) + $cmd = Get-Command -Name $FunctionName -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + return $cmd } return $null @@ -102,11 +100,28 @@ function Resolve-IdleStepMetadataCatalog { return } - if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + # Explicitly reject dictionary/hashtable values; they are not valid capability lists. + if ($Value -is [System.Collections.IDictionary] -or $Value -is [hashtable]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", + 'Providers' + ) + } + + if ($Value -is [System.Collections.IEnumerable]) { foreach ($c in $Value) { - if ($null -ne $c) { - Test-IdleCapabilityIdentifier -Capability ([string]$c) -StepType $StepType -SourceName $SourceName + if ($null -eq $c) { + continue } + + if ($c -isnot [string]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", + 'Providers' + ) + } + + Test-IdleCapabilityIdentifier -Capability $c -StepType $StepType -SourceName $SourceName } return } @@ -147,15 +162,47 @@ function Resolve-IdleStepMetadataCatalog { } # Validate metadata shape (data-only, no ScriptBlocks). - foreach ($metaKey in $value.Keys) { - $metaValue = $value[$metaKey] - - if ($metaValue -is [scriptblock]) { + # Recursively check for ScriptBlocks in the entire metadata tree. + function Test-IdleMetadataForScriptBlocks { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $Path + ) + + if ($null -eq $Value) { + return + } + + if ($Value -is [scriptblock]) { throw [System.ArgumentException]::new( - "$SourceName entry for step type '$key' contains a ScriptBlock in metadata key '$metaKey'. ScriptBlocks are not allowed (data-only boundary).", + "$SourceName entry for step type '$key' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", 'Providers' ) } + + if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary]) { + foreach ($k in $Value.Keys) { + Test-IdleMetadataForScriptBlocks -Value $Value[$k] -Path "$Path.$k" + } + } + elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $index = 0 + foreach ($item in $Value) { + Test-IdleMetadataForScriptBlocks -Value $item -Path "$Path[$index]" + $index++ + } + } + } + + foreach ($metaKey in $value.Keys) { + $metaValue = $value[$metaKey] + + # Recursively validate no ScriptBlocks anywhere in metadata + Test-IdleMetadataForScriptBlocks -Value $metaValue -Path $metaKey if ($metaKey -eq 'RequiredCapabilities') { Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceName diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index a54f11d4..67f72b86 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -500,7 +500,7 @@ function New-IdlePlanObject { } } else { - # Step.Type exists in StepRegistry but has no metadata entry - fail fast. + # Workflow references a Step.Type for which no StepMetadata entry is available - fail fast. throw [System.ArgumentException]::new( ("Workflow step '{0}' references Step.Type '{1}' which has no StepMetadata entry. " + "Host must provide Providers.StepMetadata['{1}'] = @{{ RequiredCapabilities = ... }}." -f $stepName, $stepType), diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 94fe3102..2f266676 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -35,12 +35,12 @@ function Get-IdleStepMetadataCatalog { # IdLE.Step.DisableIdentity - requires identity disable capability $catalog['IdLE.Step.DisableIdentity'] = @{ - RequiredCapabilities = @('IdLE.Identity.Disable', 'IdLE.Identity.Read') + RequiredCapabilities = @('IdLE.Identity.Disable') } # IdLE.Step.EnableIdentity - requires identity enable capability $catalog['IdLE.Step.EnableIdentity'] = @{ - RequiredCapabilities = @('IdLE.Identity.Enable', 'IdLE.Identity.Read') + RequiredCapabilities = @('IdLE.Identity.Enable') } # IdLE.Step.DeleteIdentity - requires identity delete capability diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index 0fab4e4e..cfea0213 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -35,7 +35,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $provider = [pscustomobject]@{ Name = 'IdentityProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable', 'IdLE.Identity.Read') + return @('IdLE.Identity.Disable') } -Force $providers = @{ @@ -47,7 +47,6 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.Steps.Count | Should -Be 1 $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Read' } It 'fails fast when required capabilities are missing' { @@ -99,7 +98,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $provider = [pscustomobject]@{ Name = 'IdentityProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable', 'IdLE.Identity.Read') + return @('IdLE.Identity.Disable') } -Force $providers = @{ @@ -111,7 +110,6 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.OnFailureSteps.Count | Should -Be 1 $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' - $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Read' } It 'validates entitlement capabilities from metadata' {