diff --git a/docs/reference/cmdlets/Invoke-IdlePlan.md b/docs/reference/cmdlets/Invoke-IdlePlan.md index b47340e7..b152e300 100644 --- a/docs/reference/cmdlets/Invoke-IdlePlan.md +++ b/docs/reference/cmdlets/Invoke-IdlePlan.md @@ -22,15 +22,29 @@ Invoke-IdlePlan [-Plan] <Object> [[-Providers] <Hashtable>] [[-Event Executes a plan deterministically and emits structured events. Delegates execution to IdLE.Core. +Provider resolution: +- If -Providers is supplied, it is used for execution. +- If -Providers is not supplied, Plan.Providers is used if available. +- If neither is present, execution fails early with a clear error message. + ## EXAMPLES ### EXAMPLE 1 ``` -Invoke-IdlePlan -Plan $plan -Providers $providers +# Default: plan built with providers, execution uses Plan.Providers +$providers = @{ Identity = $provider; AuthSessionBroker = $broker } +$plan = New-IdlePlan -WorkflowPath ./joiner.psd1 -Request $req -Providers $providers +Invoke-IdlePlan -Plan $plan ``` ### EXAMPLE 2 ``` +# Override: explicit -Providers at invoke time +Invoke-IdlePlan -Plan $plan -Providers $otherProviders +``` + +### EXAMPLE 3 +``` $execOptions = @{ RetryProfiles = @{ Default = @{ MaxAttempts = 3; InitialDelayMilliseconds = 200 } @@ -38,7 +52,7 @@ $execOptions = @{ } DefaultRetryProfile = 'Default' } -Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $execOptions +Invoke-IdlePlan -Plan $plan -ExecutionOptions $execOptions ``` ## PARAMETERS @@ -60,6 +74,8 @@ Accept wildcard characters: False ### -Providers Provider registry/collection passed through to execution. +If omitted and Plan.Providers exists, Plan.Providers will be used. +If supplied, overrides Plan.Providers. ```yaml Type: Hashtable diff --git a/docs/use/plan-export.md b/docs/use/plan-export.md index aa7199e5..6f8ace40 100644 --- a/docs/use/plan-export.md +++ b/docs/use/plan-export.md @@ -47,10 +47,18 @@ For the exact format and normative rules, see [Plan Export Specification](../ref ```powershell # Example only. Adjust parameters to your environment. $request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ EmployeeId = 'jdoe' } -$plan = New-IdlePlan -WorkflowPath './workflows/joiner.psd1' -Request $request +$providers = @{ Identity = New-IdleMockIdentityProvider } +$plan = New-IdlePlan -WorkflowPath './workflows/joiner.psd1' -Request $request -Providers $providers Export-IdlePlan -Plan $plan -Path './artifacts/joiner.plan.json' ``` +:::note + +Exported plans typically do not include provider objects. When executing an exported plan, +you must supply providers at execution time. + +::: + ### Review tips - Verify provider selection matches your intent (especially in multi-provider environments). diff --git a/docs/use/providers.md b/docs/use/providers.md index 723d9be1..f9e446c0 100644 --- a/docs/use/providers.md +++ b/docs/use/providers.md @@ -34,6 +34,12 @@ As providers may require additional tools and configuration, they are not import ::: +### Provider Resolution + +When executing a plan, providers can be supplied in two ways: + +1. **During planning** (recommended for most scenarios): + ```powershell Import-Module -Name IdLE.Provider.Mock @@ -41,6 +47,39 @@ $providers = @{ Identity = New-IdleMockIdentityProvider } +# Build plan with providers +$plan = New-IdlePlan -WorkflowPath ./joiner.psd1 -Request $request -Providers $providers + +# Execute without re-supplying providers (uses Plan.Providers) +$result = Invoke-IdlePlan -Plan $plan +``` + +2. **At execution time** (for provider override or exported plans): + +```powershell +# Override providers at execution time +$otherProviders = @{ + Identity = New-IdleMockIdentityProvider -Config $differentConfig +} + +$result = Invoke-IdlePlan -Plan $plan -Providers $otherProviders +``` + +#### Resolution Rules + +- If `-Providers` is supplied to `Invoke-IdlePlan`, it **takes precedence** over `Plan.Providers`. +- If `-Providers` is **not** supplied, `Invoke-IdlePlan` uses `Plan.Providers` (if available). +- If neither is present, execution fails early with: `Providers are required. Provide -Providers to Invoke-IdlePlan or build the plan with Providers.` + +#### Exported Plans + +When a plan is exported without provider objects (for review or audit), providers must be supplied at execution time: + +```powershell +# Export plan (without providers) +Export-IdlePlan -Plan $plan -Path ./plan.json + +# Later: execute with providers (plan import functionality is planned for future release) $result = Invoke-IdlePlan -Plan $plan -Providers $providers ``` diff --git a/docs/use/quickstart.md b/docs/use/quickstart.md index 96767f33..9e29882a 100644 --- a/docs/use/quickstart.md +++ b/docs/use/quickstart.md @@ -124,28 +124,29 @@ With the following command we create a simple 'Joiner' request. $request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' ``` -### 4. Build the plan (deterministic, data-only) +### 4. Select providers -The plan evaluates validity of the request in combination with the workflow definition. +For first run, we just use our internal mock provider. ```powershell -$plan = New-IdlePlan -WorkflowPath $workflow -Request $request +$providers = @{ + Identity = New-IdleMockIdentityProvider +} ``` -### 5. Select providers +### 5. Build the plan with providers -For first run, we just use our internal mock provider. +The plan evaluates validity of the request in combination with the workflow definition. ```powershell -$providers = @{ - Identity = New-IdleMockIdentityProvider -} +$plan = New-IdlePlan -WorkflowPath $workflow -Request $request -Providers $providers ``` ### 6. Execute the plan ```powershell -$result = Invoke-IdlePlan -Plan $plan -Providers $providers +# Execute without re-supplying providers (uses Plan.Providers automatically) +$result = Invoke-IdlePlan -Plan $plan ``` ### 7. Inspect result + events @@ -161,5 +162,6 @@ $result.Events | Select-Object Type, StepName, Message - If your workflow contains steps that require additional provider roles (e.g. `Messaging`, `Entitlement`), you must add them to `$providers`. - Many steps default to the provider alias `'Identity'` unless a step explicitly sets `With.Provider`. +- You can override providers at execution time by passing `-Providers` to `Invoke-IdlePlan`. ::: diff --git a/examples/Invoke-IdleDemo.ps1 b/examples/Invoke-IdleDemo.ps1 index 796cf4d3..f8d7f34d 100644 --- a/examples/Invoke-IdleDemo.ps1 +++ b/examples/Invoke-IdleDemo.ps1 @@ -269,7 +269,8 @@ foreach ($wf in $selected) { Write-Host "" Write-DemoHeader "Execute" - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + # Execute plan using Plan.Providers (no need to re-supply -Providers) + $result = Invoke-IdlePlan -Plan $plan $allResults += $result Write-Host "" diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index c37a0045..cba6d9b8 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -6,6 +6,11 @@ function Invoke-IdlePlanObject { .DESCRIPTION Executes steps in order, emits structured events, and returns a stable execution result. + Provider resolution: + - If -Providers is supplied, it is used for execution. + - If -Providers is not supplied (null), Plan.Providers is used if available. + - If neither is present, execution fails early with a clear error message. + Security: - ScriptBlocks are rejected in plan and providers. - The returned execution result is an output boundary: Providers are redacted. @@ -15,6 +20,8 @@ function Invoke-IdlePlanObject { .PARAMETER Providers Provider registry/collection passed through to execution. + If omitted and Plan.Providers exists, Plan.Providers will be used. + If supplied, overrides Plan.Providers. .PARAMETER EventSink Optional external event sink object. Must provide a WriteEvent(event) method. @@ -96,18 +103,43 @@ function Invoke-IdlePlanObject { } Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' - Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' + + # Resolve effective providers: explicit -Providers parameter takes precedence, otherwise use Plan.Providers. + # This allows the common workflow: build plan with providers once, execute without re-supplying them. + $effectiveProviders = $Providers + if ($null -eq $effectiveProviders) { + if ($planPropNames -contains 'Providers') { + $planProviders = $Plan.Providers + # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries + if ($null -ne $planProviders) { + $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or + ($planProviders.PSObject -and $planProviders.PSObject.Properties) + if ($isValidProvider) { + $effectiveProviders = $planProviders + } + } + } + } + + # Early validation: fail with a clear message if no providers are available. + if ($null -eq $effectiveProviders) { + throw [System.InvalidOperationException]::new( + 'Providers are required. Provide -Providers to Invoke-IdlePlan or build the plan with Providers.' + ) + } + + Assert-IdleNoScriptBlock -InputObject $effectiveProviders -Path 'Providers' # Validate ExecutionOptions Assert-IdleExecutionOptions -ExecutionOptions $ExecutionOptions # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist. - $stepRegistry = Get-IdleStepRegistry -Providers $Providers + $stepRegistry = Get-IdleStepRegistry -Providers $effectiveProviders $context = [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionContext' Plan = $Plan - Providers = $Providers + Providers = $effectiveProviders EventSink = $engineEventSink } @@ -202,14 +234,14 @@ function Invoke-IdlePlanObject { if ($requiresAuthBroker) { $broker = $null - if ($Providers -is [System.Collections.IDictionary]) { - if ($Providers.Contains('AuthSessionBroker')) { - $broker = $Providers['AuthSessionBroker'] + if ($effectiveProviders -is [System.Collections.IDictionary]) { + if ($effectiveProviders.Contains('AuthSessionBroker')) { + $broker = $effectiveProviders['AuthSessionBroker'] } } else { - if ($null -ne $Providers -and $Providers.PSObject.Properties.Name -contains 'AuthSessionBroker') { - $broker = $Providers.AuthSessionBroker + if ($null -ne $effectiveProviders -and $effectiveProviders.PSObject.Properties.Name -contains 'AuthSessionBroker') { + $broker = $effectiveProviders.AuthSessionBroker } } @@ -620,8 +652,8 @@ function Invoke-IdlePlanObject { # Issue #48: # Redact provider configuration/state at the output boundary (execution result). - $redactedProviders = if ($null -ne $Providers) { - Copy-IdleRedactedObject -Value $Providers + $redactedProviders = if ($null -ne $effectiveProviders) { + Copy-IdleRedactedObject -Value $effectiveProviders } else { $null diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index 4fde87f9..2fc5e5f8 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -7,11 +7,18 @@ function Invoke-IdlePlan { Executes a plan deterministically and emits structured events. Delegates execution to IdLE.Core. + Provider resolution: + - If -Providers is supplied, it is used for execution. + - If -Providers is not supplied, Plan.Providers is used if available. + - If neither is present, execution fails early with a clear error message. + .PARAMETER Plan The plan object created by New-IdlePlan. .PARAMETER Providers Provider registry/collection passed through to execution. + If omitted and Plan.Providers exists, Plan.Providers will be used. + If supplied, overrides Plan.Providers. .PARAMETER EventSink Optional external event sink for streaming. Must be an object with a WriteEvent(event) method. @@ -21,7 +28,14 @@ function Invoke-IdlePlan { Must be a hashtable with optional keys: RetryProfiles, DefaultRetryProfile. .EXAMPLE - Invoke-IdlePlan -Plan $plan -Providers $providers + # Default: plan built with providers, execution uses Plan.Providers + $providers = @{ Identity = $provider; AuthSessionBroker = $broker } + $plan = New-IdlePlan -WorkflowPath ./joiner.psd1 -Request $req -Providers $providers + Invoke-IdlePlan -Plan $plan + + .EXAMPLE + # Override: explicit -Providers at invoke time + Invoke-IdlePlan -Plan $plan -Providers $otherProviders .EXAMPLE $execOptions = @{ @@ -31,7 +45,7 @@ function Invoke-IdlePlan { } DefaultRetryProfile = 'Default' } - Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $execOptions + Invoke-IdlePlan -Plan $plan -ExecutionOptions $execOptions .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) diff --git a/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 index 2a59f2d0..1780e3f0 100644 --- a/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 @@ -139,7 +139,8 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $opts = @{ SomeKey = { Write-Host 'test' } @@ -159,7 +160,8 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $opts = @{ RetryProfiles = @{ @@ -181,7 +183,8 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $opts = @{ RetryProfiles = @{ @@ -204,7 +207,8 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $opts = @{ RetryProfiles = @{ diff --git a/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 b/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 new file mode 100644 index 00000000..24d69fdb --- /dev/null +++ b/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 @@ -0,0 +1,271 @@ +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + # Test step handler for provider fallback tests + function global:Invoke-IdleTestProviderFallbackStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + # Verify Context.Providers is not null (reproduces the original failure scenario) + if ($null -eq $Context.Providers) { + throw "Context.Providers must be a hashtable." + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleTestProviderFallbackStep' -ErrorAction SilentlyContinue +} + +Describe 'Invoke-IdlePlan Provider Fallback' { + It 'uses Plan.Providers when -Providers is not supplied' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + # Build plan with providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Execute without passing -Providers (should use Plan.Providers) + $result = Invoke-IdlePlan -Plan $plan + + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + } + + It 'explicit -Providers overrides Plan.Providers' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $planProviders = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + TestMarker = 'PlanProviders' + } + + $explicitProviders = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + TestMarker = 'ExplicitProviders' + } + + # Build plan with planProviders + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $planProviders + + # Execute with explicit providers (should override Plan.Providers) + $result = Invoke-IdlePlan -Plan $plan -Providers $explicitProviders + + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + # Verify that explicitProviders were used (check redacted providers) + $result.Providers.TestMarker | Should -Be 'ExplicitProviders' + } + + It 'fails with clear error when neither -Providers nor Plan.Providers exist' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + # Build plan with providers, then remove Providers property to simulate exported plan scenario + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Remove Providers property to simulate an exported plan without provider objects + $plan.PSObject.Properties.Remove('Providers') + + # Execute without -Providers and without Plan.Providers + { Invoke-IdlePlan -Plan $plan } | Should -Throw '*Providers are required*' + } + + It 'regression: does not fail with "Context.Providers must be a hashtable" when using Plan.Providers' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + # Build plan with providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Execute without passing -Providers (should NOT throw "Context.Providers must be a hashtable") + { Invoke-IdlePlan -Plan $plan } | Should -Not -Throw + } + + It 'applies security validations to Plan.Providers' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + MaliciousCode = { Write-Host "Malicious" } # ScriptBlock should be rejected + } + + # Build plan with providers containing ScriptBlock + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Execute without passing -Providers (should still reject ScriptBlocks in Plan.Providers) + { Invoke-IdlePlan -Plan $plan } | Should -Throw + } + + It 'redacts Plan.Providers in execution result when used as fallback' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + TestProvider = @{ + endpoint = 'https://example.test' + token = 'SecretToken123' + apiKey = 'ApiKey456' + } + } + + # Build plan with providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Execute without passing -Providers + $result = Invoke-IdlePlan -Plan $plan + + $result.Status | Should -Be 'Completed' + # Providers should be redacted (sensitive keys should have [REDACTED]) + $result.Providers | Should -Not -BeNullOrEmpty + $result.Providers.TestProvider.token | Should -Be '[REDACTED]' + $result.Providers.TestProvider.apiKey | Should -Be '[REDACTED]' + $result.Providers.TestProvider.endpoint | Should -Be 'https://example.test' + } + + It 'uses Plan.Providers when it is a PSCustomObject (not just IDictionary)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + # Create a PSCustomObject-shaped provider registry (not a hashtable) + $providersObject = [pscustomobject]@{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + # Build plan with PSCustomObject providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providersObject + + # Execute without passing -Providers (should use Plan.Providers even though it's a PSCustomObject) + $result = Invoke-IdlePlan -Plan $plan + + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + } +}