diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index bc21e7c4..63de2978 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -68,7 +68,41 @@ If any condition evaluates to false, the step is marked as `NotApplicable` durin --- -## Condition DSL +## Conditions as a guard against plan-time validation + +When a step's `Condition` evaluates to `false`, IdLE marks it `NotApplicable` and **skips all remaining plan-time processing** for that step, including: + +- `With` template resolution +- `WithSchema` validation + +This means a condition-guarded step will **not** cause a planning failure even if its `With` block references data that is absent or if required schema keys are missing. The step is simply excluded from the executable plan. + +The `Exists` operator is specifically designed for this pattern: using `Exists` in a `Condition` does **not** require the referenced path to exist at plan time. If the path is absent, `Exists` evaluates to `false` and the step becomes `NotApplicable`. + +:::info Example: Guard a step with an existence check +A step that provisions an EU-region user can be safely guarded by a condition that checks for the `Region` attribute. +If the attribute is absent, `Exists` evaluates to `false`, the step is `NotApplicable`, and neither the condition path nor the `With` block causes a planning error. + +```powershell +@{ + Name = 'Provision EU User' + Type = 'IdLE.Step.EnsureAttributes' + Condition = @{ Exists = 'Request.Context.Views.Identity.Profile.Attributes.Region' } + With = @{ + IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' + Attributes = @{ Region = '{{Request.Context.Views.Identity.Profile.Attributes.Region}}' } + } +} +``` + +If `Region` is absent, the condition evaluates to `false`, the step is `NotApplicable`, and no template errors are raised. +::: + +**Applicable steps** still undergo full template resolution and schema validation — this behavior is unchanged. + +--- + +## Conditions DSL Preconditions use the **same DSL** as Conditions. This section is the authoritative DSL reference. diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index fd130637..a9d2b3cc 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -8,6 +8,16 @@ function ConvertTo-IdleWorkflowSteps { .DESCRIPTION Evaluates Condition during planning and sets Status = Planned / NotApplicable. + When a step's Condition evaluates to false, the step is marked NotApplicable and all + subsequent plan-time processing that only makes sense for executable steps (With template + resolution and WithSchema validation) is skipped for that step. This prevents false-positive + planning failures caused by missing data referenced in With blocks that are intentionally + guarded by a Condition. + + Condition path validation uses -ExcludeExistsOperatorPaths so that steps using the Exists + operator to guard optional context attributes are not rejected at plan time merely because + the attribute is absent — that is the intended use of the Exists operator. + IMPORTANT: WorkflowSteps is optional and may be null or empty. A workflow is allowed to omit OnFailureSteps entirely. Therefore we must not mark this parameter as Mandatory. @@ -80,7 +90,7 @@ function ConvertTo-IdleWorkflowSteps { ) } - Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' + Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' -ExcludeExistsOperatorPaths $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext if (-not $isApplicable) { @@ -118,65 +128,70 @@ function ConvertTo-IdleWorkflowSteps { @{} } - # Resolve template placeholders in With (planning-time resolution) - $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + # Skip With template resolution and WithSchema validation for NotApplicable steps. + # A step whose Condition evaluated to false will never be executed, so further plan-time + # validation that assumes the step is eligible would produce false-positive failures. + if ($status -ne 'NotApplicable') { + # Resolve template placeholders in With (planning-time resolution) + $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName - # Validate WithSchema declared by step metadata (fail-fast plan-time schema check). - # Every step type must declare WithSchema. Required keys must be present; unknown keys are rejected. - # If OptionalKeys contains '*', any additional key is accepted (permissive schema for test/internal use). - if ($StepMetadataCatalog.ContainsKey($stepType)) { - $md = $StepMetadataCatalog[$stepType] - if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('WithSchema')) { - $schema = $md['WithSchema'] - if ($null -ne $schema -and $schema -is [hashtable]) { - $requiredKeys = @() - if ($schema.ContainsKey('RequiredKeys') -and $null -ne $schema['RequiredKeys']) { - $requiredKeys = @($schema['RequiredKeys']) - } - $optionalKeys = @() - if ($schema.ContainsKey('OptionalKeys') -and $null -ne $schema['OptionalKeys']) { - $optionalKeys = @($schema['OptionalKeys']) - } + # Validate WithSchema declared by step metadata (fail-fast plan-time schema check). + # Every step type must declare WithSchema. Required keys must be present; unknown keys are rejected. + # If OptionalKeys contains '*', any additional key is accepted (permissive schema for test/internal use). + if ($StepMetadataCatalog.ContainsKey($stepType)) { + $md = $StepMetadataCatalog[$stepType] + if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('WithSchema')) { + $schema = $md['WithSchema'] + if ($null -ne $schema -and $schema -is [hashtable]) { + $requiredKeys = @() + if ($schema.ContainsKey('RequiredKeys') -and $null -ne $schema['RequiredKeys']) { + $requiredKeys = @($schema['RequiredKeys']) + } + $optionalKeys = @() + if ($schema.ContainsKey('OptionalKeys') -and $null -ne $schema['OptionalKeys']) { + $optionalKeys = @($schema['OptionalKeys']) + } - # Build allowed set from all keys (required and optional combined) - $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() - foreach ($keyList in @($requiredKeys, $optionalKeys)) { - foreach ($k in $keyList) { - if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { - $null = $allAllowedKeysList.Add([string]$k) + # Build allowed set from all keys (required and optional combined) + $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() + foreach ($keyList in @($requiredKeys, $optionalKeys)) { + foreach ($k in $keyList) { + if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { + $null = $allAllowedKeysList.Add([string]$k) + } } } - } - $allowedSet = [System.Collections.Generic.HashSet[string]]::new( - $allAllowedKeysList, - [System.StringComparer]::OrdinalIgnoreCase - ) - $permissive = $allowedSet.Contains('*') - - # Validate required keys are present - foreach ($rk in $requiredKeys) { - if ([string]::IsNullOrWhiteSpace([string]$rk) -or [string]$rk -eq '*') { continue } - - if (-not $with.ContainsKey($rk)) { - $requiredList = [string]::Join(', ', ($requiredKeys | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object)) - throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') is missing required With.{2}. Required With keys: {3}." -f $stepName, $stepType, $rk, $requiredList), - 'Workflow' - ) - } - } + $allowedSet = [System.Collections.Generic.HashSet[string]]::new( + $allAllowedKeysList, + [System.StringComparer]::OrdinalIgnoreCase + ) + $permissive = $allowedSet.Contains('*') + + # Validate required keys are present + foreach ($rk in $requiredKeys) { + if ([string]::IsNullOrWhiteSpace([string]$rk) -or [string]$rk -eq '*') { continue } - # Validate no unknown keys (skip if permissive wildcard) - if (-not $permissive) { - foreach ($wk in @($with.Keys)) { - if (-not $allowedSet.Contains([string]$wk)) { - $supportedList = [string]::Join(', ', ($allAllowedKeysList | Sort-Object)) + if (-not $with.ContainsKey($rk)) { + $requiredList = [string]::Join(', ', ($requiredKeys | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object)) throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') does not support With.{2}. Supported With keys: {3}." -f $stepName, $stepType, [string]$wk, $supportedList), + ("Step '{0}' (type '{1}') is missing required With.{2}. Required With keys: {3}." -f $stepName, $stepType, $rk, $requiredList), 'Workflow' ) } } + + # Validate no unknown keys (skip if permissive wildcard) + if (-not $permissive) { + foreach ($wk in @($with.Keys)) { + if (-not $allowedSet.Contains([string]$wk)) { + $supportedList = [string]::Join(', ', ($allAllowedKeysList | Sort-Object)) + throw [System.ArgumentException]::new( + ("Step '{0}' (type '{1}') does not support With.{2}. Supported With keys: {3}." -f $stepName, $stepType, [string]$wk, $supportedList), + 'Workflow' + ) + } + } + } } } } diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index ffe39f77..60391abb 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -178,6 +178,100 @@ Describe 'New-IdlePlan' { } } + Context 'Condition skips With processing' { + It 'does not fail planning when condition uses Exists operator on an absent context path' { + $wfPath = Join-Path $script:FixturesPath 'condition-exists-absent.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.ExistsConditionTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ExistsConditionTest') + } + + # Must not throw: Exists on absent path evaluates to false without a path-resolvability error + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.Steps).Count | Should -Be 1 + $plan.Steps[0].Status | Should -Be 'NotApplicable' + } + + It 'does not fail planning when condition is false and With references missing data (template resolution skipped)' { + $wfPath = Join-Path $script:FixturesPath 'condition-skip-template.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.ConditionalSkipTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ConditionalSkipTest') + } + + # Must not throw despite template referencing absent Request.Intent.MissingKey + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.Steps).Count | Should -Be 1 + $plan.Steps[0].Status | Should -Be 'NotApplicable' + } + + It 'does not fail planning when condition is false and With is missing a required schema key' { + $wfPath = Join-Path $script:FixturesPath 'condition-skip-schema.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.StrictSchemaTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.StrictSchemaTest') -WithSchemas @{ + 'IdLE.Step.StrictSchemaTest' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @() } + } + } + + # Must not throw despite the required With.IdentityKey being absent + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.Steps).Count | Should -Be 1 + $plan.Steps[0].Status | Should -Be 'NotApplicable' + } + + It 'still enforces With template resolution and WithSchema validation when condition is true' { + $wfPath = Join-Path $script:FixturesPath 'condition-applicable-schema.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.StrictApplicableTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.StrictApplicableTest') -WithSchemas @{ + 'IdLE.Step.StrictApplicableTest' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @() } + } + } + + # Must still throw because condition is true and required With.IdentityKey is missing + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*IdentityKey*' + } + + It 'does not fail planning when condition is false for an OnFailureStep referencing missing template data' { + $wfPath = Join-Path $script:FixturesPath 'condition-skip-onfailure.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.PrimarySkipTest' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailureSkipTest' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @( + 'IdLE.Step.PrimarySkipTest', + 'IdLE.Step.OnFailureSkipTest' + ) + } + + # Must not throw despite template referencing absent data + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.OnFailureSteps).Count | Should -Be 1 + $plan.OnFailureSteps[0].Status | Should -Be 'NotApplicable' + } + } + Context 'Validation' { It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' diff --git a/tests/fixtures/workflows/condition-applicable-schema.psd1 b/tests/fixtures/workflows/condition-applicable-schema.psd1 new file mode 100644 index 00000000..746eb9d5 --- /dev/null +++ b/tests/fixtures/workflows/condition-applicable-schema.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Condition Applicable Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'StrictStep' + Type = 'IdLE.Step.StrictApplicableTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) +} diff --git a/tests/fixtures/workflows/condition-exists-absent.psd1 b/tests/fixtures/workflows/condition-exists-absent.psd1 new file mode 100644 index 00000000..43ebdcc7 --- /dev/null +++ b/tests/fixtures/workflows/condition-exists-absent.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Condition Exists Absent Path' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ConditionalExistsStep' + Type = 'IdLE.Step.ExistsConditionTest' + Condition = @{ Exists = 'Request.Context.Views.Identity.Profile.Attributes.Region' } + With = @{ + Value = '{{Request.Context.Views.Identity.Profile.Attributes.Region}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/condition-skip-onfailure.psd1 b/tests/fixtures/workflows/condition-skip-onfailure.psd1 new file mode 100644 index 00000000..d7013930 --- /dev/null +++ b/tests/fixtures/workflows/condition-skip-onfailure.psd1 @@ -0,0 +1,17 @@ +@{ + Name = 'Condition Skip OnFailureStep' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Primary'; Type = 'IdLE.Step.PrimarySkipTest' } + ) + OnFailureSteps = @( + @{ + Name = 'SkippedOnFailure' + Type = 'IdLE.Step.OnFailureSkipTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + With = @{ + Value = '{{Request.Intent.MissingKey}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/condition-skip-schema.psd1 b/tests/fixtures/workflows/condition-skip-schema.psd1 new file mode 100644 index 00000000..a62f5a2e --- /dev/null +++ b/tests/fixtures/workflows/condition-skip-schema.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Condition Skip Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'StrictStep' + Type = 'IdLE.Step.StrictSchemaTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + } + ) +} diff --git a/tests/fixtures/workflows/condition-skip-template.psd1 b/tests/fixtures/workflows/condition-skip-template.psd1 new file mode 100644 index 00000000..eec3b3d7 --- /dev/null +++ b/tests/fixtures/workflows/condition-skip-template.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Condition Skip Template' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ConditionalStep' + Type = 'IdLE.Step.ConditionalSkipTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + With = @{ + Value = '{{Request.Intent.MissingKey}}' + } + } + ) +}