diff --git a/docs/about/security.md b/docs/about/security.md index b9168887..b8723440 100644 --- a/docs/about/security.md +++ b/docs/about/security.md @@ -79,6 +79,10 @@ IdLE applies **secure defaults** to reduce accidental code execution: - Step registry handlers must be function names (strings); ScriptBlock handlers are rejected. - Event streaming uses an object-based contract (`WriteEvent(event)`); ScriptBlock event sinks are rejected. - AuthSessionBroker objects should not contain ScriptBlock properties; use ScriptMethods or direct function calls instead. +- Step `State` (values later steps can read) must match the step's `StateSchema` and is rejected if it contains + ScriptBlocks, `PSCredential` or `SecureString`. `With` templates that read `State` come from the workflow + definition only; resolved request values are never scanned for templates again + (see [Step State](../extend/steps.md#step-state)). ## Redaction at output boundaries diff --git a/docs/extend/steps.md b/docs/extend/steps.md index e25f368c..6636abcd 100644 --- a/docs/extend/steps.md +++ b/docs/extend/steps.md @@ -133,6 +133,14 @@ not set (used by the mailbox steps: `ExchangeOnline`). Steps without `Provider` - Invalid metadata fails when the catalog is loaded. - Metadata must be data-only — ScriptBlocks are rejected. +**`StateSchema`** (optional): declares the `State` the step publishes for later steps, with the same key schema +format as `WithSchema` (`Keys`, `AllowAdditionalKeys`). A step without `StateSchema` must not return `State`. +See [Step State](#step-state). + +```powershell +StateSchema = @{ Keys = @{ Mailbox = @{ Type = 'Any'; Required = $true } } } +``` + Host-supplied step types may supplement (but not override) the catalog via `Providers.StepMetadata`. ### Step handler contract @@ -185,11 +193,55 @@ looks it up in `Providers` and passes a handle. The same alias feeds `Request.Co | --- | --- | --- | | `Changed` | Bool | The step changed external state. Default `$false`. | | `Data` | Hashtable | Structured step output (e.g. per-attribute results, skipped entitlements). | -| `State` | Hashtable | Values intended for later steps. Reserved: returned in the step result, not yet consumed by the engine. | +| `State` | Hashtable | Values for later steps of the same run; must match the step's `StateSchema` (see [Step State](#step-state)). | The engine builds `IdLE.StepResult` (`Name`, `Type`, `Status`, `Changed`, `Data`, `State`, `Error`, `Attempts`, `Source`). Failures are reported by throwing; transient failures use the transient marker for retries. +### Step State + +A step publishes `State` under its step name. Later steps read it as `State..[....]`: + +```powershell +Steps = @( + @{ + Name = 'GetMailbox' # referenced names must be identifiers (letters, digits, underscores) + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ IdentityKey = '{{Request.Intent.UserPrincipalName}}' } + } + @{ + Name = 'ConvertToShared' + Type = 'IdLE.Step.Mailbox.EnsureType' + Precondition = @{ Equals = @{ Path = 'State.GetMailbox.Mailbox.Type'; Value = 'User' } } + OnPreconditionFalse = 'Continue' + With = @{ IdentityKey = '{{State.GetMailbox.Mailbox.PrimarySmtpAddress}}'; MailboxType = 'Shared' } + } +) +``` + +| Place | State supported | +| --- | --- | +| `Condition` | No: conditions are evaluated at planning time, before any step has run (`StateReferenceNotAllowed`). | +| `Precondition` | Yes: evaluated right before the step. | +| `With` templates | Yes: a `With` string with a `{{State...}}` placeholder is resolved right before the step (not for `Provider`, `AuthSessionName`, `AuthSessionOptions`). | + +**Planning** validates every reference: the producing step must run before the referencing step (`Steps` see earlier +`Steps`; `OnFailureSteps` see all `Steps` and earlier `OnFailureSteps`) and must declare `` in its +`StateSchema`. Paths below `` are not validated. A `With` string that references State stays unresolved in the +plan (and in the plan export); its `Request` placeholders must resolve at planning time, and its `WithSchema` check +runs after the execution-time resolution. Only these strings are resolved again, so request values cannot inject +`State` placeholders. + +**Execution:** + +- The engine validates the returned `State` against the `StateSchema` and stores a data-only copy after the step + completes. Steps that are `NotApplicable`, `PreconditionSkipped`, `Blocked` or `Failed` publish nothing. +- In a `Precondition`, a State path whose producer published nothing resolves to `$null`; use `Exists` to check + that the producer ran. +- A `With` placeholder that resolves to `$null` fails the step (`StateReferenceUnresolved`). +- State must not contain ScriptBlocks, `PSCredential` or `SecureString`; authentication stays with the + `AuthSessionBroker`. State is not written to events. + ### Error codes Error messages start with the code, followed by the step name, the step type and, for `With` errors, the path. @@ -201,12 +253,19 @@ Error messages start with the code, followed by the step name, the step type and | `WithKeyMissing` | Required key absent or `$null`. | Plan | | `WithKeyUnknown` | Key not in the schema (and no `AllowAdditionalKeys`). | Plan | | `WithKeyTypeMismatch` | Value does not match `Type`. | Plan | -| `WithValueNotAllowed` | Value not in `AllowedValues`, or empty without `AllowEmpty`. | Plan | +| `WithValueNotAllowed` | Value not in `AllowedValues`, or empty without `AllowEmpty`. | Plan (State templates: execution) | +| `InvalidStateSchema` | Invalid `StateSchema` (missing `Keys`, invalid key schema, ...). | Catalog load | +| `StateReferenceNotAllowed` | `State.*` in `Condition`, or in `With.Provider` / `AuthSessionName` / `AuthSessionOptions`. | Plan | +| `StateReferenceInvalid` | No step name after `State`, or the step name is not an identifier. | Plan | +| `StateReferenceUnknownStep` | No step with that name runs before the referencing step. | Plan | +| `StateReferenceAmbiguous` | Referenced from `OnFailureSteps`, and the name exists in both `Steps` and `OnFailureSteps`. | Plan | +| `StateReferenceNotDeclared` | The producing step declares no `StateSchema`, or not the referenced key. | Plan | | `LegacyStepHandler` | Handler has no `-With` parameter (pre-1.0 contract). | Before the first attempt | | `InvalidStepHandlerSignature` | `-Provider` declared without metadata `Provider`, or vice versa. | Before the first attempt | +| `StateReferenceUnresolved` | A `With` State placeholder resolves to `$null` (producer did not run or did not return the value). | Before the first attempt | | `ProviderNotSupplied` | Resolved alias not in `Providers`. | Execution (hosts may pass providers only to `Invoke-IdlePlan`) | | `ProviderMethodNotImplemented` | `Invoke` of a method the provider does not implement. | Execution | -| `InvalidStepHandlerResult` | Result is not `$null` / hashtable, has unknown keys, or the handler leaked pipeline output. | Execution | +| `InvalidStepHandlerResult` | Result is not `$null` / hashtable, has unknown keys, the handler leaked pipeline output, or `State` does not match the `StateSchema`. | Execution | ### Migration from earlier versions @@ -425,11 +484,10 @@ Steps must not: ## Inputs -Steps receive inputs from the workflow under `Inputs` and may reference: +Steps receive their inputs from the workflow under `With`. `With` templates may reference: -- `Request.*` -- `State.*` -- `Policy.*` (optional root, host-defined) +- `Request.*` (resolved at planning time) +- `State..*` (resolved right before the step runs, see [Step State](#step-state)) ### Security: Data-only constraint @@ -449,8 +507,8 @@ Assert-IdleNoScriptBlock -InputObject $value -Path 'Request.Context.Something' ## Outputs -Steps may write to `State.*` only, and only to declared output paths. -This prevents hidden coupling between steps. +Steps pass values to later steps only through the `State` result key, and only with the keys declared in their +`StateSchema` (see [Step State](#step-state)). This prevents hidden coupling between steps. ### Eventing diff --git a/docs/reference/steps/step-mailbox-get-info.md b/docs/reference/steps/step-mailbox-get-info.md index 73f1931b..6f04fdae 100644 --- a/docs/reference/steps/step-mailbox-get-info.md +++ b/docs/reference/steps/step-mailbox-get-info.md @@ -37,6 +37,14 @@ Validated at plan time against the step's ``WithSchema`` (see [Step metadata con | `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | | `Provider` | String | No | `ExchangeOnline` | Provider alias in `Providers`. | +## Outputs (State.*) + +Published under `State..` for later steps (`Precondition` and `With` templates); validated against the step's `StateSchema` (see [Step State](../../extend/steps.md#step-state)). + +| Key | Type | Always set | Notes | +| --- | --- | --- | --- | +| `Mailbox` | Any | Yes | | + ## Example ```powershell diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index 63de2978..d0a2d36d 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -290,6 +290,8 @@ This follows PowerShell's native member-access enumeration behavior. - Each node may contain exactly one operator or group - Unknown keys cause planning-time errors - Missing or empty `Path` causes validation errors +- `State.*` paths are rejected (`StateReferenceNotAllowed`): no step has run at planning time. Use a + [Precondition](./preconditions.md#state-of-earlier-steps) to act on values of earlier steps --- diff --git a/docs/use/workflows/preconditions.md b/docs/use/workflows/preconditions.md index cb78b639..81765eb5 100644 --- a/docs/use/workflows/preconditions.md +++ b/docs/use/workflows/preconditions.md @@ -70,6 +70,38 @@ If the precondition evaluates to false: For the complete DSL reference, see: [Conditions → Condition DSL](./conditions.md) ::: +Preconditions resolve paths against `Request.*`, `Plan.*` and `State.*`. + +--- + +## State of earlier steps + +Steps such as `IdLE.Step.Mailbox.GetInfo` publish values for later steps (see the **Outputs (State.\*)** table +on the step's reference page). A precondition reads them as `State..`: + +```powershell +@{ + Name = 'GetMailbox' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ IdentityKey = '{{Request.Intent.UserPrincipalName}}' } +} +@{ + Name = 'ConvertToShared' + Type = 'IdLE.Step.Mailbox.EnsureType' + Precondition = @{ Equals = @{ Path = 'State.GetMailbox.Mailbox.Type'; Value = 'User' } } + OnPreconditionFalse = 'Continue' + With = @{ IdentityKey = '{{Request.Intent.UserPrincipalName}}'; MailboxType = 'Shared' } +} +``` + +- The producing step must run before the step (planning fails otherwise), and its `Name` must be an identifier + (`GetMailbox`, not `Get mailbox`). +- If the producing step did not run (e.g. `NotApplicable`), its State paths resolve to `$null`. Use `Exists` to + check that the producer ran: `@{ Exists = 'State.GetMailbox.Mailbox' }`. +- `Condition` cannot use `State.*`: conditions are evaluated at planning time, before any step has run. + +Details: [Steps → Step State](../../extend/steps.md#step-state). + --- ## Schema diff --git a/docs/use/workflows/templates.md b/docs/use/workflows/templates.md index b4c8c8ba..30bb99fb 100644 --- a/docs/use/workflows/templates.md +++ b/docs/use/workflows/templates.md @@ -5,7 +5,7 @@ sidebar_label: Template Substitution # Template Substitution -Template substitution allows you to reference values from the **planning context** inside step configuration (`With`) values during planning. Conditions and Preconditions use the condition DSL and path resolution and do **not** support `{{...}}` templates. +Template substitution allows you to reference values from the **planning context** inside step configuration (`With`) values during planning, and values published by earlier steps (`State.*`) right before a step runs. Conditions and Preconditions use the condition DSL and path resolution and do **not** support `{{...}}` templates. Templates are **data-only** and safe. No ScriptBlocks or dynamic PowerShell expressions are supported. @@ -14,7 +14,7 @@ No ScriptBlocks or dynamic PowerShell expressions are supported. ## What is Template Substitution? -Template substitution resolves values from: `Request.*` +Template substitution resolves values from: `Request.*` and `State..*` It replaces template placeholders with actual values before execution. @@ -46,6 +46,32 @@ Templates can reference: | `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | | `Request.CorrelationId` | Stable correlation identifier | | `Request.Actor` | Originator of the request | +| `State..*` | State published by an earlier step (step `With` only, see below) | + +--- + +## State of earlier steps + +`{{State..}}` reads a value that an earlier step published (see the **Outputs (State.\*)** table on +the step's reference page): + +```powershell +@{ + Name = 'NotifyServiceDesk' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Offboarded {{Request.Intent.UserPrincipalName}} ({{State.GetMailbox.Mailbox.PrimarySmtpAddress}}).' + } +} +``` + +- A string with a `State` placeholder is resolved **right before the step runs**, not during planning. The plan + (and the plan export) shows the template as written. All other strings are resolved during planning. +- Planning checks that the producing step runs earlier and declares the key, and that the `Request` placeholders of + the string resolve. The `With` type check of the value runs after the resolution. +- If the producing step did not run or did not return the value, the step fails (`StateReferenceUnresolved`). + Guard it with a [Precondition](./preconditions.md#state-of-earlier-steps) if the producer is optional. +- `State` is not available in `Provider`, `AuthSessionName`, `AuthSessionOptions` and in Context Resolvers. --- diff --git a/examples/workflows/templates/exo-leaver.psd1 b/examples/workflows/templates/exo-leaver.psd1 index 3e23de51..04ba7db3 100644 --- a/examples/workflows/templates/exo-leaver.psd1 +++ b/examples/workflows/templates/exo-leaver.psd1 @@ -1,7 +1,7 @@ @{ Name = 'ExchangeOnline Leaver - Mailbox Offboarding' LifecycleEvent = 'Leaver' - Description = 'Converts mailbox to shared and enables Out of Office with dynamic manager/service-desk contact information.' + Description = 'Converts a user mailbox to shared and enables Out of Office with dynamic manager/service-desk contact information.' Steps = @( @{ @@ -12,10 +12,16 @@ IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } + # GetMailboxInfo publishes State.GetMailboxInfo.Mailbox for the following steps. + # Room and equipment mailboxes are left as they are. @{ - Name = 'ConvertToSharedMailbox' - Type = 'IdLE.Step.Mailbox.EnsureType' - With = @{ + Name = 'ConvertToSharedMailbox' + Type = 'IdLE.Step.Mailbox.EnsureType' + Precondition = @{ + In = @{ Path = 'State.GetMailboxInfo.Mailbox.Type'; Values = @('User', 'Shared') } + } + OnPreconditionFalse = 'Continue' + With = @{ Provider = 'ExchangeOnline' IdentityKey = '{{Request.Intent.UserPrincipalName}}' MailboxType = 'Shared' @@ -53,7 +59,8 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Mailbox offboarding completed for {{Request.Intent.UserPrincipalName}}.' + # Resolved when the step runs, after GetMailboxInfo has published its State. + Message = 'Mailbox offboarding completed for {{Request.Intent.UserPrincipalName}} ({{State.GetMailboxInfo.Mailbox.PrimarySmtpAddress}}).' } } ) diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 83945bfe..e361b5dd 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -33,108 +33,17 @@ function Assert-IdleConditionPathsResolvable { [switch] $ExcludeExistsOperatorPaths ) - 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) + # Contains/NotContains/Like/NotLike paths are not validated here (unchanged behavior). + # State paths are checked during planning (State reference validation); at execution a State path whose + # producer published nothing resolves to $null instead of failing. + $validatedOperators = @('Equals', 'NotEquals', 'Exists', 'In') + $paths = foreach ($conditionPath in @(Get-IdleConditionPath -Condition $Condition)) { + if ($validatedOperators -notcontains $conditionPath.Operator) { continue } + if ($ExcludeExistsOperatorPaths -and $conditionPath.Operator -eq 'Exists') { continue } + if ($conditionPath.Path -eq 'State' -or $conditionPath.Path.StartsWith('State.', [System.StringComparison]::OrdinalIgnoreCase)) { continue } + $conditionPath.Path } - function Get-IdleConditionPaths { - param( - [Parameter(Mandatory)] - [System.Collections.IDictionary] $Node, - - [Parameter(Mandatory)] - [AllowEmptyCollection()] - [System.Collections.Generic.List[string]] $PathList, - - [Parameter()] - [switch] $ExcludeExistsPaths - ) - - if ($Node.Contains('All')) { - foreach ($child in @($Node.All)) { - if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths - } - } - return - } - - if ($Node.Contains('Any')) { - foreach ($child in @($Node.Any)) { - if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths - } - } - return - } - - if ($Node.Contains('None')) { - foreach ($child in @($Node.None)) { - if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths - } - } - 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')) { - # Exists operator semantics: checking for the presence of a path is intentional. - # When -ExcludeExistsPaths is set (e.g. strict execution-time validation), skip these - # so that Exists can still return $false without causing a path-not-found error. - if (-not $ExcludeExistsPaths) { - $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 -ExcludeExistsPaths:$ExcludeExistsOperatorPaths - $uniquePaths = @($paths | Select-Object -Unique) if ($uniquePaths.Count -eq 0) { return diff --git a/src/IdLE.Core/Private/Assert-IdleNoCredentialObject.ps1 b/src/IdLE.Core/Private/Assert-IdleNoCredentialObject.ps1 new file mode 100644 index 00000000..e4b7bf9f --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleNoCredentialObject.ps1 @@ -0,0 +1,64 @@ +function Assert-IdleNoCredentialObject { + <# + .SYNOPSIS + Asserts that the provided object does not contain PSCredential or SecureString objects. + + .DESCRIPTION + Recursively walks hashtables, enumerables and PSCustomObjects. Used for step State, which later steps + can read: authentication material must be acquired via the AuthSessionBroker, never passed between steps. + + .PARAMETER InputObject + The object to validate. + + .PARAMETER Path + Logical path of InputObject, used in error messages. + + .OUTPUTS + None. Throws an ArgumentException if a credential object is found. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $InputObject, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + if ($null -eq $InputObject) { return } + + if ($InputObject -is [System.Management.Automation.PSCredential] -or $InputObject -is [System.Security.SecureString]) { + throw [System.ArgumentException]::new( + "Credential objects ($($InputObject.GetType().Name)) are not allowed. Found at: $Path", + $Path + ) + } + + if ($InputObject -is [string] -or $InputObject.GetType().IsValueType) { return } + + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + Assert-IdleNoCredentialObject -InputObject $InputObject[$key] -Path "$Path.$key" + } + return + } + + if ($InputObject -is [System.Collections.IEnumerable]) { + $i = 0 + foreach ($item in $InputObject) { + Assert-IdleNoCredentialObject -InputObject $item -Path "$Path[$i]" + $i++ + } + return + } + + if ($InputObject -is [pscustomobject]) { + foreach ($p in $InputObject.PSObject.Properties) { + if ($p.MemberType -eq 'NoteProperty') { + Assert-IdleNoCredentialObject -InputObject $p.Value -Path "$Path.$($p.Name)" + } + } + } +} diff --git a/src/IdLE.Core/Private/Assert-IdleStateReference.ps1 b/src/IdLE.Core/Private/Assert-IdleStateReference.ps1 new file mode 100644 index 00000000..696b186e --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleStateReference.ps1 @@ -0,0 +1,98 @@ +Set-StrictMode -Version Latest + +function Assert-IdleStateReference { + <# + .SYNOPSIS + Validates a State.. reference at planning time. + + .DESCRIPTION + Throws an ArgumentException whose message starts with StateReferenceInvalid, StateReferenceAmbiguous, + StateReferenceUnknownStep or StateReferenceNotDeclared (see docs/extend/steps.md). + Paths below are not validated. + + .PARAMETER Path + The referenced path (starting with 'State'). + + .PARAMETER StepName + Name of the referencing step. + + .PARAMETER Source + Where the reference is used ('Precondition' or the With path). + + .PARAMETER Producers + Steps that run before the referencing step: step name -> StateSchema (root key schema) or $null. + + .PARAMETER AmbiguousNames + Step names that exist in both Steps and OnFailureSteps (only relevant for OnFailureSteps). + + .OUTPUTS + None. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Source, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Producers, + + [Parameter()] + [string[]] $AmbiguousNames = @() + ) + + $prefix = "Workflow step '$StepName' references '$Path' in $Source" + $segments = @($Path -split '\.') + + if ($segments.Count -lt 2 -or $segments[1] -notmatch '^[A-Za-z][A-Za-z0-9_]*$') { + throw [System.ArgumentException]::new( + "StateReferenceInvalid: $prefix. Use State..; the producing step's Name must be an identifier (letters, digits, underscores, e.g. 'GetMailbox').", + 'Workflow' + ) + } + + $producerName = $segments[1] + + if ($AmbiguousNames -contains $producerName) { + throw [System.ArgumentException]::new( + "StateReferenceAmbiguous: $prefix, but a step named '$producerName' exists in both Steps and OnFailureSteps. Rename one of them.", + 'Workflow' + ) + } + + if (-not $Producers.ContainsKey($producerName)) { + throw [System.ArgumentException]::new( + "StateReferenceUnknownStep: $prefix, but no step named '$producerName' runs before it.", + 'Workflow' + ) + } + + $stateSchema = $Producers[$producerName] + if ($null -eq $stateSchema) { + throw [System.ArgumentException]::new( + "StateReferenceNotDeclared: $prefix, but the step type of '$producerName' declares no StateSchema (it publishes no State).", + 'Workflow' + ) + } + + if ($segments.Count -ge 3 -and -not ($stateSchema['AllowAdditionalKeys'] -eq $true)) { + $key = $segments[2] + $declared = @($stateSchema['Keys'].PSBase.Keys | Where-Object { [string]::Equals([string]$_, $key, [System.StringComparison]::OrdinalIgnoreCase) }) + if ($declared.Count -eq 0) { + $supported = [string]::Join(', ', @($stateSchema['Keys'].PSBase.Keys | Sort-Object)) + throw [System.ArgumentException]::new( + "StateReferenceNotDeclared: $prefix, but the StateSchema of '$producerName' does not declare '$key'. Declared keys: $supported.", + 'Workflow' + ) + } + } +} diff --git a/src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 b/src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 index b445adcd..7c6b8577 100644 --- a/src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 @@ -38,9 +38,12 @@ function Assert-IdleStepMetadataContract { $keySchemaProperties = @('Type', 'Required', 'Default', 'AllowedValues', 'AllowEmpty', 'Keys', 'AllowAdditionalKeys', 'Items') $example = "WithSchema = @{ Keys = @{ IdentityKey = @{ Type = 'String'; Required = `$true } } }" + # StateSchema errors use their own code; the key schema format is shared with WithSchema. + $errorCode = 'InvalidWithSchema' + function New-IdleContractError { param([string] $Text) - return [System.ArgumentException]::new("InvalidWithSchema: $prefix $Text", 'Providers') + return [System.ArgumentException]::new("${errorCode}: $prefix $Text", 'Providers') } function Assert-IdleKeySchema { @@ -165,6 +168,30 @@ function Assert-IdleStepMetadataContract { } } + if ($Metadata.ContainsKey('StateSchema')) { + $errorCode = 'InvalidStateSchema' + $stateExample = "StateSchema = @{ Keys = @{ Mailbox = @{ Type = 'Any'; Required = `$true } } }" + + $stateSchema = $Metadata['StateSchema'] + if ($stateSchema -isnot [hashtable]) { + throw (New-IdleContractError "has an invalid 'StateSchema'. Expected a hashtable, e.g. $stateExample.") + } + foreach ($property in $stateSchema.PSBase.Keys) { + if (@('Keys', 'AllowAdditionalKeys') -notcontains [string]$property) { + throw (New-IdleContractError "StateSchema has unknown property '$property'. Supported: Keys, AllowAdditionalKeys.") + } + } + if (-not $stateSchema.ContainsKey('Keys')) { + throw (New-IdleContractError "StateSchema is missing 'Keys', e.g. $stateExample.") + } + if ($stateSchema.ContainsKey('AllowAdditionalKeys') -and $stateSchema['AllowAdditionalKeys'] -isnot [bool]) { + throw (New-IdleContractError 'StateSchema.AllowAdditionalKeys must be a Bool.') + } + + Assert-IdleKeysSchema -Keys $stateSchema['Keys'] -Path 'StateSchema.Keys' + $errorCode = 'InvalidWithSchema' + } + if ($Metadata.ContainsKey('Provider')) { $provider = $Metadata['Provider'] $providerOk = $false diff --git a/src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 index 8038a940..58b820af 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 @@ -30,6 +30,13 @@ function ConvertTo-IdleSchemaValue { .PARAMETER ExcludedKeys Hashtable keys that are never accepted as additional keys (common keys of steps without a Provider). + .PARAMETER DeferredPaths + Paths whose values are resolved at execution time (With templates referencing State). They are returned + unchanged; their key schema (if declared) is stored in DeferredSchemas for execution-time validation. + + .PARAMETER DeferredSchemas + Receives path -> key schema for every visited deferred path. + .OUTPUTS The normalized value. #> @@ -50,7 +57,14 @@ function ConvertTo-IdleSchemaValue { [string] $ErrorPrefix, [Parameter()] - [string[]] $ExcludedKeys = @() + [string[]] $ExcludedKeys = @(), + + [Parameter()] + [string[]] $DeferredPaths = @(), + + [Parameter()] + [AllowNull()] + [hashtable] $DeferredSchemas ) function New-IdleSchemaError { @@ -58,6 +72,11 @@ function ConvertTo-IdleSchemaValue { return [System.ArgumentException]::new("${Code}: $ErrorPrefix $Text", 'Workflow') } + if ($DeferredPaths -contains $Path) { + if ($null -ne $DeferredSchemas) { $DeferredSchemas[$Path] = $Schema } + return $Value + } + $type = [string]$Schema['Type'] $actualType = if ($null -eq $Value) { 'null' } else { $Value.GetType().Name } @@ -120,7 +139,7 @@ function ConvertTo-IdleSchemaValue { if ($null -eq $items[$index]) { throw (New-IdleSchemaError 'WithKeyTypeMismatch' "$itemPath must not be null.") } - $result.Add((ConvertTo-IdleSchemaValue -Schema $Schema['Items'] -Value $items[$index] -Path $itemPath -ErrorPrefix $ErrorPrefix)) + $result.Add((ConvertTo-IdleSchemaValue -Schema $Schema['Items'] -Value $items[$index] -Path $itemPath -ErrorPrefix $ErrorPrefix -DeferredPaths $DeferredPaths -DeferredSchemas $DeferredSchemas)) } return , $result.ToArray() } @@ -156,7 +175,7 @@ function ConvertTo-IdleSchemaValue { continue } - $result[[string]$declaredKey] = ConvertTo-IdleSchemaValue -Schema $declared[$declaredKey] -Value $inputValue -Path "$Path.$declaredKey" -ErrorPrefix $ErrorPrefix + $result[[string]$declaredKey] = ConvertTo-IdleSchemaValue -Schema $declared[$declaredKey] -Value $inputValue -Path "$Path.$declaredKey" -ErrorPrefix $ErrorPrefix -DeferredPaths $DeferredPaths -DeferredSchemas $DeferredSchemas } foreach ($declaredKey in @($declared.PSBase.Keys | Sort-Object)) { diff --git a/src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 index 17df60eb..c76ad4ce 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 @@ -8,6 +8,9 @@ function ConvertTo-IdleStepResult { State (Hashtable). Failures are reported by throwing, never by the return value. Any other output fails with InvalidStepHandlerResult. + State is validated against the step's StateSchema: a step without StateSchema must not return State, + and State must be data-only (no ScriptBlock, PSCredential or SecureString). + .PARAMETER Output Handler output (as captured from the pipeline). @@ -23,6 +26,9 @@ function ConvertTo-IdleStepResult { .PARAMETER Source Handler provenance (ConvertTo-IdleSourceInfo). + .PARAMETER StateSchema + Root key schema of the step's State (IdLE.PlanStep.StateSchema), or $null if the step publishes no State. + .OUTPUTS PSCustomObject (PSTypeName: IdLE.StepResult) #> @@ -44,7 +50,11 @@ function ConvertTo-IdleStepResult { [Parameter()] [AllowNull()] - [object] $Source + [object] $Source, + + [Parameter()] + [AllowNull()] + [hashtable] $StateSchema ) $prefix = "InvalidStepHandlerResult: Step '$StepName' (type '$StepType')" @@ -90,6 +100,24 @@ function ConvertTo-IdleStepResult { if ($Output.Contains('State')) { $state = $Output['State'] } } + if ($null -ne $state -and $null -eq $StateSchema) { + throw [System.InvalidOperationException]::new("$prefix returned State, but its step metadata declares no StateSchema. Declare the published keys, e.g. StateSchema = @{ Keys = @{ Mailbox = @{ Type = 'Any' } } }.") + } + + if ($null -ne $StateSchema) { + # Required keys are checked even if the handler returned no State. + try { + $validated = ConvertTo-IdleSchemaValue -Schema $StateSchema -Value $(if ($null -eq $state) { @{} } else { $state }) -Path 'State' -ErrorPrefix "Step '$StepName' (type '$StepType'):" + Assert-IdleNoScriptBlock -InputObject $state -Path 'State' + Assert-IdleNoCredentialObject -InputObject $state -Path 'State' + } + catch { + throw [System.InvalidOperationException]::new("$prefix returned State that does not match its StateSchema: $($_.Exception.Message)") + } + # Defaults of optional keys are published even if the handler returned no State. + if ($null -ne $state -or $validated.Count -gt 0) { $state = $validated } + } + return [pscustomobject]@{ PSTypeName = 'IdLE.StepResult' Name = $StepName diff --git a/src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 index ac4ff27e..e30790bf 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 @@ -22,6 +22,12 @@ function ConvertTo-IdleStepWith { .PARAMETER StepType Step type (error messages). + .PARAMETER DeferredPaths + With paths resolved at execution time (State templates); skipped here, see ConvertTo-IdleSchemaValue. + + .PARAMETER DeferredSchemas + Receives path -> key schema of the deferred paths. + .OUTPUTS System.Collections.Hashtable #> @@ -40,7 +46,14 @@ function ConvertTo-IdleStepWith { [string] $StepName, [Parameter(Mandatory)] - [string] $StepType + [string] $StepType, + + [Parameter()] + [string[]] $DeferredPaths = @(), + + [Parameter()] + [AllowNull()] + [hashtable] $DeferredSchemas ) if ($null -eq $With) { $With = @{} } @@ -50,7 +63,9 @@ function ConvertTo-IdleStepWith { -Value $With ` -Path 'With' ` -ErrorPrefix ("Step '{0}' (type '{1}'):" -f $StepName, $StepType) ` - -ExcludedKeys @('Provider', 'AuthSessionName', 'AuthSessionOptions') + -ExcludedKeys @('Provider', 'AuthSessionName', 'AuthSessionOptions') ` + -DeferredPaths $DeferredPaths ` + -DeferredSchemas $DeferredSchemas # Steps whose auth sessions are named after the provider alias (e.g. 'ExchangeOnline'). if ($Metadata.ContainsKey('Provider') -and $Metadata['Provider']['AuthSessionNameFromAlias'] -eq $true -and -not $effective.ContainsKey('AuthSessionName')) { diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 7d0342c7..994fefa3 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -18,6 +18,12 @@ function ConvertTo-IdleWorkflowSteps { 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. + Step State (see docs/extend/steps.md): + - Condition must not reference State.* (StateReferenceNotAllowed). + - With strings with State placeholders are kept for execution-time resolution (DeferredWith). + - State references in Precondition and DeferredWith are validated against StateProducers + (steps that run before), which is extended with every normalized step. + 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. @@ -34,9 +40,24 @@ function ConvertTo-IdleWorkflowSteps { [Parameter(Mandatory)] [ValidateNotNull()] - [hashtable] $StepMetadataCatalog + [hashtable] $StepMetadataCatalog, + + # Step name -> StateSchema of the steps that run before this collection (extended in place). + [Parameter()] + [AllowNull()] + [hashtable] $StateProducers, + + # Step names that exist in both Steps and OnFailureSteps (passed for OnFailureSteps only). + [Parameter()] + [string[]] $AmbiguousStateNames = @() ) + if ($null -eq $StateProducers) { + $StateProducers = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + } + + $isStatePath = { param([string] $Path) $Path -eq 'State' -or $Path.StartsWith('State.', [System.StringComparison]::OrdinalIgnoreCase) } + if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { return @() } @@ -90,6 +111,15 @@ function ConvertTo-IdleWorkflowSteps { ) } + foreach ($conditionPath in @(Get-IdleConditionPath -Condition $condition)) { + if (& $isStatePath $conditionPath.Path) { + throw [System.ArgumentException]::new( + ("StateReferenceNotAllowed: Workflow step '{0}' references '{1}' in Condition. Conditions are evaluated at planning time, before any step has run; use a Precondition instead." -f $stepName, $conditionPath.Path), + 'Workflow' + ) + } + } + Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' -ExcludeExistsOperatorPaths $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext @@ -131,13 +161,42 @@ function ConvertTo-IdleWorkflowSteps { # 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. + $deferredWith = @() + $stateReferences = [System.Collections.Generic.List[object]]::new() if ($status -ne 'NotApplicable') { - # Resolve template placeholders in With (planning-time resolution) - $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + # Resolve template placeholders in With (planning-time resolution). + # Strings with State placeholders stay unresolved until the step runs. + $deferredList = [System.Collections.Generic.List[object]]::new() + $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName -DeferredList $deferredList + + foreach ($deferred in $deferredList) { + $rootKey = [string]@($deferred.Segments)[0] + if (@('Provider', 'AuthSessionName', 'AuthSessionOptions') -contains $rootKey) { + throw [System.ArgumentException]::new( + ("StateReferenceNotAllowed: Workflow step '{0}' references State in {1}. Provider routing and authentication are resolved at planning time." -f $stepName, $deferred.Path), + 'Workflow' + ) + } + foreach ($statePath in $deferred.StatePaths) { + $stateReferences.Add(@{ Path = $statePath; Source = $deferred.Path }) + } + } # Validate With against the typed WithSchema (fail-fast plan-time check) and apply defaults, # so the plan carries the effective With (including the resolved Provider alias). - $with = ConvertTo-IdleStepWith -Metadata $StepMetadataCatalog[$stepType] -With $with -StepName $stepName -StepType $stepType + # Deferred values are validated against their key schema after execution-time resolution. + $deferredSchemas = @{} + $with = ConvertTo-IdleStepWith -Metadata $StepMetadataCatalog[$stepType] -With $with -StepName $stepName -StepType $stepType ` + -DeferredPaths @($deferredList | ForEach-Object { $_.Path }) -DeferredSchemas $deferredSchemas + + # Sorted by path: With is a hashtable, and plans must not depend on its enumeration order. + $deferredWith = @(foreach ($deferred in @($deferredList | Sort-Object -Property { $_.Path })) { + @{ + Path = $deferred.Path + Segments = @($deferred.Segments) + Schema = if ($deferredSchemas.ContainsKey($deferred.Path)) { Copy-IdleDataObject -Value $deferredSchemas[$deferred.Path] } else { $null } + } + }) } $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { @@ -166,6 +225,24 @@ function ConvertTo-IdleWorkflowSteps { $preconditionEvent = $preconditionSettings.PreconditionEvent $preconditionWarnings = @() + if ($null -ne $precondition) { + foreach ($preconditionPath in @(Get-IdleConditionPath -Condition $precondition)) { + if (& $isStatePath $preconditionPath.Path) { + $stateReferences.Add(@{ Path = $preconditionPath.Path; Source = 'Precondition' }) + } + } + } + + foreach ($reference in $stateReferences) { + # Normalize the root casing so that 'state.X' and 'State.X' are validated alike. + $referencePath = 'State' + $reference.Path.Substring(5) + Assert-IdleStateReference -Path $referencePath -StepName $stepName -Source $reference.Source -Producers $StateProducers -AmbiguousNames $AmbiguousStateNames + } + + # The step's published State (if any) is visible to the steps that follow it. + $stateSchema = Get-IdleStepStateSchema -Metadata $StepMetadataCatalog[$stepType] + $StateProducers[$stepName] = $stateSchema + if ($planWarningsCanTrackCount) { $warningCountAfter = [int]$planWarnings.Count if ($warningCountAfter -gt $warningCountBefore) { @@ -191,6 +268,8 @@ function ConvertTo-IdleWorkflowSteps { PreconditionEvent = $preconditionEvent Warnings = $preconditionWarnings With = $with + DeferredWith = $deferredWith + StateSchema = $stateSchema RequiresCapabilities = $requiresCaps Status = $status RetryProfile = $retryProfile diff --git a/src/IdLE.Core/Private/Get-IdleConditionPath.ps1 b/src/IdLE.Core/Private/Get-IdleConditionPath.ps1 new file mode 100644 index 00000000..8c1bde48 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleConditionPath.ps1 @@ -0,0 +1,82 @@ +Set-StrictMode -Version Latest + +function Get-IdleConditionPath { + <# + .SYNOPSIS + Returns the paths referenced by a condition node, with the operator that uses them. + + .DESCRIPTION + Walks All / Any / None groups and returns one entry per operator path. A leading 'context.' prefix + is removed (as in Test-IdleCondition). Malformed nodes are ignored; Test-IdleConditionSchema reports them. + + .PARAMETER Condition + Condition node (data-only hashtable). + + .OUTPUTS + PSCustomObject[] with Operator and Path. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $Condition + ) + + $result = [System.Collections.Generic.List[object]]::new() + + function Add-IdleConditionPath { + param([string] $Operator, [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) + } + + $result.Add([pscustomobject]@{ Operator = $Operator; Path = $pathText }) + } + + function Add-IdleConditionNodePaths { + param([System.Collections.IDictionary] $Node) + + foreach ($group in @('All', 'Any', 'None')) { + if ($Node.Contains($group)) { + foreach ($child in @($Node[$group])) { + if ($child -is [System.Collections.IDictionary]) { + Add-IdleConditionNodePaths -Node $child + } + } + return + } + } + + if ($Node.Contains('Exists')) { + $existsValue = $Node['Exists'] + if ($existsValue -is [System.Collections.IDictionary]) { + Add-IdleConditionPath -Operator 'Exists' -PathCandidate $existsValue['Path'] + } + else { + Add-IdleConditionPath -Operator 'Exists' -PathCandidate $existsValue + } + return + } + + foreach ($operator in @('Equals', 'NotEquals', 'In', 'Contains', 'NotContains', 'Like', 'NotLike')) { + if ($Node.Contains($operator)) { + $operatorNode = $Node[$operator] + if ($operatorNode -is [System.Collections.IDictionary]) { + Add-IdleConditionPath -Operator $operator -PathCandidate $operatorNode['Path'] + } + return + } + } + } + + Add-IdleConditionNodePaths -Node $Condition + + # Enumerated to the pipeline; callers wrap the result in @(). + return $result.ToArray() +} diff --git a/src/IdLE.Core/Private/Get-IdleStepStateSchema.ps1 b/src/IdLE.Core/Private/Get-IdleStepStateSchema.ps1 new file mode 100644 index 00000000..b4bb3943 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleStepStateSchema.ps1 @@ -0,0 +1,33 @@ +function Get-IdleStepStateSchema { + <# + .SYNOPSIS + Returns the effective root schema for the State a step publishes. + + .DESCRIPTION + Wraps StateSchema.Keys into a Hashtable key schema (a data-only copy), or returns $null if the + step metadata declares no StateSchema (the step publishes no State). + + .PARAMETER Metadata + Step metadata entry (validated by Assert-IdleStepMetadataContract). + + .OUTPUTS + System.Collections.Hashtable (key schema with Type = 'Hashtable') or $null. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Metadata + ) + + if (-not $Metadata.ContainsKey('StateSchema')) { + return $null + } + + $stateSchema = $Metadata['StateSchema'] + return @{ + Type = 'Hashtable' + Keys = Copy-IdleDataObject -Value $stateSchema['Keys'] + AllowAdditionalKeys = ($stateSchema.ContainsKey('AllowAdditionalKeys') -and $stateSchema['AllowAdditionalKeys'] -eq $true) + } +} diff --git a/src/IdLE.Core/Private/Get-IdleTemplateEscapePattern.ps1 b/src/IdLE.Core/Private/Get-IdleTemplateEscapePattern.ps1 new file mode 100644 index 00000000..f4c5147b --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleTemplateEscapePattern.ps1 @@ -0,0 +1,31 @@ +function Get-IdleTemplateEscapePattern { + <# + .SYNOPSIS + Returns the regex that matches escaped template openings (\{{ not followed by a valid template path). + + .DESCRIPTION + \{{ is an escape for a literal {{ unless it is followed by a valid allowed-root template path and }}. + In that case the backslash is a literal character and the template is resolved (e.g. DOMAIN\{{Request.IdentityKeys.sAMAccountName}}). + + The lookahead is restricted to the exact set of allowed roots so that sequences like \{{Request.Foo}} + (invalid root) or \{{Request..Name}} (double dot) are escaped to literal {{ rather than failing with path/root errors. + + Shared by Resolve-IdleTemplateString and Get-IdleTemplatePath so that template detection and + resolution always agree on what is a template. + + .PARAMETER IncludeState + Treat State..* as an allowed root (With templates of workflow steps). + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [switch] $IncludeState + ) + + $stateRoot = if ($IncludeState) { '|State(?:\.[A-Za-z0-9_]+)+' } else { '' } + return "\\{{(?!(?:Request\.(?:(?:Intent|Context|IdentityKeys)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)$stateRoot)}})" +} diff --git a/src/IdLE.Core/Private/Get-IdleTemplatePath.ps1 b/src/IdLE.Core/Private/Get-IdleTemplatePath.ps1 new file mode 100644 index 00000000..3ec0cd36 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleTemplatePath.ps1 @@ -0,0 +1,34 @@ +function Get-IdleTemplatePath { + <# + .SYNOPSIS + Returns the placeholder paths of a template string. + + .DESCRIPTION + Applies the same escape handling as Resolve-IdleTemplateString with the State root enabled (escaped \{{ + sequences are not templates) and returns the trimmed path of every {{...}} placeholder, in order of appearance. + Paths are not validated. + + .PARAMETER Value + The string to scan. + + .OUTPUTS + System.String[] + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value + ) + + # Paths are enumerated to the pipeline; callers wrap the result in @(). + if ($Value -notlike '*{{*') { + return + } + + $normalized = [regex]::Replace($Value, (Get-IdleTemplateEscapePattern -IncludeState), [string][char]0xE001) + foreach ($match in [regex]::Matches($normalized, '\{\{([^}]+)\}\}')) { + $match.Groups[1].Value.Trim() + } +} diff --git a/src/IdLE.Core/Private/Resolve-IdleDeferredStepWith.ps1 b/src/IdLE.Core/Private/Resolve-IdleDeferredStepWith.ps1 new file mode 100644 index 00000000..05140a2d --- /dev/null +++ b/src/IdLE.Core/Private/Resolve-IdleDeferredStepWith.ps1 @@ -0,0 +1,91 @@ +function Resolve-IdleDeferredStepWith { + <# + .SYNOPSIS + Resolves a plan step's deferred With templates (State placeholders) right before the step runs. + + .DESCRIPTION + Planning keeps With strings that contain State placeholders unresolved and records them in + IdLE.PlanStep.DeferredWith (Path, Segments, Schema). This function resolves each recorded string + against Request and State in one pass, validates the result against the recorded key schema and + returns a copy of the step with the effective With. The plan step itself is not modified. + + Only recorded strings are resolved: values that were resolved at planning time (e.g. request data) + are never scanned for templates again. + + Errors start with StateReferenceUnresolved (missing or $null State value) or the With schema codes. + + .PARAMETER Step + Plan step. + + .PARAMETER Request + Request snapshot of the plan. + + .PARAMETER State + State published by earlier steps (step name -> State hashtable). + + .OUTPUTS + The plan step, or a copy of it with the resolved With. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter()] + [AllowNull()] + [object] $Request, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $State + ) + + $deferredWith = @(Get-IdlePropertyValue -Object $Step -Name 'DeferredWith' | Where-Object { $null -ne $_ }) + if ($deferredWith.Count -eq 0) { + return $Step + } + + $stepName = [string](Get-IdlePropertyValue -Object $Step -Name 'Name') + $stepType = [string](Get-IdlePropertyValue -Object $Step -Name 'Type') + $effectiveRequest = if ($null -ne $Request) { $Request } else { [pscustomobject]@{} } + + function Get-IdleChildValue { + param([object] $Parent, [object] $Segment) + if ($Parent -is [System.Collections.IDictionary]) { return $Parent[$Segment] } + if ($Parent -is [array]) { return $Parent[[int]$Segment] } + return $Parent.PSObject.Properties[[string]$Segment].Value + } + + function Set-IdleChildValue { + param([object] $Parent, [object] $Segment, [object] $Value) + if ($Parent -is [System.Collections.IDictionary]) { $Parent[$Segment] = $Value; return } + if ($Parent -is [array]) { $Parent[[int]$Segment] = $Value; return } + $Parent.PSObject.Properties[[string]$Segment].Value = $Value + } + + $with = Copy-IdleDataObject -Value (Get-IdlePropertyValue -Object $Step -Name 'With') + + foreach ($entry in $deferredWith) { + $segments = @(Get-IdlePropertyValue -Object $entry -Name 'Segments') + $path = [string](Get-IdlePropertyValue -Object $entry -Name 'Path') + $schema = Get-IdlePropertyValue -Object $entry -Name 'Schema' + + $parent = $with + for ($i = 0; $i -lt $segments.Count - 1; $i++) { + $parent = Get-IdleChildValue -Parent $parent -Segment $segments[$i] + } + $lastSegment = $segments[$segments.Count - 1] + + $value = Resolve-IdleTemplateString -Value (Get-IdleChildValue -Parent $parent -Segment $lastSegment) -Request $effectiveRequest -StepName $stepName -State $State + if ($schema -is [System.Collections.IDictionary]) { + $value = ConvertTo-IdleSchemaValue -Schema ([hashtable]$schema) -Value $value -Path $path -ErrorPrefix ("Step '{0}' (type '{1}'):" -f $stepName, $stepType) + } + + Set-IdleChildValue -Parent $parent -Segment $lastSegment -Value $value + } + + $effectiveStep = $Step.PSObject.Copy() + $effectiveStep.With = $with + return $effectiveStep +} diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 625ef88c..8391ba0b 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -19,6 +19,12 @@ function Resolve-IdleTemplateString { - Request.LifecycleEvent - Request.CorrelationId - Request.Actor + - State..* (only with -State or -DeferState; values published by earlier steps during execution) + + State placeholders (see docs/extend/steps.md): + - Planning (-DeferState): a string that contains a State placeholder is validated and returned unchanged; + its Request placeholders must resolve. The caller records the string for execution-time resolution. + - Execution (-State): the original string is resolved against Request and State in one pass. Escaping: - \{{ → literal {{ (backslash escapes the opening braces when not followed by a valid allowed-root template path+}}) @@ -35,11 +41,18 @@ function Resolve-IdleTemplateString { .PARAMETER StepName The name of the step being processed (for error messages). + .PARAMETER State + State published by earlier steps (step name -> State hashtable). Enables the State root. + + .PARAMETER DeferState + Planning mode: return strings that contain State placeholders unchanged (after validation). + .OUTPUTS For pure placeholders (single placeholder with no surrounding text), returns the resolved value with its original type preserved (string, bool, int, datetime, guid, etc.). For mixed strings (string interpolation with multiple placeholders or surrounding text), returns a string with placeholders replaced. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Request', Justification = 'Used inside nested scriptblock $resolvePath; PSSA does not track parameter usage across nested scriptblock boundaries.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'State', Justification = 'Used inside nested scriptblock $resolvePath; PSSA does not track parameter usage across nested scriptblock boundaries.')] [CmdletBinding()] param( [Parameter()] @@ -52,7 +65,14 @@ function Resolve-IdleTemplateString { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $StepName + [string] $StepName, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] $State, + + [Parameter()] + [switch] $DeferState ) if ($null -eq $Value) { @@ -73,6 +93,9 @@ function Resolve-IdleTemplateString { # Define validation constants used in multiple paths $pathValidationPattern = '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$' $allowedRoots = @('Request.Intent', 'Request.Context', 'Request.IdentityKeys', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') + if ($DeferState -or $PSBoundParameters.ContainsKey('State')) { + $allowedRoots += 'State' + } # Helper function to validate path pattern $validatePath = { @@ -108,10 +131,16 @@ function Resolve-IdleTemplateString { param([string]$Path) # Resolve the value (shared path resolver handles hashtables and objects) - $contextWrapper = [pscustomobject]@{ Request = $Request } + $contextWrapper = [pscustomobject]@{ Request = $Request; State = $State } $resolvedValue = Get-IdleValueByPath -Object $contextWrapper -Path $Path # Fail fast on null/missing values + if ($null -eq $resolvedValue -and $Path.StartsWith('State.')) { + throw [System.ArgumentException]::new( + ("StateReferenceUnresolved: Step '{0}': Path '{1}' resolved to null or does not exist. The producing step did not run, did not complete or did not return this value; guard the step with a Precondition (Exists) if the producer is optional." -f $StepName, $Path), + 'Workflow' + ) + } if ($null -eq $resolvedValue) { throw [System.ArgumentException]::new( ("Template resolution error in step '{0}': Path '{1}' resolved to null or does not exist. Ensure the request contains all required values." -f $StepName, $Path), @@ -184,11 +213,8 @@ function Resolve-IdleTemplateString { # Escape sequence normalization: \{{ (not followed by a valid allowed-root template path+}}) → literal {{. # Use a Unicode Private Use Area character as placeholder so template matching does not see the # escaped braces. This character is extremely unlikely to appear in real workflow configuration. - # The lookahead is intentionally restricted to the exact set of allowed roots so that sequences - # like \{{Request.Foo}} (invalid root) or \{{Request..Name}} (double dot) are still escaped to - # literal {{, rather than flowing into template parsing and failing with path/root errors. $litOpenPlaceholder = [string][char]0xE001 - $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Intent|Context|IdentityKeys)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' + $backslashEscapePattern = Get-IdleTemplateEscapePattern -IncludeState:($allowedRoots -contains 'State') $normalizedValue = if ($stringValue -match '\\{{') { [regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder) } else { @@ -231,6 +257,22 @@ function Resolve-IdleTemplateString { $pattern = '\{\{([^}]+)\}\}' $matches = [regex]::Matches($stringValue, $pattern) + # Planning: a string with State placeholders is resolved at execution time. Validate it now + # (paths, roots, Request values present) and return the original string unchanged. + if ($DeferState) { + $paths = @($matches | ForEach-Object { $_.Groups[1].Value.Trim() }) + if (@($paths | Where-Object { $_ -ceq 'State' -or $_.StartsWith('State.') }).Count -gt 0) { + foreach ($path in $paths) { + & $validatePath $path + & $validateAllowedRoot $path + if (-not ($path -ceq 'State' -or $path.StartsWith('State.'))) { + $null = & $resolvePath $path + } + } + return $Value + } + } + # For pure placeholders, we'll return the typed value directly if ($isPurePlaceholder) { # There should be exactly one match diff --git a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 index 5fd928a6..add0f05a 100644 --- a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 @@ -10,6 +10,10 @@ function Resolve-IdleWorkflowTemplates { This function is called during planning to resolve templates in: - Steps[*].With (including nested structures) - OnFailureSteps[*].With (including nested structures) + - ContextResolvers[*].With + + With -DeferredList, strings that contain State placeholders are left unchanged and recorded + (Path, Segments, StatePaths) for execution-time resolution. Without it, the State root is not allowed. .PARAMETER Value The value to process (hashtable, array, string, or scalar). @@ -20,6 +24,15 @@ function Resolve-IdleWorkflowTemplates { .PARAMETER StepName The name of the step being processed (for error messages). + .PARAMETER Path + Path of Value (e.g. 'With.Message'), matching the paths used by ConvertTo-IdleSchemaValue. + + .PARAMETER Segments + Keys and array indexes leading from the root to Value. + + .PARAMETER DeferredList + Receives one entry per deferred string: @{ Path; Segments; StatePaths }. + .OUTPUTS The value with all template strings resolved. #> @@ -35,7 +48,18 @@ function Resolve-IdleWorkflowTemplates { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $StepName + [string] $StepName, + + [Parameter()] + [string] $Path = 'With', + + [Parameter()] + [AllowEmptyCollection()] + [object[]] $Segments = @(), + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.List[object]] $DeferredList ) if ($null -eq $Value) { @@ -44,7 +68,16 @@ function Resolve-IdleWorkflowTemplates { # Strings: resolve templates if ($Value -is [string]) { - return Resolve-IdleTemplateString -Value $Value -Request $Request -StepName $StepName + if ($null -eq $DeferredList) { + return Resolve-IdleTemplateString -Value $Value -Request $Request -StepName $StepName + } + + $resolved = Resolve-IdleTemplateString -Value $Value -Request $Request -StepName $StepName -DeferState + $statePaths = @(Get-IdleTemplatePath -Value $Value | Where-Object { $_ -ceq 'State' -or $_.StartsWith('State.') }) + if ($statePaths.Count -gt 0) { + $DeferredList.Add(@{ Path = $Path; Segments = @($Segments); StatePaths = $statePaths }) + } + return $resolved } # Primitives: return as-is @@ -56,7 +89,7 @@ function Resolve-IdleWorkflowTemplates { if ($Value -is [System.Collections.IDictionary]) { $resolved = @{} foreach ($key in $Value.Keys) { - $resolved[$key] = Resolve-IdleWorkflowTemplates -Value $Value[$key] -Request $Request -StepName $StepName + $resolved[$key] = Resolve-IdleWorkflowTemplates -Value $Value[$key] -Request $Request -StepName $StepName -Path "$Path.$key" -Segments (@($Segments) + [string]$key) -DeferredList $DeferredList } return $resolved } @@ -64,8 +97,10 @@ function Resolve-IdleWorkflowTemplates { # Arrays/lists: recurse on items if (Test-IdleEnumerableValue -Value $Value) { $resolved = @() + $index = 0 foreach ($item in $Value) { - $resolved += Resolve-IdleWorkflowTemplates -Value $item -Request $Request -StepName $StepName + $resolved += Resolve-IdleWorkflowTemplates -Value $item -Request $Request -StepName $StepName -Path "$Path[$index]" -Segments (@($Segments) + $index) -DeferredList $DeferredList + $index++ } # Comma operator: keep single-element and empty arrays as arrays (no pipeline unrolling). return , $resolved @@ -76,7 +111,7 @@ function Resolve-IdleWorkflowTemplates { if (@($props).Count -gt 0) { $resolved = [ordered]@{} foreach ($prop in $props) { - $resolved[$prop.Name] = Resolve-IdleWorkflowTemplates -Value $prop.Value -Request $Request -StepName $StepName + $resolved[$prop.Name] = Resolve-IdleWorkflowTemplates -Value $prop.Value -Request $Request -StepName $StepName -Path "$Path.$($prop.Name)" -Segments (@($Segments) + $prop.Name) -DeferredList $DeferredList } return [pscustomobject]$resolved } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index e825d698..bcd81cf9 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -198,10 +198,15 @@ function Invoke-IdlePlanObject { $blocked = $false $stepResults = @() - # Precondition evaluation context: includes Plan and Request for condition DSL path resolution. + # State published by completed steps (step name -> State), read via State..* in + # Preconditions and deferred With templates. OnFailureSteps see the State of all completed steps. + $stepState = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # Precondition evaluation context: includes Plan, Request and State for condition DSL path resolution. $preconditionContext = @{ Plan = $Plan Request = $request + State = $stepState } $i = 0 @@ -432,9 +437,12 @@ function Invoke-IdlePlanObject { $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry $handlerSource = ConvertTo-IdleSourceInfo -Module $impl.Module + # With templates that reference State are resolved now that the producing steps have run. + $effectiveStep = Resolve-IdleDeferredStepWith -Step $step -Request $request -State $stepState + # Fail before the first attempt if the handler does not follow the step contract # or the provider is not supplied. - $null = New-IdleStepHandlerArguments -Handler $impl -Step $step -Context $context + $null = New-IdleStepHandlerArguments -Handler $impl -Step $effectiveStep -Context $context # Safe-by-default transient retries: # - Only retries if the thrown exception is explicitly marked transient. @@ -444,7 +452,7 @@ function Invoke-IdlePlanObject { $retrySeed = "$corr|$stepType|$stepName|$i" $retryParams = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $ExecutionOptions $operation = { - $handlerArguments = New-IdleStepHandlerArguments -Handler $impl -Step $step -Context $context + $handlerArguments = New-IdleStepHandlerArguments -Handler $impl -Step $effectiveStep -Context $context & $impl @handlerArguments } $retry = Invoke-IdleWithRetry ` @@ -460,12 +468,19 @@ function Invoke-IdlePlanObject { -DeterministicSeed $retrySeed # The engine owns the result shape; handlers report failures by throwing. - $stepResults += ConvertTo-IdleStepResult ` + $stepResult = ConvertTo-IdleStepResult ` -Output $retry.Value ` -StepName $stepName ` -StepType ([string]$stepType) ` -Attempts ([int]$retry.Attempts) ` - -Source $handlerSource + -Source $handlerSource ` + -StateSchema (Get-IdlePropertyValue -Object $step -Name 'StateSchema') + $stepResults += $stepResult + + # Snapshot, so that later changes to the handler's objects do not leak into later steps. + if ($null -ne $stepResult.State) { + $stepState[$stepName] = Copy-IdleDataObject -Value $stepResult.State + } $context.EventSink.WriteEvent( 'IdLE.Step.Completed', @@ -687,14 +702,16 @@ function Invoke-IdlePlanObject { $impl = Resolve-IdleStepHandler -StepType ([string]$ofType) -StepRegistry $stepRegistry $handlerSource = ConvertTo-IdleSourceInfo -Module $impl.Module - $null = New-IdleStepHandlerArguments -Handler $impl -Step $ofStep -Context $context + $effectiveOfStep = Resolve-IdleDeferredStepWith -Step $ofStep -Request $request -State $stepState + + $null = New-IdleStepHandlerArguments -Handler $impl -Step $effectiveOfStep -Context $context # Reuse safe-by-default transient retries for OnFailure steps. # - Retry parameters resolved from ExecutionOptions if provided. $retrySeed = "$corr|OnFailure|$ofType|$ofName|$j" $retryParams = Resolve-IdleStepRetryParameters -Step $ofStep -ExecutionOptions $ExecutionOptions $operation = { - $handlerArguments = New-IdleStepHandlerArguments -Handler $impl -Step $ofStep -Context $context + $handlerArguments = New-IdleStepHandlerArguments -Handler $impl -Step $effectiveOfStep -Context $context & $impl @handlerArguments } $retry = Invoke-IdleWithRetry ` @@ -709,12 +726,18 @@ function Invoke-IdlePlanObject { -OperationName 'OnFailureStepExecution' ` -DeterministicSeed $retrySeed - $onFailureStepResults += ConvertTo-IdleStepResult ` + $ofStepResult = ConvertTo-IdleStepResult ` -Output $retry.Value ` -StepName $ofName ` -StepType ([string]$ofType) ` -Attempts ([int]$retry.Attempts) ` - -Source $handlerSource + -Source $handlerSource ` + -StateSchema (Get-IdlePropertyValue -Object $ofStep -Name 'StateSchema') + $onFailureStepResults += $ofStepResult + + if ($null -ne $ofStepResult.State) { + $stepState[$ofName] = Copy-IdleDataObject -Value $ofStepResult.State + } $context.EventSink.WriteEvent( 'IdLE.OnFailure.StepCompleted', diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index cef20b61..0d859f57 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -126,8 +126,13 @@ function New-IdlePlanObject { # Normalize primary and OnFailure steps. # IMPORTANT: # ConvertTo-IdleWorkflowSteps may return an empty array that would otherwise collapse to $null on assignment. - $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog) - $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog) + # State references: Steps see earlier Steps; OnFailureSteps see all Steps and earlier OnFailureSteps. + $stateProducers = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog -StateProducers $stateProducers) + + $onFailureStepNames = @(@($workflowOnFailureSteps) | Where-Object { $null -ne $_ } | ForEach-Object { [string](Get-IdlePropertyValue -Object $_ -Name 'Name') }) + $ambiguousStateNames = @($plan.Steps | ForEach-Object { $_.Name } | Where-Object { $onFailureStepNames -contains $_ }) + $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog -StateProducers $stateProducers -AmbiguousStateNames $ambiguousStateNames) # Fail-fast capability validation (includes OnFailureSteps). $allStepsForCapabilities = @() diff --git a/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 b/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 index 840f2a9a..4d81153d 100644 --- a/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 +++ b/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 @@ -26,6 +26,12 @@ IdentityKey = @{ Type = 'String'; Required = $true } } } + # Mailbox: the provider's mailbox object (e.g. PrimarySmtpAddress, RecipientTypeDetails). + StateSchema = @{ + Keys = @{ + Mailbox = @{ Type = 'Any'; Required = $true } + } + } } # IdLE.Step.Mailbox.EnsureType - idempotently converts a mailbox to the specified type diff --git a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 index 49391b06..d5686997 100644 --- a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 @@ -196,3 +196,79 @@ Describe 'Mailbox OutOfOffice step - template resolution' { } } } + +Describe 'Mailbox GetInfo step - State for later steps' { + BeforeAll { + $script:ExoLeaverPath = Join-Path (Get-RepoRootPath) 'examples/workflows/templates/exo-leaver.psd1' + + function New-StateMailboxProvider { + param([string] $MailboxType) + + $provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + MailboxType = $MailboxType + TypeCalls = [System.Collections.Generic.List[string]]::new() + } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Type.Ensure', 'IdLE.Mailbox.OutOfOffice.Ensure') + } + $provider | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + param($IdentityKey, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.Mailbox' + IdentityKey = 'max.power@contoso.com' + PrimarySmtpAddress = 'max.power@contoso.com' + Type = $this.MailboxType + } + } + $provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxType -Value { + param($IdentityKey, $DesiredType, $AuthSession) + $this.TypeCalls.Add("$IdentityKey|$DesiredType") + return [pscustomobject]@{ IdentityKey = $IdentityKey; Changed = $true } + } + $provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { + param($IdentityKey, $Config, $AuthSession) + return [pscustomobject]@{ IdentityKey = $IdentityKey; Changed = $true } + } + return $provider + } + + function Invoke-ExoLeaver { + param([object] $Provider) + + $providers = @{ + ExchangeOnline = $Provider + AuthSessionBroker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'mock-token-string' + } + $request = New-IdleTestRequest -LifecycleEvent 'Leaver' -Intent @{ + UserPrincipalName = 'max.power@contoso.com' + Manager = @{ DisplayName = 'Manager'; Mail = 'manager@contoso.com' } + ServiceDesk = @{ DisplayName = 'Service Desk'; Mail = 'servicedesk@contoso.com' } + } + $plan = New-IdlePlan -WorkflowPath $script:ExoLeaverPath -Request $request -Providers $providers + return Invoke-IdlePlan -Plan $plan -Providers $providers + } + } + + It 'uses the mailbox State in a Precondition and a With template (example workflow)' { + $provider = New-StateMailboxProvider -MailboxType 'User' + $result = Invoke-ExoLeaver -Provider $provider + + $result.Status | Should -Be 'Completed' + $result.Steps[0].State.Mailbox.PrimarySmtpAddress | Should -Be 'max.power@contoso.com' + @($provider.TypeCalls) | Should -Be @('max.power@contoso.com|Shared') + + $completion = @($result.Events | Where-Object { $_.Type -eq 'IdLE.Step.EmitEvent.Emitted' }) + $completion.Count | Should -Be 1 + $completion[0].Message | Should -Be 'Mailbox offboarding completed for max.power@contoso.com (max.power@contoso.com).' + } + + It 'skips the conversion for a room mailbox via the State Precondition' { + $provider = New-StateMailboxProvider -MailboxType 'Room' + $result = Invoke-ExoLeaver -Provider $provider + + $result.Status | Should -Be 'Completed' + ($result.Steps | Where-Object Name -eq 'ConvertToSharedMailbox').Status | Should -Be 'PreconditionSkipped' + @($provider.TypeCalls).Count | Should -Be 0 + } +} diff --git a/tests/Core/StepState.Tests.ps1 b/tests/Core/StepState.Tests.ps1 new file mode 100644 index 00000000..149ccfbf --- /dev/null +++ b/tests/Core/StepState.Tests.ps1 @@ -0,0 +1,410 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + # Producer: publishes With.Publish as State, or $global:IdleStateTestOutput when set (non-data values). + function global:Invoke-IdleStateTestProducer { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $With + ) + + if ($null -ne $global:IdleStateTestOutput) { + return $global:IdleStateTestOutput + } + return @{ State = $With['Publish'] } + } + + # Consumer: records the With it receives. + function global:Invoke-IdleStateTestConsumer { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $With, + [Parameter(Mandatory)] [object] $Step + ) + + $global:IdleStateTestConsumed[$Step.Name] = $With + return $null + } + + # Step without StateSchema that returns State anyway. + function global:Invoke-IdleStateTestUndeclared { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $With + ) + + return @{ State = @{ Value = 'x' } } + } + + function global:Invoke-IdleStateTestFail { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $With + ) + + throw 'Simulated failure.' + } + + function New-StateTestProviders { + param([hashtable] $ProducerStateSchema) + + if ($null -eq $ProducerStateSchema) { + $ProducerStateSchema = @{ + Keys = @{ + Mailbox = @{ Type = 'Any'; Required = $true } + Count = @{ Type = 'Int' } + } + } + } + + return @{ + StepRegistry = @{ + 'Test.IdLE.Step.Produce' = 'Invoke-IdleStateTestProducer' + 'Test.IdLE.Step.Consume' = 'Invoke-IdleStateTestConsumer' + 'Test.IdLE.Step.Undeclared' = 'Invoke-IdleStateTestUndeclared' + 'Test.IdLE.Step.Fail' = 'Invoke-IdleStateTestFail' + } + StepMetadata = @{ + 'Test.IdLE.Step.Produce' = @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{ Publish = @{ Type = 'Hashtable' } } } + StateSchema = $ProducerStateSchema + } + 'Test.IdLE.Step.Consume' = @{ + RequiredCapabilities = @() + WithSchema = @{ + Keys = @{ + Value = @{ Type = 'String' } + Number = @{ Type = 'Int' } + Items = @{ Type = 'Array'; Items = @{ Type = 'String' } } + } + } + } + 'Test.IdLE.Step.Undeclared' = @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{} } + } + 'Test.IdLE.Step.Fail' = @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{} } + } + } + } + } + + function New-StateTestPlan { + param( + [Parameter(Mandatory)] [string] $Steps, + [Parameter()] [string] $OnFailureSteps = '@()', + [Parameter()] [hashtable] $Intent = @{ Text = 'hello' }, + [Parameter()] [hashtable] $Providers = (New-StateTestProviders) + ) + + $wfPath = New-IdleTestWorkflowFile -FileName 'step-state.psd1' -Content @" +@{ + Name = 'State' + LifecycleEvent = 'Joiner' + Steps = $Steps + OnFailureSteps = $OnFailureSteps +} +"@ + return New-IdlePlan -WorkflowPath $wfPath -Request (New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent $Intent) -Providers $Providers + } + + function Invoke-StateTestWorkflow { + param( + [Parameter(Mandatory)] [string] $Steps, + [Parameter()] [string] $OnFailureSteps = '@()', + [Parameter()] [hashtable] $Intent = @{ Text = 'hello' } + ) + + $providers = New-StateTestProviders + $plan = New-StateTestPlan -Steps $Steps -OnFailureSteps $OnFailureSteps -Intent $Intent -Providers $providers + return Invoke-IdlePlan -Plan $plan -Providers $providers + } + + $script:ProduceStep = "@{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce'; With = @{ Publish = @{ Mailbox = @{ Type = 'UserMailbox'; Smtp = 'a@contoso.com' }; Count = 5 } } }" +} + +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleStateTestProducer', 'Function:\Invoke-IdleStateTestConsumer', 'Function:\Invoke-IdleStateTestUndeclared', 'Function:\Invoke-IdleStateTestFail' -ErrorAction SilentlyContinue + Remove-Variable -Name 'IdleStateTestOutput', 'IdleStateTestConsumed' -Scope Global -ErrorAction SilentlyContinue +} + +Describe 'Step State' { + + BeforeEach { + $global:IdleStateTestOutput = $null + $global:IdleStateTestConsumed = @{} + } + + Context 'StateSchema in the step metadata' { + + It 'rejects a StateSchema without Keys (InvalidStateSchema)' { + $providers = New-StateTestProviders -ProducerStateSchema @{ AllowAdditionalKeys = $true } + { New-StateTestPlan -Steps "@( $script:ProduceStep )" -Providers $providers } | + Should -Throw '*InvalidStateSchema*StateSchema is missing*Keys*' + } + + It 'rejects an invalid key schema (InvalidStateSchema)' { + $providers = New-StateTestProviders -ProducerStateSchema @{ Keys = @{ Mailbox = @{ Type = 'Object' } } } + { New-StateTestPlan -Steps "@( $script:ProduceStep )" -Providers $providers } | + Should -Throw '*InvalidStateSchema*StateSchema.Keys.Mailbox.Type*' + } + + It 'carries the StateSchema on the plan step' { + $plan = New-StateTestPlan -Steps "@( $script:ProduceStep )" + $plan.Steps[0].StateSchema.Type | Should -Be 'Hashtable' + $plan.Steps[0].StateSchema.Keys.Keys | Should -Contain 'Mailbox' + } + } + + Context 'Planning validation' { + + It 'rejects State references in Condition (StateReferenceNotAllowed)' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; Condition = @{ Exists = 'State.Produce.Mailbox' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw '*StateReferenceNotAllowed*Condition*' + } + + It 'rejects a reference to an unknown step (StateReferenceUnknownStep)' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.Missing.Mailbox}}' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw "*StateReferenceUnknownStep*no step named 'Missing'*" + } + + It 'rejects a reference to a step that runs later (StateReferenceUnknownStep)' { + $steps = "@( @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; Precondition = @{ Exists = 'State.Produce.Mailbox' } }, $script:ProduceStep )" + { New-StateTestPlan -Steps $steps } | Should -Throw "*StateReferenceUnknownStep*'Produce' runs before it*" + } + + It 'rejects a reference to the step itself (StateReferenceUnknownStep)' { + $steps = "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce'; Precondition = @{ Exists = 'State.Produce.Mailbox' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw '*StateReferenceUnknownStep*' + } + + It 'rejects a step name that is not an identifier (StateReferenceInvalid)' { + $steps = "@( @{ Name = 'Get mailbox'; Type = 'Test.IdLE.Step.Produce'; With = @{ Publish = @{ Mailbox = 'x' } } }, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; Precondition = @{ Exists = 'State.Get mailbox.Mailbox' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw '*StateReferenceInvalid*must be an identifier*' + } + + It 'rejects a reference without step name (StateReferenceInvalid)' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; Precondition = @{ Exists = 'State' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw '*StateReferenceInvalid*' + } + + It 'rejects a reference to a step without StateSchema (StateReferenceNotDeclared)' { + $steps = "@( @{ Name = 'U'; Type = 'Test.IdLE.Step.Undeclared' }, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.U.Value}}' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw '*StateReferenceNotDeclared*declares no StateSchema*' + } + + It 'rejects an undeclared State key (StateReferenceNotDeclared)' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.Produce.Other}}' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw "*StateReferenceNotDeclared*does not declare 'Other'*Count, Mailbox*" + } + + It 'rejects an OnFailureSteps reference to a name used in both collections (StateReferenceAmbiguous)' { + $onFailure = "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce'; With = @{ Publish = @{ Mailbox = 'x' } } }, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.Produce.Mailbox.Type}}' } } )" + { New-StateTestPlan -Steps "@( $script:ProduceStep )" -OnFailureSteps $onFailure } | Should -Throw '*StateReferenceAmbiguous*' + } + + It 'rejects State in the provider routing keys (StateReferenceNotAllowed)' { + $providers = New-StateTestProviders + $providers.StepMetadata['Test.IdLE.Step.Consume'].Provider = @{ DefaultAlias = 'Identity' } + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ AuthSessionName = '{{State.Produce.Mailbox.Type}}' } } )" + { New-StateTestPlan -Steps $steps -Providers $providers } | Should -Throw '*StateReferenceNotAllowed*With.AuthSessionName*' + } + + It 'fails planning when a Request placeholder of a deferred string does not resolve' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{Request.Intent.Missing}}-{{State.Produce.Mailbox.Type}}' } } )" + { New-StateTestPlan -Steps $steps } | Should -Throw "*Request.Intent.Missing*resolved to null*" + } + + It 'keeps deferred strings verbatim in the plan and records them in DeferredWith' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{Request.Intent.Text}}:{{State.Produce.Mailbox.Smtp}}'; Number = '{{State.Produce.Count}}'; Items = @('static', '{{State.Produce.Mailbox.Type}}') } } )" + $plan = New-StateTestPlan -Steps $steps + + $consumer = $plan.Steps[1] + $consumer.With.Value | Should -Be '{{Request.Intent.Text}}:{{State.Produce.Mailbox.Smtp}}' + $consumer.With.Number | Should -Be '{{State.Produce.Count}}' + @($consumer.DeferredWith.Path) | Should -Be @('With.Items[1]', 'With.Number', 'With.Value') -Because 'every deferred string is recorded' + ($consumer.DeferredWith | Where-Object Path -eq 'With.Number').Schema.Type | Should -Be 'Int' + ($consumer.DeferredWith | Where-Object Path -eq 'With.Items[1]').Schema.Type | Should -Be 'String' + + $json = Export-IdlePlan -Plan $plan + $json | Should -Match ([regex]::Escape('{{State.Produce.Count}}')) + } + + It 'does not defer strings without State placeholders' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{Request.Intent.Text}}' } } )" + $plan = New-StateTestPlan -Steps $steps + $plan.Steps[1].With.Value | Should -Be 'hello' + @($plan.Steps[1].DeferredWith).Count | Should -Be 0 + } + } + + Context 'Execution' { + + It 'passes State to later steps via With templates and Preconditions' { + $steps = @" +@( + $script:ProduceStep + @{ + Name = 'C' + Type = 'Test.IdLE.Step.Consume' + Precondition = @{ Equals = @{ Path = 'State.Produce.Mailbox.Type'; Value = 'UserMailbox' } } + With = @{ Value = '{{Request.Intent.Text}}:{{State.Produce.Mailbox.Smtp}}'; Number = '{{State.Produce.Count}}' } + } +) +"@ + $result = Invoke-StateTestWorkflow -Steps $steps + + $result.Status | Should -Be 'Completed' + $result.Steps[0].State.Count | Should -Be 5 + $global:IdleStateTestConsumed['C'].Value | Should -Be 'hello:a@contoso.com' + $global:IdleStateTestConsumed['C'].Number | Should -Be 5 + $global:IdleStateTestConsumed['C'].Number | Should -BeOfType [int] + } + + It 'does not modify the plan step when resolving deferred With' { + $providers = New-StateTestProviders + $plan = New-StateTestPlan -Providers $providers -Steps "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Number = '{{State.Produce.Count}}' } } )" + $null = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan.Steps[1].With.Number | Should -Be '{{State.Produce.Count}}' + } + + It 'never resolves State placeholders that come from request data' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{Request.Intent.Text}}|{{State.Produce.Count}}' } } )" + $result = Invoke-StateTestWorkflow -Steps $steps -Intent @{ Text = '{{State.Produce.Mailbox.Smtp}}' } + + $result.Status | Should -Be 'Completed' + $global:IdleStateTestConsumed['C'].Value | Should -Be '{{State.Produce.Mailbox.Smtp}}|5' + } + + It 'lets a Precondition with Exists skip a step whose producer did not run' { + $steps = @" +@( + @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce'; Condition = @{ Equals = @{ Path = 'Request.LifecycleEvent'; Value = 'Leaver' } }; With = @{ Publish = @{ Mailbox = 'x' } } } + @{ + Name = 'C' + Type = 'Test.IdLE.Step.Consume' + Precondition = @{ All = @( @{ Exists = 'State.Produce.Mailbox' }, @{ Equals = @{ Path = 'State.Produce.Mailbox'; Value = 'x' } } ) } + OnPreconditionFalse = 'Continue' + } +) +"@ + $result = Invoke-StateTestWorkflow -Steps $steps + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'NotApplicable' + $result.Steps[1].Status | Should -Be 'PreconditionSkipped' + $global:IdleStateTestConsumed.ContainsKey('C') | Should -BeFalse + } + + It 'fails the step when a With template references State of a producer that did not run (StateReferenceUnresolved)' { + $steps = @" +@( + @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce'; Condition = @{ Equals = @{ Path = 'Request.LifecycleEvent'; Value = 'Leaver' } }; With = @{ Publish = @{ Mailbox = 'x' } } } + @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.Produce.Mailbox}}' } } +) +"@ + $result = Invoke-StateTestWorkflow -Steps $steps + + $result.Status | Should -Be 'Failed' + $result.Steps[1].Status | Should -Be 'Failed' + $result.Steps[1].Error | Should -Match 'StateReferenceUnresolved' + } + + It 'validates a resolved deferred value against its key schema (WithKeyTypeMismatch)' { + $steps = "@( $script:ProduceStep, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Number = '{{State.Produce.Mailbox.Type}}' } } )" + $result = Invoke-StateTestWorkflow -Steps $steps + + $result.Steps[1].Status | Should -Be 'Failed' + $result.Steps[1].Error | Should -Match 'WithKeyTypeMismatch.*With\.Number must be an Int' + } + + It 'gives OnFailureSteps the State of completed steps' { + $steps = "@( $script:ProduceStep, @{ Name = 'Boom'; Type = 'Test.IdLE.Step.Fail' } )" + $onFailure = "@( @{ Name = 'Cleanup'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.Produce.Mailbox.Smtp}}' } } )" + $result = Invoke-StateTestWorkflow -Steps $steps -OnFailureSteps $onFailure + + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'Completed' + $global:IdleStateTestConsumed['Cleanup'].Value | Should -Be 'a@contoso.com' + } + + It 'publishes StateSchema defaults when the handler returns no State' { + $providers = New-StateTestProviders -ProducerStateSchema @{ Keys = @{ Mailbox = @{ Type = 'String'; Default = 'none' } } } + $steps = "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce' }, @{ Name = 'C'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.Produce.Mailbox}}' } } )" + $plan = New-StateTestPlan -Steps $steps -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].State.Mailbox | Should -Be 'none' + $global:IdleStateTestConsumed['C'].Value | Should -Be 'none' + } + + It 'publishes no State for a failed producer' { + # Undeclared key 'Other': the producer fails with InvalidStepHandlerResult. + $steps = "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce'; With = @{ Publish = @{ Mailbox = 'x'; Other = 1 } } } )" + $onFailure = @" +@( + @{ Name = 'Guarded'; Type = 'Test.IdLE.Step.Consume'; Precondition = @{ Exists = 'State.Produce.Mailbox' }; OnPreconditionFalse = 'Continue' } + @{ Name = 'Unguarded'; Type = 'Test.IdLE.Step.Consume'; With = @{ Value = '{{State.Produce.Mailbox}}' } } +) +"@ + $result = Invoke-StateTestWorkflow -Steps $steps -OnFailureSteps $onFailure + + $result.Status | Should -Be 'Failed' + $result.Steps[0].Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'PartiallyFailed' + ($result.OnFailure.Steps | Where-Object Name -eq 'Guarded').Status | Should -Be 'PreconditionSkipped' + $unguarded = $result.OnFailure.Steps | Where-Object Name -eq 'Unguarded' + $unguarded.Status | Should -Be 'Failed' + $unguarded.Error | Should -Match 'StateReferenceUnresolved' + $global:IdleStateTestConsumed.Count | Should -Be 0 + } + } + + Context 'Handler result validation' { + + It 'rejects State from a step without StateSchema (InvalidStepHandlerResult)' { + $result = Invoke-StateTestWorkflow -Steps "@( @{ Name = 'U'; Type = 'Test.IdLE.Step.Undeclared' } )" + + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Match 'InvalidStepHandlerResult.*declares no StateSchema' + } + + It 'rejects undeclared State keys (InvalidStepHandlerResult)' { + $result = Invoke-StateTestWorkflow -Steps "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce'; With = @{ Publish = @{ Mailbox = 'x'; Other = 1 } } } )" + + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Match 'InvalidStepHandlerResult.*does not match its StateSchema.*State\.Other' + } + + It 'rejects missing required State keys (InvalidStepHandlerResult)' { + $result = Invoke-StateTestWorkflow -Steps "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce' } )" + + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Match 'InvalidStepHandlerResult.*State\.Mailbox is required' + } + + It 'rejects ScriptBlocks in State (InvalidStepHandlerResult)' { + $global:IdleStateTestOutput = @{ State = @{ Mailbox = @{ Nested = { 'code' } } } } + $result = Invoke-StateTestWorkflow -Steps "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce' } )" + + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Match 'InvalidStepHandlerResult.*ScriptBlocks are not allowed.*State\.Mailbox\.Nested' + } + + It 'rejects credential objects in State (InvalidStepHandlerResult)' { + $secret = ConvertTo-SecureString -String 'not-a-real-secret' -AsPlainText -Force + $global:IdleStateTestOutput = @{ State = @{ Mailbox = [pscredential]::new('user', $secret) } } + $result = Invoke-StateTestWorkflow -Steps "@( @{ Name = 'Produce'; Type = 'Test.IdLE.Step.Produce' } )" + + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Match 'InvalidStepHandlerResult.*Credential objects \(PSCredential\).*State\.Mailbox' + } + } +} diff --git a/tests/Packaging/Generate-IdleStepReference.Tests.ps1 b/tests/Packaging/Generate-IdleStepReference.Tests.ps1 index 4baaab05..87991f8a 100644 --- a/tests/Packaging/Generate-IdleStepReference.Tests.ps1 +++ b/tests/Packaging/Generate-IdleStepReference.Tests.ps1 @@ -63,4 +63,42 @@ Describe 'Generate-IdleStepReference.ps1' { $script:PagesByStepType[$StepType] | Should -Match ([regex]::Escape("- **Required Capabilities**: $expected")) } } + + Context 'State outputs' { + + BeforeDiscovery { + $repoRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '../..')).Path + $catalogFiles = Get-ChildItem -Path (Join-Path $repoRoot 'src') -Directory -Filter 'IdLE.Steps.*' | + ForEach-Object { Join-Path $_.FullName 'StepMetadataCatalog.psd1' } | + Where-Object { Test-Path -LiteralPath $_ } + + $script:StateCases = foreach ($catalogFile in $catalogFiles) { + $catalog = Import-PowerShellDataFile -Path $catalogFile + foreach ($stepType in $catalog.Keys) { + $metadata = $catalog[$stepType] + @{ + StepType = $stepType + StateKeys = @(if ($metadata.Contains('StateSchema')) { $metadata['StateSchema']['Keys'].Keys }) + } + } + } + } + + It 'catalogs declare at least one step with a StateSchema' -ForEach @(@{ CaseCount = @($script:StateCases | Where-Object { $_.StateKeys.Count -gt 0 }).Count }) { + $CaseCount | Should -BeGreaterThan 0 + } + + It 'renders the State outputs of only if it declares a StateSchema' -ForEach $script:StateCases { + $page = $script:PagesByStepType[$StepType] + if ($StateKeys.Count -eq 0) { + $page | Should -Not -Match ([regex]::Escape('## Outputs (State.*)')) + return + } + + $page | Should -Match ([regex]::Escape('## Outputs (State.*)')) + foreach ($key in $StateKeys) { + $page | Should -Match ([regex]::Escape("| ``$key`` |")) + } + } + } } diff --git a/tests/Steps/_testHelpers.Steps.ps1 b/tests/Steps/_testHelpers.Steps.ps1 index 3660e243..4e22cd02 100644 --- a/tests/Steps/_testHelpers.Steps.ps1 +++ b/tests/Steps/_testHelpers.Steps.ps1 @@ -103,7 +103,8 @@ function Invoke-IdleTestStepHandler { .DESCRIPTION Validates Step.With against the step metadata catalog (plan-time contract: types, defaults, Provider alias), builds the handler arguments (-Context, -With, -Provider handle, -Step) and converts the handler - output into an IdLE.StepResult. Use it in step unit tests instead of calling the handler directly. + output into an IdLE.StepResult (State validated against the StateSchema). Use it in step unit tests + instead of calling the handler directly. .PARAMETER Context Execution context (Providers, EventSink, optional AcquireAuthSession). @@ -159,7 +160,7 @@ function Invoke-IdleTestStepHandler { $handlerArguments = New-IdleStepHandlerArguments -Handler $handler -Step $planStep -Context $Context $output = & $handler @handlerArguments - ConvertTo-IdleStepResult -Output $output -StepName $stepName -StepType $stepType -Attempts 1 -Source $null + ConvertTo-IdleStepResult -Output $output -StepName $stepName -StepType $stepType -Attempts 1 -Source $null -StateSchema (Get-IdleStepStateSchema -Metadata $catalog[$stepType]) } $Context $Step $StepMetadata $StepRegistry } diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index 8fa32738..c832246a 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -248,11 +248,16 @@ function Get-IdleStepWithKeyRows { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $ModuleName + [string] $ModuleName, + + # WithSchema (inputs) or StateSchema (outputs, optional in the catalog). + [Parameter()] + [ValidateSet('WithSchema', 'StateSchema')] + [string] $SchemaName = 'WithSchema' ) - # Rows are derived from the step's typed WithSchema (StepMetadataCatalog), including the engine-provided - # common keys for steps that declare a Provider. + # Rows are derived from the step's typed WithSchema / StateSchema (StepMetadataCatalog), including the + # engine-provided common keys for steps that declare a Provider (WithSchema only). $module = Get-Module -Name $ModuleName -All -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $module) { return @() @@ -264,7 +269,10 @@ function Get-IdleStepWithKeyRows { } $metadata = $catalog[$StepType] - $keys = $metadata['WithSchema']['Keys'] + if (-not $metadata.ContainsKey($SchemaName)) { + return @() + } + $keys = $metadata[$SchemaName]['Keys'] function Get-IdleKeyNotes { param([hashtable] $KeySchema) @@ -307,7 +315,7 @@ function Get-IdleStepWithKeyRows { }) } - if ($metadata.ContainsKey('Provider')) { + if ($SchemaName -eq 'WithSchema' -and $metadata.ContainsKey('Provider')) { $alias = [string]$metadata['Provider']['DefaultAlias'] $sessionDefault = if ($metadata['Provider']['AuthSessionNameFromAlias'] -eq $true) { 'provider alias' } else { 'broker default session' } $rows.Add([pscustomobject]@{ Name = 'Provider'; Type = 'String'; Required = $false; Default = "``$alias``"; Notes = 'Provider alias in `Providers`.'; Common = $true }) @@ -512,6 +520,7 @@ function New-IdleStepDocModel { $withKeys = @(Get-IdleStepWithKeyRows -StepType $stepType -ModuleName $moduleName) $requiredWithKeys = @($withKeys | Where-Object Required | ForEach-Object Name) + $stateKeys = @(Get-IdleStepWithKeyRows -StepType $stepType -ModuleName $moduleName -SchemaName 'StateSchema') $idempotent = 'Unknown' if ($description -match '(?i)\bidempotent\b') { @@ -572,6 +581,7 @@ function New-IdleStepDocModel { Description = $description WithKeys = $withKeys RequiredWithKeys = $requiredWithKeys + StateKeys = $stateKeys Idempotent = $idempotent RequiredCapabilities = $requiredCapabilities Examples = $examples @@ -639,6 +649,20 @@ function New-IdleStepDetailPageContent { [void]$sb.AppendLine() } + if ($Model.StateKeys.Count -gt 0) { + [void]$sb.AppendLine('## Outputs (State.*)') + [void]$sb.AppendLine() + [void]$sb.AppendLine('Published under `State..` for later steps (`Precondition` and `With` templates); validated against the step''s `StateSchema` (see [Step State](../../extend/steps.md#step-state)).') + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Key | Type | Always set | Notes |') + [void]$sb.AppendLine('| --- | --- | --- | --- |') + foreach ($row in $Model.StateKeys) { + $required = if ($row.Required) { 'Yes' } else { 'No' } + [void]$sb.AppendLine(("| ``{0}`` | {1} | {2} | {3} |" -f $row.Name, $row.Type, $required, $row.Notes)) + } + [void]$sb.AppendLine() + } + # Add examples section [void]$sb.AppendLine('## Example') [void]$sb.AppendLine()