From 47cc3ab77325f599bdde320f173cb0bef38641db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:08:01 +0000 Subject: [PATCH 1/8] Initial plan From 4365a5440fb623ea2f6e61b10a56a8f0298c81d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:15:11 +0000 Subject: [PATCH 2/8] Core: Add template substitution resolver functions and tests Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Resolve-IdleTemplateString.ps1 | 204 +++++ .../Private/Resolve-IdleWorkflowTemplates.ps1 | 91 ++ src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 3 + tests/Resolve-IdleWorkflowTemplates.Tests.ps1 | 837 ++++++++++++++++++ 4 files changed, 1135 insertions(+) create mode 100644 src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 create mode 100644 src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 create mode 100644 tests/Resolve-IdleWorkflowTemplates.Tests.ps1 diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 new file mode 100644 index 00000000..e737bffe --- /dev/null +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -0,0 +1,204 @@ +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) + $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 ($null -ne $props -and @($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..7b381e78 --- /dev/null +++ b/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -0,0 +1,837 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Template Substitution' { + Context 'Single placeholder substitution' { + It 'resolves a simple Request.Input placeholder' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'template-simple.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Simple' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + UserName = '{{Request.Input.UserPrincipalName}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-desiredstate.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - DesiredState' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Department = '{{Request.DesiredState.Department}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-multiple.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Multiple' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Message = 'User {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}}) is joining.' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-nested-hash.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Nested Hash' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + User = @{ + Name = '{{Request.Input.DisplayName}}' + Email = '{{Request.Input.Mail}}' + } + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-array.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Array' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Emails = @( + '{{Request.Input.PrimaryEmail}}' + '{{Request.Input.SecondaryEmail}}' + ) + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-unbalanced-open.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Unbalanced Open' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Name' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-unbalanced-close.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Unbalanced Close' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Request.Input.Name}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-path-spaces.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Path Spaces' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.User Name}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-path-special.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Path Special' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.User@Name}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-missing-path.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Missing Path' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.NonExistent}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-null-value.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Null Value' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.NullField}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-plan-root.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Plan Root' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Plan.WorkflowName}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-providers-root.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Providers Root' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Providers.AuthSessionBroker}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-workflow-root.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Workflow Root' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Workflow.Name}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-input-exists.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Input Exists' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Name}}' + } + } + ) +} +'@ + + # 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 = Join-Path -Path $TestDrive -ChildPath 'template-input-alias.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Input Alias' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Name}}' + } + } + ) +} +'@ + + # 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 = Join-Path -Path $TestDrive -ChildPath 'template-escaped.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Escaped' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Literal \{{ braces here' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-escaped-mixed.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Escaped Mixed' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Literal \{{ and template {{Request.Input.Name}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + 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}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-lifecycle-event.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - LifecycleEvent' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Event = '{{Request.LifecycleEvent}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-correlation-id.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - CorrelationId' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Id = '{{Request.CorrelationId}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-actor.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Actor' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + ActorName = '{{Request.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 = Join-Path -Path $TestDrive -ChildPath 'template-numeric.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Numeric' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'ID: {{Request.Input.UserId}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-boolean.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Boolean' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = 'Enabled: {{Request.Input.IsEnabled}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-hashtable.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Hashtable' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.UserData}}' + } + } + ) +} +'@ + + $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 = Join-Path -Path $TestDrive -ChildPath 'template-array-value.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Template Test - Array Value' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Input.Tags}}' + } + } + ) +} +'@ + + $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*' + } + } +} From 193b5bac40418d77cdbf312c92f564949356afb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:17:02 +0000 Subject: [PATCH 3/8] Docs: Add template substitution section to workflows.md Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/usage/workflows.md | 75 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index 6cbd6627..2a07686b 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -106,6 +106,79 @@ 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. + +**Syntax:** + +```powershell +@{ + Name = 'CreateUser' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + UserPrincipalName = '{{Request.Input.UserPrincipalName}}' + DisplayName = '{{Request.Input.DisplayName}}' + Message = 'Creating user {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}})' + } +} +``` + +**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 +187,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) From c125a6c6aaa46416e77e041b1bd81fc49ea1bde9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:18:13 +0000 Subject: [PATCH 4/8] Code review: Simplify null checks per review feedback Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 | 4 ++-- src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index e737bffe..00eba01c 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -119,7 +119,7 @@ function Resolve-IdleTemplateString { if ($path.StartsWith('Request.Input.')) { # Check if Request has an Input property $hasInputProperty = $false - if ($Request.PSObject.Properties['Input'] -ne $null) { + if ($Request.PSObject.Properties['Input']) { $hasInputProperty = $true } @@ -131,7 +131,7 @@ function Resolve-IdleTemplateString { elseif ($path -eq 'Request.Input') { # Check if Request has an Input property $hasInputProperty = $false - if ($Request.PSObject.Properties['Input'] -ne $null) { + if ($Request.PSObject.Properties['Input']) { $hasInputProperty = $true } diff --git a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 index ba61c5bc..cbf391bd 100644 --- a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 @@ -78,7 +78,7 @@ function Resolve-IdleWorkflowTemplates { # PSCustomObject: recurse on properties $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) - if ($null -ne $props -and @($props).Count -gt 0) { + if (@($props).Count -gt 0) { $resolved = [ordered]@{} foreach ($prop in $props) { $resolved[$prop.Name] = Resolve-IdleWorkflowTemplates -Value $prop.Value -Request $Request -StepName $StepName From 3b0948ebd8cdc2337188883ae2d57ef513689607 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:25:50 +0000 Subject: [PATCH 5/8] Docs: Fix example and add request value explanation Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/usage/workflows.md | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index 2a07686b..fb91534e 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -110,20 +110,50 @@ If the condition is not met, the step is marked as `Skipped` and a skip event is 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. -**Syntax:** +**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 = @{ - UserPrincipalName = '{{Request.Input.UserPrincipalName}}' - DisplayName = '{{Request.Input.DisplayName}}' - Message = 'Creating user {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}})' + 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 From ce5bcf6063e97e2adfae138e1aaef60f0ae1167d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:28:08 +0000 Subject: [PATCH 6/8] Tests: Move workflow definitions to fixture files Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/Resolve-IdleWorkflowTemplates.Tests.ps1 | 23 ++++++------------- .../template-tests/template-actor.psd1 | 13 +++++++++++ .../template-tests/template-array-value.psd1 | 13 +++++++++++ .../template-tests/template-array.psd1 | 16 +++++++++++++ .../template-tests/template-boolean.psd1 | 13 +++++++++++ .../template-correlation-id.psd1 | 13 +++++++++++ .../template-tests/template-desiredstate.psd1 | 13 +++++++++++ .../template-escaped-mixed.psd1 | 13 +++++++++++ .../template-tests/template-escaped.psd1 | 13 +++++++++++ .../template-tests/template-hashtable.psd1 | 13 +++++++++++ .../template-tests/template-input-alias.psd1 | 13 +++++++++++ .../template-tests/template-input-exists.psd1 | 13 +++++++++++ .../template-lifecycle-event.psd1 | 13 +++++++++++ .../template-tests/template-missing-path.psd1 | 13 +++++++++++ .../template-tests/template-multiple.psd1 | 13 +++++++++++ .../template-tests/template-nested-hash.psd1 | 16 +++++++++++++ .../template-tests/template-null-value.psd1 | 13 +++++++++++ .../template-tests/template-numeric.psd1 | 13 +++++++++++ .../template-tests/template-onfailure.psd1 | 22 ++++++++++++++++++ .../template-tests/template-path-spaces.psd1 | 13 +++++++++++ .../template-tests/template-path-special.psd1 | 13 +++++++++++ .../template-tests/template-plan-root.psd1 | 13 +++++++++++ .../template-providers-root.psd1 | 13 +++++++++++ .../template-tests/template-simple.psd1 | 13 +++++++++++ .../template-unbalanced-close.psd1 | 13 +++++++++++ .../template-unbalanced-open.psd1 | 13 +++++++++++ .../template-workflow-root.psd1 | 13 +++++++++++ 27 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/workflows/template-tests/template-actor.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-array-value.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-array.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-boolean.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-correlation-id.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-desiredstate.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-escaped.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-hashtable.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-input-alias.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-input-exists.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-lifecycle-event.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-missing-path.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-multiple.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-nested-hash.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-null-value.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-numeric.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-onfailure.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-path-spaces.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-path-special.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-plan-root.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-providers-root.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-simple.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-workflow-root.psd1 diff --git a/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 index 7b381e78..5e5944aa 100644 --- a/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -1,27 +1,18 @@ 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 = Join-Path -Path $TestDrive -ChildPath 'template-simple.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Template Test - Simple' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'TestStep' - Type = 'IdLE.Step.Test' - With = @{ - UserName = '{{Request.Input.UserPrincipalName}}' - } - } - ) -} -'@ + $wfPath = Get-TemplateTestFixture 'template-simple' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserPrincipalName = 'jdoe@example.com' 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}}' + } + } + ) +} From 80c8ab38396bde64b5653eaaf18c671737bccd9f Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:48:37 +0100 Subject: [PATCH 7/8] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 00eba01c..557ab6d8 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -72,7 +72,10 @@ function Resolve-IdleTemplateString { # Check for unbalanced braces (typo safety) $openCount = ([regex]::Matches($stringValue, '(? Date: Sat, 24 Jan 2026 22:54:05 +0000 Subject: [PATCH 8/8] Fix template test fixtures and resolver issues - Replace all Set-Content in tests with fixture file references - Fix brace balance check to handle escaped sequences correctly - Remove duplication in Input property existence check - Reject empty arrays consistently with non-empty arrays - Verify all existing workflow examples still work Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Resolve-IdleTemplateString.ps1 | 36 +- tests/Resolve-IdleWorkflowTemplates.Tests.ps1 | 440 +----------------- 2 files changed, 42 insertions(+), 434 deletions(-) diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 557ab6d8..5e03717f 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -71,14 +71,19 @@ function Resolve-IdleTemplateString { } # Check for unbalanced braces (typo safety) + # Count non-escaped opening braces $openCount = ([regex]::Matches($stringValue, '(?