diff --git a/docs/use/workflows.md b/docs/use/workflows.md index 1031ff5e..b5b4b108 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -31,7 +31,7 @@ Each step supports several optional execution control properties: | Property | Evaluated at | Purpose | |---|---|---| | `Condition` | Plan time | Include or skip the step based on request/intent data. | -| `Preconditions` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](workflows/preconditions.md). | +| `Precondition` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](workflows/preconditions.md). | | `OnFailureSteps` | After failure (workflow-level) | Cleanup/rollback steps run after a primary step fails. | --- diff --git a/docs/use/workflows/preconditions.md b/docs/use/workflows/preconditions.md index 6f19dfa3..3d9932fe 100644 --- a/docs/use/workflows/preconditions.md +++ b/docs/use/workflows/preconditions.md @@ -39,7 +39,7 @@ Add these optional properties to a workflow step definition: | Property | Type | Required | Description | |---|---|---|---| -| `Preconditions` | `Array[Condition]` | No | One or more condition nodes (same DSL as `Condition`). All must pass for the step to execute. | +| `Precondition` | `Condition` | No | One condition node (same DSL as `Condition`). It must evaluate to true for the step to execute. | | `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default), `Fail`, or `Continue`. | | `PreconditionEvent` | `Hashtable` | No | Structured event emitted when a precondition fails. | @@ -72,14 +72,16 @@ Add these optional properties to a workflow step definition: # Runtime guard: only execute if BYOD wipe is confirmed. # Note: the condition DSL compares values as strings. # Request.Context.Byod.WipeConfirmed must be the string 'true' (e.g. set by a ContextResolver). - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Request.Context.Byod.WipeConfirmed' Value = 'true' } } - ) + ) + } OnPreconditionFalse = 'Blocked' PreconditionEvent = @{ Type = 'ManualActionRequired' @@ -97,7 +99,7 @@ Add these optional properties to a workflow step definition: ## Condition DSL -Each entry in `Preconditions` uses the same **declarative condition DSL** as the `Condition` +`Precondition` uses the same **declarative condition DSL** as the `Condition` property. Supported operators: | Operator | Shape | Description | @@ -122,6 +124,10 @@ Paths are resolved against the **execution-time context**, which includes: A leading `context.` prefix is ignored for readability (e.g. `context.Request.Intent.Department` resolves identically to `Request.Intent.Department`). +At planning time, IdLE validates `Path` references to fail fast on typos and wrong roots. For `Precondition`, unresolved paths under `Request.Context.*` are treated as soft (non-fatal) to support context enrichment that may arrive later at runtime (for example via host/runtime context resolver behavior). Other unresolved roots still fail fast. +When this soft-check path is used, IdLE records a planning warning (`PreconditionContextPathUnresolvedAtPlan`) in `Plan.Warnings`, and the warning is included in `Export-IdlePlan` output for CI policy checks. + + --- ## Blocked vs. Failed vs. Continue outcomes @@ -214,7 +220,7 @@ Ensure context values are stored as strings when using `Equals` or `In` operator ## Backward compatibility -Steps without `Preconditions` behave exactly as before. Adding preconditions to a step does not +Steps without `Precondition` behave exactly as before. Adding a precondition to a step does not affect any other steps. --- diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 new file mode 100644 index 00000000..0249a54b --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -0,0 +1,167 @@ +Set-StrictMode -Version Latest + +function Assert-IdleConditionPathsResolvable { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Condition, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Source, + + [Parameter()] + [switch] $AllowMissingRequestContextPaths, + + [Parameter()] + [AllowNull()] + [object] $WarningSink + ) + + function Add-IdlePathIfPresent { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $PathList, + + [Parameter(Mandatory)] + [AllowNull()] + [object] $PathCandidate + ) + + if ($null -eq $PathCandidate) { + return + } + + $pathText = [string]$PathCandidate + if ([string]::IsNullOrWhiteSpace($pathText)) { + return + } + + if ($pathText.StartsWith('context.', [System.StringComparison]::OrdinalIgnoreCase)) { + $pathText = $pathText.Substring(8) + } + + $null = $PathList.Add($pathText) + } + + function Get-IdleConditionPaths { + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $PathList + ) + + if ($Node.Contains('All')) { + foreach ($child in @($Node.All)) { + if ($child -is [System.Collections.IDictionary]) { + Get-IdleConditionPaths -Node $child -PathList $PathList + } + } + return + } + + if ($Node.Contains('Any')) { + foreach ($child in @($Node.Any)) { + if ($child -is [System.Collections.IDictionary]) { + Get-IdleConditionPaths -Node $child -PathList $PathList + } + } + return + } + + if ($Node.Contains('None')) { + foreach ($child in @($Node.None)) { + if ($child -is [System.Collections.IDictionary]) { + Get-IdleConditionPaths -Node $child -PathList $PathList + } + } + return + } + + if ($Node.Contains('Equals')) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.Equals.Path + return + } + + if ($Node.Contains('NotEquals')) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.NotEquals.Path + return + } + + if ($Node.Contains('Exists')) { + $existsVal = $Node.Exists + if ($existsVal -is [string]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal + } + elseif ($existsVal -is [System.Collections.IDictionary]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path + } + return + } + + if ($Node.Contains('In')) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.In.Path + return + } + } + + $paths = [System.Collections.Generic.List[string]]::new() + Get-IdleConditionPaths -Node $Condition -PathList $paths + + $uniquePaths = @($paths | Select-Object -Unique) + if ($uniquePaths.Count -eq 0) { + return + } + + $missingPaths = @() + $softMissingContextPaths = @() + foreach ($path in $uniquePaths) { + if (-not (Test-IdlePathExists -Object $Context -Path $path)) { + if ($AllowMissingRequestContextPaths -and $path.StartsWith('Request.Context.')) { + $softMissingContextPaths += $path + continue + } + $missingPaths += $path + } + } + + if ($softMissingContextPaths.Count -gt 0 -and $null -ne $WarningSink) { + $warningItem = [ordered]@{ + Code = 'PreconditionContextPathUnresolvedAtPlan' + Type = 'Warning' + Step = $StepName + Source = $Source + Paths = @($softMissingContextPaths | Select-Object -Unique) + Message = ("Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', @($softMissingContextPaths | Select-Object -Unique)))) + } + + if ($WarningSink -is [System.Collections.IList]) { + $null = $WarningSink.Add($warningItem) + } + elseif ($WarningSink -is [object[]]) { + # Fallback for fixed arrays: cannot mutate by reference safely. + # Caller should pass an IList (plan.Warnings is an ArrayList) for collection. + } + } + + if ($missingPaths.Count -gt 0) { + $missingPathList = [string]::Join(', ', $missingPaths) + throw [System.ArgumentException]::new( + ("Workflow step '{0}' has unresolved condition path(s) in {1}: [{2}]. Check Request/Plan structure or ContextResolvers outputs." -f $StepName, $Source, $missingPathList), + 'Workflow' + ) + } +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index dc83bd33..e524ddbe 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -41,6 +41,12 @@ function ConvertTo-IdlePlanExportObject { ) foreach ($name in $Names) { + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($name)) { + return $Object[$name] + } + continue + } $prop = $Object.PSObject.Properties[$name] if ($null -ne $prop) { return $prop.Value @@ -273,10 +279,29 @@ function ConvertTo-IdlePlanExportObject { $stepList += $stepMap } + + # ---- Plan warnings ------------------------------------------------------ + $rawWarnings = Get-FirstPropertyValue -Object $Plan -Names @('Warnings', 'PlanningWarnings') + $warningList = @() + foreach ($w in @($rawWarnings)) { + if ($null -eq $w) { continue } + + $warningMap = New-OrderedMap + $warningMap.code = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Code', 'code')) + $warningMap.type = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Type', 'type')) + $warningMap.step = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Step', 'step', 'StepName')) + $warningMap.source = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Source', 'source')) + $warningMap.paths = Get-FirstPropertyValue -Object $w -Names @('Paths', 'paths') + $warningMap.message = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Message', 'message')) + + $warningList += $warningMap + } + $planMap = New-OrderedMap $planMap.id = $planId $planMap.mode = $mode $planMap.steps = $stepList + $planMap.warnings = $warningList # ---- Metadata block ------------------------------------------------------ $metadataMap = New-OrderedMap diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 new file mode 100644 index 00000000..b5d44813 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -0,0 +1,118 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleWorkflowStepPreconditionSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $PlanningContext + ) + + $normalized = @{ + Precondition = $null + OnPreconditionFalse = $null + PreconditionEvent = $null + } + + # Runtime Precondition: evaluated at execution time (not planning time). + # Uses the same declarative condition DSL as Condition. + if (Test-IdleWorkflowStepKey -Step $Step -Key 'Precondition') { + $rawPrecondition = Get-IdlePropertyValue -Object $Step -Name 'Precondition' + if ($null -ne $rawPrecondition) { + if ($rawPrecondition -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': Precondition must be a hashtable (condition node)." -f $StepName), + 'Workflow' + ) + } + + $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$rawPrecondition) -StepName $StepName + if (@($pcErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Precondition on step '{0}': {1}" -f $StepName, ([string]::Join(' ', @($pcErrors)))), + 'Workflow' + ) + } + + $warningSink = $null + $planObj = $PlanningContext.Plan + if ($null -ne $planObj) { + if ($planObj -is [System.Collections.IDictionary]) { + if ($planObj.Contains('Warnings')) { $warningSink = $planObj['Warnings'] } + } else { + $wProp = $planObj.PSObject.Properties['Warnings'] + if ($null -ne $wProp) { $warningSink = $wProp.Value } + } + } + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' -AllowMissingRequestContextPaths -WarningSink $warningSink + $normalized.Precondition = Copy-IdleDataObject -Value $rawPrecondition + } + } + + if (Test-IdleWorkflowStepKey -Step $Step -Key 'OnPreconditionFalse') { + $rawOnPreconditionFalseValue = Get-IdlePropertyValue -Object $Step -Name 'OnPreconditionFalse' + if ($null -ne $rawOnPreconditionFalseValue) { + $rawOnPreconditionFalse = [string]$rawOnPreconditionFalseValue + if (-not [string]::IsNullOrWhiteSpace($rawOnPreconditionFalse)) { + if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $StepName, $rawOnPreconditionFalse), + 'Workflow' + ) + } + + $normalized.OnPreconditionFalse = $rawOnPreconditionFalse + } + } + } + + if (Test-IdleWorkflowStepKey -Step $Step -Key 'PreconditionEvent') { + $rawPreconditionEvent = Get-IdlePropertyValue -Object $Step -Name 'PreconditionEvent' + if ($null -ne $rawPreconditionEvent) { + if ($rawPreconditionEvent -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent must be a hashtable." -f $StepName), + 'Workflow' + ) + } + + $pcEvtType = if ($rawPreconditionEvent.Contains('Type')) { [string]$rawPreconditionEvent['Type'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtType)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Type is required and must be a non-empty string." -f $StepName), + 'Workflow' + ) + } + + $pcEvtMsg = if ($rawPreconditionEvent.Contains('Message')) { [string]$rawPreconditionEvent['Message'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtMsg)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Message is required and must be a non-empty string." -f $StepName), + 'Workflow' + ) + } + + # PreconditionEvent.Data is optional but must be a hashtable if present. + if ($rawPreconditionEvent.Contains('Data') -and $null -ne $rawPreconditionEvent['Data']) { + if ($rawPreconditionEvent['Data'] -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Data must be a hashtable." -f $StepName), + 'Workflow' + ) + } + } + + $normalized.PreconditionEvent = Copy-IdleDataObject -Value $rawPreconditionEvent + } + } + + return $normalized +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 22da8a13..64779e4c 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -80,6 +80,8 @@ function ConvertTo-IdleWorkflowSteps { ) } + Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' + $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext if (-not $isApplicable) { $status = 'NotApplicable' @@ -126,89 +128,10 @@ function ConvertTo-IdleWorkflowSteps { $null } - # Runtime Preconditions: evaluated at execution time (not planning time). - # Each precondition uses the same declarative condition DSL as Condition. - $preconditions = $null - if (Test-IdleWorkflowStepKey -Step $s -Key 'Preconditions') { - $rawPreconditions = Get-IdlePropertyValue -Object $s -Name 'Preconditions' - if ($null -ne $rawPreconditions) { - $pcList = @($rawPreconditions) - for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { - $pc = $pcList[$pcIdx] - if ($pc -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $stepName, $pcIdx), - 'Workflow' - ) - } - $pcErrors = Test-IdleConditionSchema -Condition $pc -StepName $stepName - if (@($pcErrors).Count -gt 0) { - throw [System.ArgumentException]::new( - ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $stepName, ([string]::Join(' ', @($pcErrors)))), - 'Workflow' - ) - } - } - $preconditions = @() - foreach ($pc in $pcList) { - $preconditions += Copy-IdleDataObject -Value $pc - } - } - } - - $onPreconditionFalse = $null - if (Test-IdleWorkflowStepKey -Step $s -Key 'OnPreconditionFalse') { - $rawOnPreconditionFalseValue = Get-IdlePropertyValue -Object $s -Name 'OnPreconditionFalse' - if ($null -ne $rawOnPreconditionFalseValue) { - $rawOnPreconditionFalse = [string]$rawOnPreconditionFalseValue - if (-not [string]::IsNullOrWhiteSpace($rawOnPreconditionFalse)) { - if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), - 'Workflow' - ) - } - $onPreconditionFalse = $rawOnPreconditionFalse - } - } - } - - $preconditionEvent = $null - if (Test-IdleWorkflowStepKey -Step $s -Key 'PreconditionEvent') { - $rawPreconditionEvent = Get-IdlePropertyValue -Object $s -Name 'PreconditionEvent' - if ($null -ne $rawPreconditionEvent) { - if ($rawPreconditionEvent -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent must be a hashtable." -f $stepName), - 'Workflow' - ) - } - $pcEvtType = if ($rawPreconditionEvent.Contains('Type')) { [string]$rawPreconditionEvent['Type'] } else { $null } - if ([string]::IsNullOrWhiteSpace($pcEvtType)) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent.Type is required and must be a non-empty string." -f $stepName), - 'Workflow' - ) - } - $pcEvtMsg = if ($rawPreconditionEvent.Contains('Message')) { [string]$rawPreconditionEvent['Message'] } else { $null } - if ([string]::IsNullOrWhiteSpace($pcEvtMsg)) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent.Message is required and must be a non-empty string." -f $stepName), - 'Workflow' - ) - } - # PreconditionEvent.Data is optional but must be a hashtable if present. - if ($rawPreconditionEvent.Contains('Data') -and $null -ne $rawPreconditionEvent['Data']) { - if ($rawPreconditionEvent['Data'] -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent.Data must be a hashtable." -f $stepName), - 'Workflow' - ) - } - } - $preconditionEvent = Copy-IdleDataObject -Value $rawPreconditionEvent - } - } + $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName -PlanningContext $PlanningContext + $precondition = $preconditionSettings.Precondition + $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse + $preconditionEvent = $preconditionSettings.PreconditionEvent $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' @@ -216,7 +139,7 @@ function ConvertTo-IdleWorkflowSteps { Type = $stepType Description = $description Condition = Copy-IdleDataObject -Value $condition - Preconditions = $preconditions + Precondition = $precondition OnPreconditionFalse = $onPreconditionFalse PreconditionEvent = $preconditionEvent With = $with diff --git a/src/IdLE.Core/Private/Test-IdlePathExists.ps1 b/src/IdLE.Core/Private/Test-IdlePathExists.ps1 new file mode 100644 index 00000000..4807573a --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdlePathExists.ps1 @@ -0,0 +1,39 @@ +Set-StrictMode -Version Latest + +function Test-IdlePathExists { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + $current = $Object + foreach ($segment in ($Path -split '\.')) { + if ($null -eq $current) { + return $false + } + + if ($current -is [System.Collections.IDictionary]) { + if (-not $current.Contains($segment)) { + return $false + } + + $current = $current[$segment] + continue + } + + $prop = $current.PSObject.Properties[$segment] + if ($null -eq $prop) { + return $false + } + + $current = $prop.Value + } + + return $true +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 767fb7e3..75671529 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -23,7 +23,7 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Preconditions', 'OnPreconditionFalse', 'PreconditionEvent') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Precondition', 'OnPreconditionFalse', 'PreconditionEvent') foreach ($k in $Step.Keys) { if ($allowedStepKeys -notcontains $k) { $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -57,8 +57,8 @@ function Test-IdleWorkflowSchema { } } - # Helper: Validate Preconditions, OnPreconditionFalse, and PreconditionEvent fields on a step. - function Test-IdleWorkflowStepPreconditions { + # Helper: Validate Precondition, OnPreconditionFalse, and PreconditionEvent fields on a step. + function Test-IdleWorkflowStepPreconditionSettings { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -72,19 +72,13 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - if ($Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions) { - if ($Step.Preconditions -is [string] -or - $Step.Preconditions -is [System.Collections.IDictionary] -or - -not ($Step.Preconditions -is [System.Collections.IEnumerable])) { - $ErrorList.Add("'$StepPath.Preconditions' must be an array/list of condition hashtables (not a single hashtable).") + if ($Step.ContainsKey('Precondition') -and $null -ne $Step.Precondition) { + if ($Step.Precondition -isnot [System.Collections.IDictionary]) { + $ErrorList.Add("'$StepPath.Precondition' must be a hashtable (condition node).") } else { - $pcIdx = 0 - foreach ($pc in @($Step.Preconditions)) { - if ($pc -isnot [System.Collections.IDictionary]) { - $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' must be a hashtable (condition node).") - } - $pcIdx++ + foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Precondition) -StepName $null)) { + $ErrorList.Add("'$StepPath.Precondition' has invalid condition schema: $schemaError") } } } @@ -115,129 +109,124 @@ function Test-IdleWorkflowSchema { } } - $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers') - foreach ($key in $Workflow.Keys) { - if ($allowedRootKeys -notcontains $key) { - $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") + function Test-IdleWorkflowStepCondition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Step, + + [Parameter(Mandatory)] + [string] $StepPath, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + # Conditions use the same declarative condition DSL as runtime preconditions. + if ($Step.ContainsKey('Condition') -and $null -ne $Step.Condition) { + if ($Step.Condition -isnot [System.Collections.IDictionary]) { + $ErrorList.Add("'$StepPath.Condition' must be a hashtable (declarative condition object).") + } + else { + foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Condition) -StepName $null)) { + $ErrorList.Add("'$StepPath.Condition' has invalid condition schema: $schemaError") + } + } } } - if (-not $Workflow.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$Workflow.Name)) { - $errors.Add("Missing or empty required root key 'Name'.") - } + function Test-IdleWorkflowStepCollection { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $StepCollection, - if (-not $Workflow.ContainsKey('LifecycleEvent') -or [string]::IsNullOrWhiteSpace([string]$Workflow.LifecycleEvent)) { - $errors.Add("Missing or empty required root key 'LifecycleEvent'.") - } + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $CollectionName, + + [Parameter(Mandatory)] + [string] $DuplicateNameErrorTemplate, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + if ($StepCollection -isnot [System.Collections.IEnumerable] -or + $StepCollection -is [string] -or + $StepCollection -is [System.Collections.IDictionary]) { + $ErrorList.Add("'$CollectionName' must be an array/list of step hashtables.") + return + } - if (-not $Workflow.ContainsKey('Steps') -or $null -eq $Workflow.Steps) { - $errors.Add("Missing required root key 'Steps'.") - } - elseif ($Workflow.Steps -isnot [System.Collections.IEnumerable] -or $Workflow.Steps -is [string]) { - $errors.Add("'Steps' must be an array/list of step hashtables.") - } - else { $stepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $i = 0 - foreach ($step in $Workflow.Steps) { - $stepPath = "Steps[$i]" + foreach ($step in $StepCollection) { + $stepPath = "{0}[{1}]" -f $CollectionName, $i if ($null -eq $step -or $step -isnot [hashtable]) { - $errors.Add("$stepPath must be a hashtable.") + $ErrorList.Add("$stepPath must be a hashtable.") $i++ continue } - Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $errors + Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $ErrorList if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { - $errors.Add("Missing or empty required key '$stepPath.Name'.") + $ErrorList.Add("Missing or empty required key '$stepPath.Name'.") } else { if (-not $stepNames.Add([string]$step.Name)) { - $errors.Add("Duplicate step name '$($step.Name)' detected. Step names must be unique.") + $ErrorList.Add(($DuplicateNameErrorTemplate -f [string]$step.Name)) } } if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) { - $errors.Add("Missing or empty required key '$stepPath.Type'.") + $ErrorList.Add("Missing or empty required key '$stepPath.Type'.") } - # Conditions must be declarative data, never a ScriptBlock/expression. - # We only enforce the shape here; semantic validation comes later. - if ($step.ContainsKey('Condition') -and $null -ne $step.Condition -and $step.Condition -isnot [hashtable]) { - $errors.Add("'$stepPath.Condition' must be a hashtable (declarative condition object).") - } + Test-IdleWorkflowStepCondition -Step $step -StepPath $stepPath -ErrorList $ErrorList # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) { - $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") + $ErrorList.Add("'$stepPath.With' must be a hashtable (step parameters).") } - # Validate RetryProfile - Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors - - # Validate Preconditions, OnPreconditionFalse, PreconditionEvent - Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $ErrorList + Test-IdleWorkflowStepPreconditionSettings -Step $step -StepPath $stepPath -ErrorList $ErrorList $i++ } } - # OnFailureSteps are optional. If present, validate them like regular Steps. - if ($Workflow.ContainsKey('OnFailureSteps') -and $null -ne $Workflow.OnFailureSteps) { - if ($Workflow.OnFailureSteps -isnot [System.Collections.IEnumerable] -or $Workflow.OnFailureSteps -is [string]) { - $errors.Add("'OnFailureSteps' must be an array/list of step hashtables.") + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers') + foreach ($key in $Workflow.Keys) { + if ($allowedRootKeys -notcontains $key) { + $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") } - else { - $failureStepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - - $i = 0 - foreach ($step in $Workflow.OnFailureSteps) { - $stepPath = "OnFailureSteps[$i]" - - if ($null -eq $step -or $step -isnot [hashtable]) { - $errors.Add("$stepPath must be a hashtable.") - $i++ - continue - } - - 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'.") - } - else { - if (-not $failureStepNames.Add([string]$step.Name)) { - $errors.Add("Duplicate step name '$($step.Name)' detected in 'OnFailureSteps'. Step names must be unique within this collection.") - } - } - - if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) { - $errors.Add("Missing or empty required key '$stepPath.Type'.") - } - - # Conditions must be declarative data, never a ScriptBlock/expression. - # We only enforce the shape here; semantic validation comes later. - if ($step.ContainsKey('Condition') -and $null -ne $step.Condition -and $step.Condition -isnot [hashtable]) { - $errors.Add("'$stepPath.Condition' must be a hashtable (declarative condition object).") - } + } - # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. - if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) { - $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") - } + if (-not $Workflow.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$Workflow.Name)) { + $errors.Add("Missing or empty required root key 'Name'.") + } - # Validate RetryProfile - Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors + if (-not $Workflow.ContainsKey('LifecycleEvent') -or [string]::IsNullOrWhiteSpace([string]$Workflow.LifecycleEvent)) { + $errors.Add("Missing or empty required root key 'LifecycleEvent'.") + } - # Validate Preconditions, OnPreconditionFalse, PreconditionEvent - Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + if (-not $Workflow.ContainsKey('Steps') -or $null -eq $Workflow.Steps) { + $errors.Add("Missing required root key 'Steps'.") + } + else { + Test-IdleWorkflowStepCollection -StepCollection $Workflow.Steps -CollectionName 'Steps' -DuplicateNameErrorTemplate "Duplicate step name '{0}' detected. Step names must be unique." -ErrorList $errors + } - $i++ - } - } + # OnFailureSteps are optional. If present, validate them like regular Steps. + if ($Workflow.ContainsKey('OnFailureSteps') -and $null -ne $Workflow.OnFailureSteps) { + Test-IdleWorkflowStepCollection -StepCollection $Workflow.OnFailureSteps -CollectionName 'OnFailureSteps' -DuplicateNameErrorTemplate "Duplicate step name '{0}' detected in 'OnFailureSteps'. Step names must be unique within this collection." -ErrorList $errors } # ContextResolvers are optional. If present, validate each resolver entry. diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index f060f765..f3484670 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -318,24 +318,20 @@ function Invoke-IdlePlanObject { continue } - # Runtime Preconditions: evaluated immediately before step execution (online, not planning-time). + # Runtime Precondition: evaluated immediately before step execution (online, not planning-time). # Blocked = policy/precondition gate (does not trigger OnFailureSteps). Stops execution. # Fail = treated as a technical failure (triggers OnFailureSteps). Stops execution. # Continue = emits events but skips the step and continues to the next step. # Non-IDictionary precondition nodes are treated as precondition failures (fail closed). - $stepPreconditions = Get-IdlePropertyValue -Object $step -Name 'Preconditions' - if ($null -ne $stepPreconditions -and @($stepPreconditions).Count -gt 0) { + $stepPrecondition = Get-IdlePropertyValue -Object $step -Name 'Precondition' + if ($null -ne $stepPrecondition) { $preconditionPassed = $true - foreach ($pc in @($stepPreconditions)) { - if ($pc -isnot [System.Collections.IDictionary]) { - # Fail closed: a malformed or unexpected node type is treated as a failed precondition. - $preconditionPassed = $false - break - } - if (-not (Test-IdleCondition -Condition ([hashtable]$pc) -Context $preconditionContext)) { - $preconditionPassed = $false - break - } + if ($stepPrecondition -isnot [System.Collections.IDictionary]) { + # Fail closed: a malformed or unexpected node type is treated as a failed precondition. + $preconditionPassed = $false + } + elseif (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) { + $preconditionPassed = $false } if (-not $preconditionPassed) { @@ -621,21 +617,17 @@ function Invoke-IdlePlanObject { continue } - # Runtime Preconditions for OnFailure steps: evaluated immediately before execution. + # Runtime Precondition for OnFailure steps: evaluated immediately before execution. # OnFailure runs best-effort, so precondition failures skip the step but do not halt # remaining OnFailure steps. Non-IDictionary nodes are treated as failures (fail closed). - $ofPreconditions = Get-IdlePropertyValue -Object $ofStep -Name 'Preconditions' - if ($null -ne $ofPreconditions -and @($ofPreconditions).Count -gt 0) { + $ofPrecondition = Get-IdlePropertyValue -Object $ofStep -Name 'Precondition' + if ($null -ne $ofPrecondition) { $ofPreconditionPassed = $true - foreach ($opc in @($ofPreconditions)) { - if ($opc -isnot [System.Collections.IDictionary]) { - $ofPreconditionPassed = $false - break - } - if (-not (Test-IdleCondition -Condition ([hashtable]$opc) -Context $preconditionContext)) { - $ofPreconditionPassed = $false - break - } + if ($ofPrecondition -isnot [System.Collections.IDictionary]) { + $ofPreconditionPassed = $false + } + elseif (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { + $ofPreconditionPassed = $false } if (-not $ofPreconditionPassed) { diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 0c863142..43c473df 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -107,7 +107,7 @@ function New-IdlePlanObject { OnFailureSteps = @() Actions = @() - Warnings = @() + Warnings = [System.Collections.ArrayList]::new() Providers = $Providers } diff --git a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 new file mode 100644 index 00000000..3b78f425 --- /dev/null +++ b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 @@ -0,0 +1,82 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Assert-IdleConditionPathsResolvable' { + InModuleScope 'IdLE.Core' { + It 'accepts condition paths that exist in planning context' { + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ Exists = 'Request.Intent.OffboardingDate' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'CheckPaths' -Source 'Condition' + } | Should -Not -Throw + } + + It 'throws when at least one condition path does not exist in planning context' { + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ Exists = 'Request.Context.OffboardingDate' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'CheckPaths' -Source 'Condition' + } | Should -Throw + } + + It 'reports unresolved paths in one clear error message with step/source context' { + $condition = @{ + All = @( + @{ Exists = 'Request.Context.OffboardingDate' } + @{ Exists = 'Request.Context.Manager' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'RegionCheck' -Source 'Precondition' + } | Should -Throw "*Workflow step 'RegionCheck' has unresolved condition path(s) in Precondition:*Request.Context.OffboardingDate*Request.Context.Manager*" + } + + It 'can allow unresolved Request.Context paths when explicitly enabled' { + $condition = @{ + All = @( + @{ Exists = 'Request.Context.OffboardingDate' } + @{ Exists = 'Request.Context.Manager' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'RegionCheck' -Source 'Precondition' -AllowMissingRequestContextPaths + } | Should -Not -Throw + } + } +} diff --git a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 new file mode 100644 index 00000000..1b25097e --- /dev/null +++ b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 @@ -0,0 +1,80 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { + InModuleScope 'IdLE.Core' { + It 'returns null values when the step does not define precondition settings' { + $step = @{ Name = 'Noop'; Type = 'IdLE.Step.Noop' } + + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'Noop' -PlanningContext $planningContext + + $result.Precondition | Should -BeNullOrEmpty + $result.OnPreconditionFalse | Should -BeNullOrEmpty + $result.PreconditionEvent | Should -BeNullOrEmpty + } + + It 'normalizes valid precondition settings and deep-copies the data' { + $step = @{ + Name = 'GuardedStep' + Type = 'IdLE.Step.Noop' + Precondition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + OnPreconditionFalse = 'Continue' + PreconditionEvent = @{ + Type = 'ManualActionRequired' + Message = 'Operator action needed' + Data = @{ Ticket = 'INC-1234' } + } + } + + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'GuardedStep' -PlanningContext $planningContext + + $result.Precondition.Equals.Path | Should -Be 'Plan.LifecycleEvent' + $result.OnPreconditionFalse | Should -Be 'Continue' + $result.PreconditionEvent.Type | Should -Be 'ManualActionRequired' + $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' + + # Verify deep-copy behavior. + $step.PreconditionEvent.Data.Ticket = 'CHANGED' + $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' + } + + It 'does not throw when precondition uses unresolved Request.Context path in planning context' { + $step = @{ + Name = 'MissingPath' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.Context.OffboardingDate' } + } + + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'MissingPath' -PlanningContext $planningContext } | Should -Not -Throw + } + + It 'throws when precondition uses unresolved non-context path in planning context' { + $step = @{ + Name = 'MissingIntentPath' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.Intent.OffboardingDate' } + } + + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'MissingIntentPath' -PlanningContext $planningContext } | Should -Throw + } + + It 'throws when OnPreconditionFalse has an invalid value' { + $step = @{ + Name = 'InvalidPolicy' + Type = 'IdLE.Step.Noop' + OnPreconditionFalse = 'StopAll' + } + + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'InvalidPolicy' -PlanningContext $planningContext } | Should -Throw + } + } +} diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index b8482b14..8d45f92c 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -80,6 +80,47 @@ Describe 'Export-IdlePlan' { } } + Context 'Planning warnings export' { + It 'includes planning warnings in exported plan for CI checks' { + $cid = '22222222-2222-2222-2222-222222222222' + + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-export-warning.psd1' -Content @' +@{ + Name = 'Joiner - Export Warning Fixture' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Check Context' + Type = 'EnsureMailbox' + Precondition = @{ Exists = 'Request.Context.OffboardingDate' } + With = @{ mailboxType = 'User' } + } + ) +} +'@ + + $req = New-IdleTestRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` + -Intent ([ordered]@{ department = 'IT' }) + + $providers = @{ + Dummy = $true + StepRegistry = @{ 'EnsureMailbox' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('EnsureMailbox') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + @($plan.Warnings).Count | Should -BeGreaterThan 0 + + $json = $plan | Export-IdlePlan | ConvertFrom-Json + @($json.plan.warnings).Count | Should -BeGreaterThan 0 + $json.plan.warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' + $json.plan.warnings[0].step | Should -Be 'Check Context' + } + } Context 'Contract invariants' { It 'always includes schemaVersion 1.0' { $cid = '11111111-1111-1111-1111-111111111111' diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 31b6e19b..8b8c19cb 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -320,12 +320,12 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw } - It 'throws when Preconditions is a single hashtable instead of an array' { + It 'accepts Precondition as a single condition object' { $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-single-hashtable.psd1' $req = New-IdleRequest -LifecycleEvent 'Joiner' $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPCSingleHt') } - { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Not -Throw } } diff --git a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 new file mode 100644 index 00000000..37396e44 --- /dev/null +++ b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 @@ -0,0 +1,87 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Workflow schema validation - Condition/Precondition DSL parity' { + InModuleScope 'IdLE.Core' { + It 'rejects invalid Condition DSL nodes at definition validation time' { + $workflow = @{ + Name = 'Condition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'InvalidCondition' + Type = 'IdLE.Step.Noop' + Condition = @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors | Where-Object { $_ -like "*Steps``[0``].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + } + + It 'rejects invalid Condition DSL nodes in OnFailureSteps at definition validation time' { + $workflow = @{ + Name = 'Condition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Primary'; Type = 'IdLE.Step.Noop' } + ) + OnFailureSteps = @( + @{ + Name = 'InvalidOnFailureCondition' + Type = 'IdLE.Step.Noop' + Condition = @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors | Where-Object { $_ -like "*OnFailureSteps``[0``].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + } + + It 'rejects invalid Precondition DSL node at definition validation time' { + $workflow = @{ + Name = 'Precondition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'InvalidPrecondition' + Type = 'IdLE.Step.Noop' + Precondition = @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors | Where-Object { $_ -like "*Steps``[0``].Precondition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + } + + It 'accepts valid precondition using the same condition DSL' { + $workflow = @{ + Name = 'Precondition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ValidPrecondition' + Type = 'IdLE.Step.Noop' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + Precondition = @{ + All = @( + @{ Exists = @{ Path = 'Request.IdentityKeys.EmployeeId' } } + @{ In = @{ Path = 'Plan.LifecycleEvent'; Values = @('Joiner', 'Mover') } } + ) + } + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors).Count | Should -Be 0 + } + } +} diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index a21c7d09..adbf1042 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -35,7 +35,8 @@ }, "expectedState": null } - ] + ], + "warnings": [] }, "metadata": { "generatedBy": "Export-IdlePlanObject", diff --git a/tests/fixtures/workflows/preconditions/blocked-default.psd1 b/tests/fixtures/workflows/preconditions/blocked-default.psd1 index a216facd..0bf4cdb1 100644 --- a/tests/fixtures/workflows/preconditions/blocked-default.psd1 +++ b/tests/fixtures/workflows/preconditions/blocked-default.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.BlockedDefault' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } } ) } diff --git a/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 index ebfccaf4..e0ef8ab7 100644 --- a/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 +++ b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.BlockedNoOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } } ) OnFailureSteps = @( diff --git a/tests/fixtures/workflows/preconditions/blocked.psd1 b/tests/fixtures/workflows/preconditions/blocked.psd1 index 8d51859a..2faa473b 100644 --- a/tests/fixtures/workflows/preconditions/blocked.psd1 +++ b/tests/fixtures/workflows/preconditions/blocked.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.BlockedPrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Blocked' } @{ diff --git a/tests/fixtures/workflows/preconditions/continue-event.psd1 b/tests/fixtures/workflows/preconditions/continue-event.psd1 index a3f5ea0d..5f10bcc7 100644 --- a/tests/fixtures/workflows/preconditions/continue-event.psd1 +++ b/tests/fixtures/workflows/preconditions/continue-event.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.ContinuePreconditionEvent' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Continue' PreconditionEvent = @{ Type = 'PolicyAdvisory' diff --git a/tests/fixtures/workflows/preconditions/continue.psd1 b/tests/fixtures/workflows/preconditions/continue.psd1 index 0d56086c..960f5f1b 100644 --- a/tests/fixtures/workflows/preconditions/continue.psd1 +++ b/tests/fixtures/workflows/preconditions/continue.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.ContinuePrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Continue' } @{ diff --git a/tests/fixtures/workflows/preconditions/event.psd1 b/tests/fixtures/workflows/preconditions/event.psd1 index 3e6cacf1..1c4956a3 100644 --- a/tests/fixtures/workflows/preconditions/event.psd1 +++ b/tests/fixtures/workflows/preconditions/event.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.PreconditionEvent' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } PreconditionEvent = @{ Type = 'ManualActionRequired' Message = 'Perform Intune wipe before proceeding' diff --git a/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 index ada7f0be..c25b05a0 100644 --- a/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 +++ b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailRunsOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } ) diff --git a/tests/fixtures/workflows/preconditions/fail.psd1 b/tests/fixtures/workflows/preconditions/fail.psd1 index 1a572cf7..3f10d445 100644 --- a/tests/fixtures/workflows/preconditions/fail.psd1 +++ b/tests/fixtures/workflows/preconditions/fail.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailPrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } @{ diff --git a/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 index 8de5d90a..c1a640cb 100644 --- a/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidPCEvt' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } PreconditionEvent = @{ Message = 'Some message' } diff --git a/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 index 166161e8..55206a77 100644 --- a/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidOPF' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Skip' } ) diff --git a/tests/fixtures/workflows/preconditions/invalid-schema.psd1 b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 index 3caa0e71..7206bf35 100644 --- a/tests/fixtures/workflows/preconditions/invalid-schema.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 @@ -5,11 +5,13 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidPreconditionSchema' - Preconditions = @( + Precondition = @{ + All = @( @{ UnknownKey = 'bad' } - ) + ) + } } ) } diff --git a/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 index 316f51b7..964b1642 100644 --- a/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 @@ -5,7 +5,7 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidPCSingleHt' - Preconditions = @{ + Precondition = @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' diff --git a/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 index 5fcc341c..cc339c16 100644 --- a/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 +++ b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailRunsOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } ) @@ -20,14 +22,16 @@ @{ Name = 'Cleanup' Type = 'IdLE.Step.OnFailureCleanup' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } } ) } diff --git a/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 index 9ac0b888..3062c8ef 100644 --- a/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 +++ b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailRunsOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } ) @@ -20,14 +22,16 @@ @{ Name = 'Cleanup' Type = 'IdLE.Step.OnFailureCleanup' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Continue' } ) diff --git a/tests/fixtures/workflows/preconditions/passing.psd1 b/tests/fixtures/workflows/preconditions/passing.psd1 index 4374340b..dcb17e4a 100644 --- a/tests/fixtures/workflows/preconditions/passing.psd1 +++ b/tests/fixtures/workflows/preconditions/passing.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.PassingPrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Leaver' } } - ) + ) + } } ) }