diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index 6cbd6627..fb91534e 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -106,6 +106,109 @@ If the condition is not met, the step is marked as `Skipped` and a skip event is ## References and inputs +### Template substitution ({{...}}) + +IdLE supports **template substitution** for embedding request values into workflow step configurations using `{{...}}` placeholders. Templates are resolved during planning (plan build), producing a plan with resolved values. + +**How it works:** + +When you create a lifecycle request, you provide data in the request object (via `DesiredState`, `IdentityKeys`, etc.). Templates in workflow configurations reference these values using dot-notation paths. During plan building, IdLE resolves the templates by looking up the paths in the request object and substituting the actual values. + +**Creating a request with values:** + +```powershell +$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + UserPrincipalName = 'jdoe@example.com' + DisplayName = 'John Doe' + GivenName = 'John' + Surname = 'Doe' + Department = 'Engineering' +} +``` + +The values in `DesiredState` are accessible via `Request.Input.*` (or `Request.DesiredState.*`) in templates. + +**Using templates in workflows:** + +```powershell +@{ + Name = 'CreateUser' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + Attributes = @{ + UserPrincipalName = '{{Request.Input.UserPrincipalName}}' + DisplayName = '{{Request.Input.DisplayName}}' + } + } +} +@{ + Name = 'EmitEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Creating user {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}})' + } +} +``` + +When the plan is built, templates are resolved to the actual values from the request: +- `{{Request.Input.UserPrincipalName}}` → `'jdoe@example.com'` +- `{{Request.Input.DisplayName}}` → `'John Doe'` + +**Key features:** + +- **Concise syntax**: Use `{{Path}}` instead of verbose `@{ ValueFrom = 'Path' }` objects +- **Multiple placeholders**: Place multiple templates in one string +- **Nested structures**: Templates work in nested hashtables and arrays +- **Planning-time resolution**: Templates are resolved during plan build, not execution +- **Security boundary**: Only allowlisted request roots are accessible + +**Allowed roots:** + +For security, template resolution only allows accessing these request properties: + +- `Request.Input.*` (aliased to `Request.DesiredState.*` if Input does not exist) +- `Request.DesiredState.*` +- `Request.IdentityKeys.*` +- `Request.Changes.*` +- `Request.LifecycleEvent` +- `Request.CorrelationId` +- `Request.Actor` + +Attempting to access other roots (like `Plan.*`, `Providers.*`, or `Workflow.*`) will fail during planning with an actionable error. + +**Type handling:** + +Templates resolve scalar values (string, numeric, bool, datetime, guid) to strings. Non-scalar values (hashtables, arrays, objects) are rejected with an error. If you need to map complex objects, use explicit mapping steps or host-side pre-flattening. + +**Error handling:** + +Template resolution fails fast during planning if: + +- Path does not exist or resolves to `$null` +- Path uses invalid characters or patterns +- Braces are unbalanced (typo safety) +- Root is not in the allowlist +- Value is non-scalar + +These deterministic errors prevent silent substitution bugs (like empty UPNs). + +**Escaping:** + +Use `\{{` to include literal `{{` in a string: + +```powershell +With = @{ + Message = 'Literal \{{ braces here and template {{Request.Input.Name}}' +} +# Resolves to: 'Literal {{ braces here and template ' +``` + +**Request.Input alias:** + +Workflow authors can use `Request.Input.*` for consistency, even if the request object only provides `DesiredState`. IdLE automatically aliases `Request.Input.*` to `Request.DesiredState.*` when the `Input` property does not exist. + +### Legacy reference syntax (ValueFrom) + Prefer explicit reference fields over implicit parsing: - `Value` for literals @@ -114,6 +217,8 @@ Prefer explicit reference fields over implicit parsing: This makes configurations safe and statically validatable. +**Note:** Template substitution (`{{...}}`) is preferred for string fields. Use `ValueFrom` objects when you need non-string references or conditional defaults. + ## Advanced Workflow Patterns (Content for advanced patterns will be added in future updates) diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 new file mode 100644 index 00000000..5e03717f --- /dev/null +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -0,0 +1,205 @@ +function Resolve-IdleTemplateString { + <# + .SYNOPSIS + Resolves template placeholders in a string using request context. + + .DESCRIPTION + Scans a string for {{...}} placeholders and resolves them against the request object. + Only allowlisted request roots are permitted for security. + + Template syntax: + - Placeholder format: {{}} + - Path is a dot-separated property path + - Multiple placeholders are supported in one string + + Allowed roots (security boundary): + - Request.Input.* (aliased to Request.DesiredState.* if Input does not exist) + - Request.DesiredState.* + - Request.IdentityKeys.* + - Request.Changes.* + - Request.LifecycleEvent + - Request.CorrelationId + - Request.Actor + + Escaping: + - \{{ → literal {{ (escape removed after resolution) + + .PARAMETER Value + The string value to resolve. If not a string, returns the value unchanged. + + .PARAMETER Request + The request object providing context for template resolution. + + .PARAMETER StepName + The name of the step being processed (for error messages). + + .OUTPUTS + Resolved string with placeholders replaced by request values. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Request, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -isnot [string]) { + return $Value + } + + $stringValue = [string]$Value + + # Quick exit: no template markers present + if ($stringValue -notlike '*{{*' -and $stringValue -notlike '*}}*') { + # Handle escaped braces with no actual templates + if ($stringValue -like '*\{{*') { + return $stringValue -replace '\\{{', '{{' + } + return $stringValue + } + + # Check for unbalanced braces (typo safety) + # Count non-escaped opening braces + $openCount = ([regex]::Matches($stringValue, '(? + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Request, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName + ) + + if ($null -eq $Value) { + return $null + } + + # Strings: resolve templates + if ($Value -is [string]) { + return Resolve-IdleTemplateString -Value $Value -Request $Request -StepName $StepName + } + + # Primitives: return as-is + if ($Value -is [int] -or + $Value -is [long] -or + $Value -is [double] -or + $Value -is [decimal] -or + $Value -is [bool] -or + $Value -is [datetime] -or + $Value -is [guid]) { + return $Value + } + + # Hashtables/dictionaries: recurse on values + if ($Value -is [System.Collections.IDictionary]) { + $resolved = @{} + foreach ($key in $Value.Keys) { + $resolved[$key] = Resolve-IdleWorkflowTemplates -Value $Value[$key] -Request $Request -StepName $StepName + } + return $resolved + } + + # Arrays/lists: recurse on items + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $resolved = @() + foreach ($item in $Value) { + $resolved += Resolve-IdleWorkflowTemplates -Value $item -Request $Request -StepName $StepName + } + return $resolved + } + + # PSCustomObject: recurse on properties + $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) + if (@($props).Count -gt 0) { + $resolved = [ordered]@{} + foreach ($prop in $props) { + $resolved[$prop.Name] = Resolve-IdleWorkflowTemplates -Value $prop.Value -Request $Request -StepName $StepName + } + return [pscustomobject]$resolved + } + + # Fallback: return as-is + return $Value +} diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 5dbb79dc..cff9617a 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -521,6 +521,9 @@ function New-IdlePlanObject { @{} } + # Resolve template placeholders in With (planning-time resolution) + $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = $stepName diff --git a/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 new file mode 100644 index 00000000..6a3ff40b --- /dev/null +++ b/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -0,0 +1,438 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Helper to get fixture workflow path + function Get-TemplateTestFixture { + param([string]$Name) + return Join-Path $PSScriptRoot "fixtures/workflows/template-tests/$Name.psd1" + } +} + +Describe 'Template Substitution' { + Context 'Single placeholder substitution' { + It 'resolves a simple Request.Input placeholder' { + $wfPath = Get-TemplateTestFixture 'template-simple' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + UserPrincipalName = 'jdoe@example.com' + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.UserName | Should -Be 'jdoe@example.com' + } + + It 'resolves Request.DesiredState placeholder directly' { + $wfPath = Get-TemplateTestFixture 'template-desiredstate' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Department = 'Engineering' + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Department | Should -Be 'Engineering' + } + } + + Context 'Multiple placeholders in one string' { + It 'resolves multiple placeholders in a single string' { + $wfPath = Get-TemplateTestFixture 'template-multiple' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + DisplayName = 'John Doe' + UserPrincipalName = 'jdoe@example.com' + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Message | Should -Be 'User John Doe (jdoe@example.com) is joining.' + } + } + + Context 'Nested hashtable and array substitution' { + It 'resolves templates in nested hashtables' { + $wfPath = Get-TemplateTestFixture 'template-nested-hash' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + DisplayName = 'Jane Smith' + Mail = 'jsmith@example.com' + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.User.Name | Should -Be 'Jane Smith' + $plan.Steps[0].With.User.Email | Should -Be 'jsmith@example.com' + } + + It 'resolves templates in arrays' { + $wfPath = Get-TemplateTestFixture 'template-array' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + PrimaryEmail = 'primary@example.com' + SecondaryEmail = 'secondary@example.com' + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Emails[0] | Should -Be 'primary@example.com' + $plan.Steps[0].With.Emails[1] | Should -Be 'secondary@example.com' + } + } + + Context 'Invalid syntax handling' { + It 'throws on unbalanced opening brace' { + $wfPath = Get-TemplateTestFixture 'template-unbalanced-open' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*Unbalanced braces*' + } + + It 'throws on unbalanced closing brace' { + $wfPath = Get-TemplateTestFixture 'template-unbalanced-close' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*Unbalanced braces*' + } + } + + Context 'Invalid path patterns' { + It 'throws on path with spaces' { + $wfPath = Get-TemplateTestFixture 'template-path-spaces' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*Invalid path pattern*' + } + + It 'throws on path with special characters' { + $wfPath = Get-TemplateTestFixture 'template-path-special' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*Invalid path pattern*' + } + } + + Context 'Missing path segments' { + It 'throws when path does not exist' { + $wfPath = Get-TemplateTestFixture 'template-missing-path' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*resolved to null or does not exist*' + } + } + + Context 'Null resolved values' { + It 'throws when resolved value is null' { + $wfPath = Get-TemplateTestFixture 'template-null-value' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ NullField = $null } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*resolved to null*' + } + } + + Context 'Disallowed root access' { + It 'throws when accessing Plan root' { + $wfPath = Get-TemplateTestFixture 'template-plan-root' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*is not allowed*' + } + + It 'throws when accessing Providers root' { + $wfPath = Get-TemplateTestFixture 'template-providers-root' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*is not allowed*' + } + + It 'throws when accessing Workflow root' { + $wfPath = Get-TemplateTestFixture 'template-workflow-root' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*is not allowed*' + } + } + + Context 'Request.Input alias behavior' { + It 'uses Request.Input when Input property exists' { + $wfPath = Get-TemplateTestFixture 'template-input-exists' + + # Create a request with explicit Input property + $req = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + LifecycleEvent = 'Joiner' + CorrelationId = [guid]::NewGuid().ToString() + Input = @{ Name = 'FromInput' } + DesiredState = @{ Name = 'FromDesiredState' } + } + + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Value | Should -Be 'FromInput' + } + + It 'aliases Request.Input to Request.DesiredState when Input does not exist' { + $wfPath = Get-TemplateTestFixture 'template-input-alias' + + # Use standard request without explicit Input property + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Name = 'FromDesiredState' + } + + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Value | Should -Be 'FromDesiredState' + } + } + + Context 'Escaping' { + It 'handles escaped opening braces' { + $wfPath = Get-TemplateTestFixture 'template-escaped' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Value | Should -Be 'Literal {{ braces here' + } + + It 'handles escaped braces mixed with templates' { + $wfPath = Get-TemplateTestFixture 'template-escaped-mixed' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'TestName' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Value | Should -Be 'Literal {{ and template TestName' + } + } + + Context 'OnFailureSteps template resolution' { + It 'resolves templates in OnFailureSteps' { + $wfPath = Get-TemplateTestFixture 'template-onfailure' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Name = 'John Doe' + UserPrincipalName = 'jdoe@example.com' + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Value | Should -Be 'John Doe' + $plan.OnFailureSteps[0].With.ErrorMessage | Should -Be 'Failed for user jdoe@example.com' + } + } + + Context 'Allowed roots' { + It 'allows Request.LifecycleEvent' { + $wfPath = Get-TemplateTestFixture 'template-lifecycle-event' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Event | Should -Be 'Joiner' + } + + It 'allows Request.CorrelationId' { + $wfPath = Get-TemplateTestFixture 'template-correlation-id' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Id | Should -Be $req.CorrelationId + } + + It 'allows Request.Actor' { + $wfPath = Get-TemplateTestFixture 'template-actor' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } -Actor 'admin@example.com' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.ActorName | Should -Be 'admin@example.com' + } + } + + Context 'Type handling' { + It 'resolves numeric types to strings' { + $wfPath = Get-TemplateTestFixture 'template-numeric' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Value | Should -Be 'ID: 12345' + } + + It 'resolves boolean types to strings' { + $wfPath = Get-TemplateTestFixture 'template-boolean' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsEnabled = $true } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Value | Should -Be 'Enabled: True' + } + + It 'throws when resolving to a hashtable' { + $wfPath = Get-TemplateTestFixture 'template-hashtable' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + UserData = @{ Name = 'John'; Age = 30 } + } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*non-scalar value*' + } + + It 'throws when resolving to an array' { + $wfPath = Get-TemplateTestFixture 'template-array-value' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Tags = @('tag1', 'tag2') + } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*non-scalar value*' + } + } +} diff --git a/tests/fixtures/workflows/template-tests/template-actor.psd1 b/tests/fixtures/workflows/template-tests/template-actor.psd1 new file mode 100644 index 00000000..3287d620 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-actor.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Actor' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + ActorName = '{{Request.Actor}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-array-value.psd1 b/tests/fixtures/workflows/template-tests/template-array-value.psd1 new file mode 100644 index 00000000..312ebaed --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-array-value.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Array Value' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Tags}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-array.psd1 b/tests/fixtures/workflows/template-tests/template-array.psd1 new file mode 100644 index 00000000..1a26a4a0 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-array.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Template Test - Array' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Emails = @( + '{{Request.Input.PrimaryEmail}}' + '{{Request.Input.SecondaryEmail}}' + ) + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-boolean.psd1 b/tests/fixtures/workflows/template-tests/template-boolean.psd1 new file mode 100644 index 00000000..e97ff909 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-boolean.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Boolean' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Enabled: {{Request.Input.IsEnabled}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-correlation-id.psd1 b/tests/fixtures/workflows/template-tests/template-correlation-id.psd1 new file mode 100644 index 00000000..f29c455c --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-correlation-id.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - CorrelationId' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Id = '{{Request.CorrelationId}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 b/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 new file mode 100644 index 00000000..c7023998 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - DesiredState' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Department = '{{Request.DesiredState.Department}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 new file mode 100644 index 00000000..9c183fbf --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Escaped Mixed' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Literal \{{ and template {{Request.Input.Name}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-escaped.psd1 b/tests/fixtures/workflows/template-tests/template-escaped.psd1 new file mode 100644 index 00000000..ceca8fd5 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-escaped.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Escaped' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Literal \{{ braces here' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-hashtable.psd1 b/tests/fixtures/workflows/template-tests/template-hashtable.psd1 new file mode 100644 index 00000000..63f86c99 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-hashtable.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Hashtable' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.UserData}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-input-alias.psd1 b/tests/fixtures/workflows/template-tests/template-input-alias.psd1 new file mode 100644 index 00000000..e7cce502 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-input-alias.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Input Alias' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Name}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-input-exists.psd1 b/tests/fixtures/workflows/template-tests/template-input-exists.psd1 new file mode 100644 index 00000000..14f6c2ea --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-input-exists.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Input Exists' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Name}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-lifecycle-event.psd1 b/tests/fixtures/workflows/template-tests/template-lifecycle-event.psd1 new file mode 100644 index 00000000..6d98eeac --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-lifecycle-event.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - LifecycleEvent' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Event = '{{Request.LifecycleEvent}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-missing-path.psd1 b/tests/fixtures/workflows/template-tests/template-missing-path.psd1 new file mode 100644 index 00000000..fde9f978 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-missing-path.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Missing Path' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.NonExistent}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-multiple.psd1 b/tests/fixtures/workflows/template-tests/template-multiple.psd1 new file mode 100644 index 00000000..a517dbb3 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-multiple.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Multiple' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Message = 'User {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}}) is joining.' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 b/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 new file mode 100644 index 00000000..ebcd065d --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Template Test - Nested Hash' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + User = @{ + Name = '{{Request.Input.DisplayName}}' + Email = '{{Request.Input.Mail}}' + } + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-null-value.psd1 b/tests/fixtures/workflows/template-tests/template-null-value.psd1 new file mode 100644 index 00000000..0934e8c4 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-null-value.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Null Value' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.NullField}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-numeric.psd1 b/tests/fixtures/workflows/template-tests/template-numeric.psd1 new file mode 100644 index 00000000..8e764866 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-numeric.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Numeric' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'ID: {{Request.Input.UserId}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-onfailure.psd1 b/tests/fixtures/workflows/template-tests/template-onfailure.psd1 new file mode 100644 index 00000000..a19fe396 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-onfailure.psd1 @@ -0,0 +1,22 @@ +@{ + Name = 'Template Test - OnFailureSteps' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'MainStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Name}}' + } + } + ) + OnFailureSteps = @( + @{ + Name = 'FailureHandler' + Type = 'IdLE.Step.Test' + With = @{ + ErrorMessage = 'Failed for user {{Request.Input.UserPrincipalName}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 b/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 new file mode 100644 index 00000000..876498db --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Path Spaces' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.User Name}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-path-special.psd1 b/tests/fixtures/workflows/template-tests/template-path-special.psd1 new file mode 100644 index 00000000..a8998840 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-path-special.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Path Special' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.User@Name}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-plan-root.psd1 b/tests/fixtures/workflows/template-tests/template-plan-root.psd1 new file mode 100644 index 00000000..bca84a57 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-plan-root.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Plan Root' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Plan.WorkflowName}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-providers-root.psd1 b/tests/fixtures/workflows/template-tests/template-providers-root.psd1 new file mode 100644 index 00000000..91465500 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-providers-root.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Providers Root' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Providers.AuthSessionBroker}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-simple.psd1 b/tests/fixtures/workflows/template-tests/template-simple.psd1 new file mode 100644 index 00000000..bba47ec9 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-simple.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Simple' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + UserName = '{{Request.Input.UserPrincipalName}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 b/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 new file mode 100644 index 00000000..cbe8a751 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Unbalanced Close' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Request.Input.Name}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 b/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 new file mode 100644 index 00000000..3b5d51f0 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Unbalanced Open' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Name' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-workflow-root.psd1 b/tests/fixtures/workflows/template-tests/template-workflow-root.psd1 new file mode 100644 index 00000000..93c69933 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-workflow-root.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Workflow Root' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Workflow.Name}}' + } + } + ) +}