diff --git a/docs/advanced/provider-capabilities.md b/docs/advanced/provider-capabilities.md index 5d809d40..e74d2594 100644 --- a/docs/advanced/provider-capabilities.md +++ b/docs/advanced/provider-capabilities.md @@ -48,12 +48,18 @@ Workflow Definition (PSD1) v Plan Builder (New-IdlePlan / New-IdlePlanObject) | - |-- normalizes steps (Name/Type/With/Condition/RequiresCapabilities) + |-- loads step metadata catalog: + | - discovers loaded IdLE.Steps.* modules + | - merges their Get-IdleStepMetadataCatalog outputs + | - applies host supplements (Providers.StepMetadata) | - |-- NEW: capability validation (fail fast) - | - collect required capabilities from steps - | - discover available capabilities from providers - | - compare and throw on missing capabilities + |-- normalizes steps (Name/Type/With/Condition) + | - derives RequiresCapabilities from metadata catalog + | + |-- capability validation (fail fast) + | - collects required capabilities from all steps + | - discovers available capabilities from providers + | - compares and throws on missing capabilities | v Plan artifact (IdLE.Plan) is created @@ -98,36 +104,69 @@ IdLE includes a reusable Pester contract to enforce this. ## Step requirements -Steps can declare required capabilities in workflow definitions using the optional key: +Steps declare required capabilities via **step metadata catalogs** owned by step packs. -- `RequiresCapabilities` +### Step pack ownership -Supported shapes: +Step packs (`IdLE.Steps.*` modules) own metadata for their step types via the `Get-IdleStepMetadataCatalog` function. -- missing / `$null` -> no requirements -- string -> single capability -- string array -> multiple capabilities +Each step pack exports a case-insensitive hashtable mapping: +- **Key**: `StepType` (string, e.g., `IdLE.Step.DisableIdentity`) +- **Value**: metadata hashtable containing at least: + - `RequiredCapabilities`: string or string[] (normalized to string[] by Core) -Example: +Example from `IdLE.Steps.Common`: ```powershell -@{ - Name = 'Disable identity' - Type = 'DisableIdentity' - RequiresCapabilities = @('IdLE.Identity.Read', 'IdLE.Identity.Disable') +function Get-IdleStepMetadataCatalog { + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + $catalog['IdLE.Step.DisableIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Disable') + } + + $catalog['IdLE.Step.EnsureAttribute'] = @{ + RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') + } + + return $catalog } ``` -During planning, IdLE normalizes this into a stable, sorted, unique string array on each plan step. +### Discovery and merge + +During plan building, `IdLE.Core`: + +1. Discovers all loaded modules matching `IdLE.Steps.*` that export `Get-IdleStepMetadataCatalog` +2. Calls each function and merges the returned catalogs deterministically (by module name ascending) +3. Fails fast with `DuplicateStepTypeMetadata` if the same step type appears in multiple step packs + +### Host supplements (custom step types only) + +Hosts may provide metadata for **new, host-defined** step types via `Providers.StepMetadata`: + +```powershell +$providers = @{ + StepMetadata = @{ + 'Custom.Step.SpecialAction' = @{ + RequiredCapabilities = @('Custom.Capability.SpecialAction') + } + } +} +``` + +**Important**: Host metadata is **supplement-only**. Hosts cannot override step pack metadata. Attempting to provide metadata for a step type already owned by a loaded step pack will result in `DuplicateStepTypeMetadata`. ## Capability validation Capability validation is performed during plan build: -1. Collect required capabilities from all steps (`RequiresCapabilities`) -2. Discover available capabilities from all provider instances passed via `-Providers` -3. Compare required vs. available -4. Throw a deterministic error if any required capabilities are missing +1. Load step metadata catalog (from step packs and host supplements) +2. Normalize steps and derive `RequiresCapabilities` from metadata +3. Collect required capabilities from all steps (including `OnFailureSteps`) +4. Discover available capabilities from all provider instances passed via `-Providers` +5. Compare required vs. available +6. Throw a deterministic error if any required capabilities are missing The thrown error message includes: @@ -137,6 +176,20 @@ The thrown error message includes: This is designed for good UX and for automated diagnostics in CI logs. +### Error: MissingStepTypeMetadata + +If a workflow references a step type that has no metadata entry, plan building fails with `MissingStepTypeMetadata`. + +Remediation: +1. Import/load the step pack module (`IdLE.Steps.*`) that owns the step type, OR +2. For custom/host-defined step types only, provide `Providers.StepMetadata` + +### Error: DuplicateStepTypeMetadata + +If the same step type appears in multiple step packs, or if a host attempts to override step pack metadata, plan building fails with `DuplicateStepTypeMetadata`. + +This ensures clear ownership and prevents ambiguous behavior. + ## Provider discovery from `-Providers` The engine treats the `-Providers` argument as a host-controlled "bag of objects". diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index ce2dbbf2..b3ea84d1 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -311,7 +311,7 @@ The following built-in steps in `IdLE.Steps.Common` work with the AD provider: - **IdLE.Step.EnsureAttribute** - Set/update user attributes - **IdLE.Step.EnsureEntitlement** - Manage group memberships -All steps declare `RequiresCapabilities` for plan-time validation. +Step metadata (including required capabilities) is provided by step pack modules (`IdLE.Steps.Common`) and used for plan-time validation. --- diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 37b20ffc..d415f8b5 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -19,36 +19,6 @@ function Resolve-IdleStepMetadataCatalog { $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) Module-scoped discovery (prefer the specified module to avoid name shadowing) - $module = Get-Module -Name $ModuleName -All | Select-Object -First 1 - if ($null -ne $module -and - $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 - } - # Helper: Validate a single capability identifier format. function Test-IdleCapabilityIdentifier { [CmdletBinding()] @@ -132,8 +102,83 @@ function Resolve-IdleStepMetadataCatalog { ) } - # Helper: Validate and merge metadata from a hashtable source. - function Merge-IdleStepMetadata { + # Helper: Validate metadata contains no ScriptBlocks (wrapper around Assert-IdleNoScriptBlock). + function Assert-IdleStepMetadataNoScriptBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [string] $SourceName + ) + + try { + Assert-IdleNoScriptBlock -InputObject $Value -Path $Path + } + catch { + # Rethrow with metadata-specific error message + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", + 'Providers' + ) + } + } + + # 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 + } + + # Helper: Discover loaded step packs exporting Get-IdleStepMetadataCatalog. + function Get-IdleStepPackModules { + [CmdletBinding()] + param() + + $loadedModules = Get-Module -Name 'IdLE.Steps.*' -All + if ($null -eq $loadedModules) { + return @() + } + + $stepPackModules = @() + foreach ($m in @($loadedModules)) { + if ($null -ne $m.ExportedCommands -and $m.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog')) { + $stepPackModules += $m + } + } + + # Sort by module name for deterministic order + return @($stepPackModules | Sort-Object -Property Name) + } + + # Helper: Merge step pack catalog with duplicate detection. + function Merge-IdleStepPackCatalog { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -144,115 +189,67 @@ function Resolve-IdleStepMetadataCatalog { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $SourceName + [string] $SourceModuleName, + + [Parameter(Mandatory)] + [hashtable] $StepTypeOwners ) 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') + throw [System.ArgumentException]::new("$SourceModuleName 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).", + "$SourceModuleName entry for step type '$key' must be a hashtable (metadata object).", 'Providers' ) } # Validate metadata shape (data-only, no ScriptBlocks). - # 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 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 + Assert-IdleStepMetadataNoScriptBlock -Value $metaValue -Path $metaKey -StepType $key -SourceName $SourceModuleName if ($metaKey -eq 'RequiredCapabilities') { - Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceName + Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceModuleName } } - # Merge (host metadata overrides built-in). + # Check for duplicates across step packs + if ($StepTypeOwners.ContainsKey([string]$key)) { + $existingOwner = $StepTypeOwners[[string]$key] + $errorMessage = "DuplicateStepTypeMetadata: Step type '$key' is defined in both '$existingOwner' and '$SourceModuleName'. " + + "Step packs must own unique step types." + throw [System.InvalidOperationException]::new($errorMessage) + } + + # Register ownership and add to catalog + $StepTypeOwners[[string]$key] = $SourceModuleName $Target[[string]$key] = $value } } - # Helper: Get host-provided StepMetadata if available. - function Get-IdleHostStepMetadata { - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Providers - ) - - if ($null -eq $Providers) { - return $null - } + # 1) Discover and merge step pack catalogs (deterministic order). + $stepTypeOwners = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + $stepPackModules = Get-IdleStepPackModules - 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. - $builtInFunction = Resolve-IdleModuleFunction -FunctionName 'Get-IdleStepMetadataCatalog' -ModuleName 'IdLE.Steps.Common' - - if ($null -ne $builtInFunction) { - $functionModule = $builtInFunction.ModuleName ?? $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' + foreach ($module in $stepPackModules) { + $catalogFunction = $module.ExportedCommands['Get-IdleStepMetadataCatalog'] + if ($null -ne $catalogFunction) { + $stepPackCatalog = & $catalogFunction + if ($null -ne $stepPackCatalog -and $stepPackCatalog -is [hashtable]) { + Merge-IdleStepPackCatalog -Target $catalog -Source $stepPackCatalog -SourceModuleName $module.Name -StepTypeOwners $stepTypeOwners } } } - # 2) Merge host-provided StepMetadata (optional) - this overrides built-in. + # 2) Apply host-provided StepMetadata as supplement-only (no overrides). $hostMetadata = Get-IdleHostStepMetadata -Providers $Providers if ($null -ne $hostMetadata) { @@ -260,7 +257,44 @@ function Resolve-IdleStepMetadataCatalog { 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' + foreach ($key in $hostMetadata.Keys) { + if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { + throw [System.ArgumentException]::new('Providers.StepMetadata contains an empty step type key.', 'Providers') + } + + # Check if this step type already exists in step pack catalog (no override allowed) + if ($catalog.ContainsKey([string]$key)) { + $existingOwner = $stepTypeOwners[[string]$key] + $errorMessage = "DuplicateStepTypeMetadata: Step type '$key' is already defined in step pack '$existingOwner'. " + + "Host metadata (Providers.StepMetadata) can only supplement with new step types, not override existing ones." + throw [System.InvalidOperationException]::new($errorMessage) + } + + $value = $hostMetadata[$key] + + if ($value -isnot [hashtable]) { + throw [System.ArgumentException]::new( + "Providers.StepMetadata 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] + + # Recursively validate no ScriptBlocks anywhere in metadata + Assert-IdleStepMetadataNoScriptBlock -Value $metaValue -Path $metaKey -StepType $key -SourceName 'Providers.StepMetadata' + + if ($metaKey -eq 'RequiredCapabilities') { + Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName 'Providers.StepMetadata' + } + } + + # Add host supplement + $catalog[[string]$key] = $value + $stepTypeOwners[[string]$key] = 'Host' + } } return $catalog diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 67f72b86..5dbb79dc 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -501,11 +501,10 @@ function New-IdlePlanObject { } else { # 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), - 'Providers' - ) + $errorMessage = "MissingStepTypeMetadata: Workflow step '$stepName' references step type '$stepType' which has no metadata entry. " + + "To resolve this: (1) Import/load the step pack module (IdLE.Steps.*) that provides metadata for '$stepType' via Get-IdleStepMetadataCatalog, OR " + + "(2) For host-defined/custom step types only, provide Providers.StepMetadata['$stepType'] = @{ RequiredCapabilities = @(...) }." + throw [System.InvalidOperationException]::new($errorMessage) } $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index c449c769..2f266676 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -63,11 +63,5 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') } - # IdLE.Step.TriggerDirectorySync - requires trigger and status capabilities - # Note: Even when With.Wait = $false, we advertise Status capability to keep planning deterministic - $catalog['IdLE.Step.TriggerDirectorySync'] = @{ - RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') - } - return $catalog } diff --git a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 index 910e49d1..b2f4298b 100644 --- a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 +++ b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 @@ -12,6 +12,7 @@ ) FunctionsToExport = @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepTriggerDirectorySync' ) diff --git a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 index cb677b8a..1e9e2b68 100644 --- a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 +++ b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 @@ -25,5 +25,6 @@ if (Test-Path -Path $PublicPath) { } Export-ModuleMember -Function @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepTriggerDirectorySync' ) diff --git a/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..df754fcf --- /dev/null +++ b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,35 @@ +function Get-IdleStepMetadataCatalog { + <# + .SYNOPSIS + Returns metadata for DirectorySync step types. + + .DESCRIPTION + This function provides a metadata catalog mapping Step.Type to metadata objects + for directory sync step types owned by this step pack. + + 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.TriggerDirectorySync'].RequiredCapabilities + # Returns: @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + #> + [CmdletBinding()] + param() + + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # IdLE.Step.TriggerDirectorySync - requires trigger and status capabilities + # Note: Even when With.Wait = $false, we advertise Status capability to keep planning deterministic + $catalog['IdLE.Step.TriggerDirectorySync'] = @{ + RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } + + return $catalog +} diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index cfea0213..e28ac11f 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -6,7 +6,7 @@ BeforeAll { Describe 'New-IdlePlan - required provider capabilities' { - It 'fails fast when a step type has no metadata entry' { + It 'fails fast when a step type has no metadata entry (MissingStepTypeMetadata)' { $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -23,7 +23,7 @@ Describe 'New-IdlePlan - required provider capabilities' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'no StepMetadata entry' + $_.Exception.Message | Should -Match 'MissingStepTypeMetadata' $_.Exception.Message | Should -Match 'Custom.Step.Unknown' } } @@ -65,7 +65,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } } - It 'allows host metadata to override built-in metadata' { + It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-override.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -84,11 +84,14 @@ Describe 'New-IdlePlan - required provider capabilities' { } } - $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') + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + } } It 'validates OnFailureSteps capabilities from metadata' { diff --git a/tests/Resolve-IdleStepMetadataCatalog.Tests.ps1 b/tests/Resolve-IdleStepMetadataCatalog.Tests.ps1 new file mode 100644 index 00000000..9c6cff7e --- /dev/null +++ b/tests/Resolve-IdleStepMetadataCatalog.Tests.ps1 @@ -0,0 +1,259 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + $fixturesPath = Join-Path $PSScriptRoot 'fixtures/workflows' +} + +Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { + + It 'discovers loaded step packs exporting Get-IdleStepMetadataCatalog' { + # Both IdLE.Steps.Common and IdLE.Steps.DirectorySync should be loaded + $commonModule = Get-Module -Name 'IdLE.Steps.Common' + $commonModule | Should -Not -BeNullOrEmpty + $commonModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue + + $dirSyncModule = Get-Module -Name 'IdLE.Steps.DirectorySync' + $dirSyncModule | Should -Not -BeNullOrEmpty + $dirSyncModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue + + # Create a minimal workflow to trigger catalog resolution + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + # This will internally call Resolve-IdleStepMetadataCatalog + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Verify the catalog was used - the step should have capabilities from metadata + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + } + + It 'merges catalogs from multiple step packs deterministically' { + # Create workflows that use steps from both Common and DirectorySync + $wfPathDirSync = Join-Path -Path $fixturesPath -ChildPath 'joiner-with-dirsync.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } -Force + + $providers = @{ + DirectorySync = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPathDirSync -Request $req -Providers $providers + + # DirectorySync step should have correct capabilities + $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' + } + + It 'allows host to supplement with new step types not in step packs' { + # Create a workflow with a custom step type + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'CustomProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Test') + } -Force + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + StepMetadata = @{ + 'Custom.Step.Unknown' = @{ + RequiredCapabilities = @('Custom.Capability.Test') + } + } + CustomProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Custom step should have host-provided capabilities + $plan.Steps[0].RequiresCapabilities | Should -Contain 'Custom.Capability.Test' + } + + It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' + $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') + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + $_.Exception.Message | Should -Match 'IdLE.Steps.Common' + $_.Exception.Message | Should -Match 'supplement' + } + } + + It 'validates metadata does not contain ScriptBlocks (host supplement)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + StepMetadata = @{ + 'Custom.Step.Unknown' = @{ + RequiredCapabilities = { 'Dynamic.Cap' } + } + } + } + + 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' + } + } +} + +Describe 'New-IdlePlan - step metadata catalog integration' { + + It 'fails fast with MissingStepTypeMetadata when step type has no metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' + $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 'MissingStepTypeMetadata' + $_.Exception.Message | Should -Match 'Custom.Step.Unknown' + $_.Exception.Message | Should -Match 'Import/load the step pack' + $_.Exception.Message | Should -Match 'Providers.StepMetadata' + } + } + + It 'derives capabilities from step pack metadata (IdLE.Step.DisableIdentity)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + } + + It 'derives capabilities from DirectorySync step pack (IdLE.Step.TriggerDirectorySync)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-with-dirsync.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } -Force + + $providers = @{ + DirectorySync = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' + } + + It 'validates OnFailureSteps capabilities from metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-onfailure.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.OnFailureSteps.Count | Should -Be 1 + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + } + + It 'validates entitlement capabilities from metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-entitlements.psd1' + $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 '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', 'IdLE.Entitlement.Revoke') + } -Force + + $providers = @{ Entitlement = $provider } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $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' + } +} diff --git a/tests/fixtures/workflows/joiner-with-dirsync.psd1 b/tests/fixtures/workflows/joiner-with-dirsync.psd1 new file mode 100644 index 00000000..cf20dbba --- /dev/null +++ b/tests/fixtures/workflows/joiner-with-dirsync.psd1 @@ -0,0 +1,18 @@ +@{ + # This workflow tests DirectorySync step metadata resolution during the Joiner lifecycle event. + # It intentionally omits actual joiner steps to focus solely on DirectorySync step capability derivation. + Name = 'Joiner - DirectorySync Metadata Test' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Trigger directory sync' + Type = 'IdLE.Step.TriggerDirectorySync' + With = @{ + AuthSessionName = 'DirSync' + PolicyType = 'Delta' + Wait = $false + Provider = 'DirectorySync' + } + } + ) +}