From d79a3ef88311c35ff4e6773b9a2d83ec29f81fd5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:35:47 +0100 Subject: [PATCH 1/2] provider: adding mock provider --- .../IdLE.Provider.Mock.psd1 | 22 +++ .../IdLE.Provider.Mock.psm1 | 17 ++ .../Public/New-IdleMockIdentityProvider.ps1 | 148 ++++++++++++++++++ tests/ModuleSurface.Tests.ps1 | 18 ++- .../IdentityProvider.Contract.ps1 | 86 ++++++++++ .../Providers/MockIdentityProvider.Tests.ps1 | 97 ++++++++++++ 6 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 create mode 100644 src/IdLE.Provider.Mock/IdLE.Provider.Mock.psm1 create mode 100644 src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 create mode 100644 tests/ProviderContracts/IdentityProvider.Contract.ps1 create mode 100644 tests/Providers/MockIdentityProvider.Tests.ps1 diff --git a/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 b/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 new file mode 100644 index 00000000..084e20e2 --- /dev/null +++ b/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 @@ -0,0 +1,22 @@ +@{ + RootModule = 'IdLE.Provider.Mock.psm1' + ModuleVersion = '0.2.0' + GUID = 'e661d3d6-1797-4cb1-b173-474982dbd653' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Mock provider implementation for IdLE (in-memory, deterministic).' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'New-IdleMockIdentityProvider' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('Identity Lifecycle Engine', 'IdLE', 'Provider', 'Mock') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + ContactEmail = '13959569+blindzero@users.noreply.github.com' + } + } +} diff --git a/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psm1 b/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psm1 new file mode 100644 index 00000000..feaa0fdc --- /dev/null +++ b/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psm1 @@ -0,0 +1,17 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize the list first to avoid enumeration issues if the session/module state changes during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } +} + +Export-ModuleMember -Function @( + 'New-IdleMockIdentityProvider' +) diff --git a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 new file mode 100644 index 00000000..58b65f38 --- /dev/null +++ b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 @@ -0,0 +1,148 @@ +function New-IdleMockIdentityProvider { + <# + .SYNOPSIS + Creates an in-memory identity provider for tests and demos. + + .DESCRIPTION + This provider is deterministic and has no external dependencies. + It is designed to be used in unit tests, contract tests, and example workflows. + + The provider keeps all state in a private in-memory store that is scoped to the + returned provider object instance (no global state). This makes tests predictable. + + .PARAMETER InitialStore + Optional initial store content. This is useful when a test wants to start with + pre-seeded identities. The input is shallow-copied to avoid unintended mutations + from the outside. + + .EXAMPLE + $provider = New-IdleMockIdentityProvider + $provider.EnsureAttribute('user1', 'Department', 'IT') | Out-Null + + .EXAMPLE + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ Department = 'HR' } + } + } + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.Provider.MockIdentityProvider) + #> + [CmdletBinding()] + param( + [Parameter()] + [hashtable] $InitialStore = @{} + ) + + # Shallow-copy the initial store to keep the provider instance deterministic. + # We avoid referencing external hashtables directly, so tests cannot mutate the provider by accident. + $store = @{} + foreach ($key in $InitialStore.Keys) { + $store[$key] = $InitialStore[$key] + } + + $provider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.MockIdentityProvider' + Name = 'MockIdentityProvider' + Store = $store + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + # Create missing identities on demand to keep tests and demos frictionless. + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + } + } + + # Ensure required sub-structures exist even when the identity was pre-seeded. + if (-not ($this.Store[$IdentityKey] -is [hashtable])) { + throw "Mock identity store entry '$IdentityKey' must be a hashtable." + } + if (-not $this.Store[$IdentityKey].ContainsKey('Attributes') -or $null -eq $this.Store[$IdentityKey].Attributes) { + $this.Store[$IdentityKey].Attributes = @{} + } + if (-not ($this.Store[$IdentityKey].Attributes -is [hashtable])) { + throw "Mock identity '$IdentityKey' property 'Attributes' must be a hashtable." + } + if (-not $this.Store[$IdentityKey].ContainsKey('Enabled')) { + $this.Store[$IdentityKey].Enabled = $true + } + + return $this.Store[$IdentityKey] + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter(Mandatory)] + [AllowNull()] + $Value + ) + + $identity = $this.GetIdentity($IdentityKey) + $attrs = $identity.Attributes + + $hasCurrent = $attrs.ContainsKey($Name) + $current = if ($hasCurrent) { $attrs[$Name] } else { $null } + + # Idempotent convergence: only change state if the desired value differs. + $changed = (-not $hasCurrent) -or ($current -ne $Value) + + if ($changed) { + $attrs[$Name] = $Value + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttribute' + IdentityKey = $IdentityKey + Name = $Name + PreviousValue = $current + Changed = [bool]$changed + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + $identity = $this.GetIdentity($IdentityKey) + + # Idempotent convergence: if already disabled, do nothing. + $changed = ($identity.Enabled -ne $false) + if ($changed) { + $identity.Enabled = $false + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'DisableIdentity' + IdentityKey = $IdentityKey + Changed = [bool]$changed + } + } -Force + + return $provider +} diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index b0a357fa..da69f1eb 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -3,6 +3,7 @@ BeforeAll { $idlePsd1 = Join-Path $repoRoot 'src\IdLE\IdLE.psd1' $corePsd1 = Join-Path $repoRoot 'src\IdLE.Core\IdLE.Core.psd1' $stepsPsd1 = Join-Path $repoRoot 'src\IdLE.Steps.Common\IdLE.Steps.Common.psd1' + $providerMockPsd1 = Join-Path $repoRoot 'src\IdLE.Provider.Mock\IdLE.Provider.Mock.psd1' } Describe 'Module manifests and public surface' { @@ -57,10 +58,23 @@ Describe 'Module manifests and public surface' { ($idle.NestedModules | Where-Object Name -eq 'IdLE.Core') | Should -Not -BeNullOrEmpty } - It 'Steps module exports the intended step function' { + It 'Steps module exports the intended step functions' { Remove-Module IdLE.Steps.Common -Force -ErrorAction SilentlyContinue Import-Module $stepsPsd1 -Force -ErrorAction Stop - (Get-Command -Module IdLE.Steps.Common).Name | Should -Contain 'Invoke-IdleStepEmitEvent' + $exported = (Get-Command -Module IdLE.Steps.Common).Name + $exported | Should -Contain 'Invoke-IdleStepEmitEvent' + $exported | Should -Contain 'Invoke-IdleStepEnsureAttribute' + } + + It 'IdLE.Provider.Mock manifest is valid' { + { Test-ModuleManifest -Path $providerMockPsd1 -ErrorAction Stop } | Should -Not -Throw + } + + It 'Mock provider module exports the intended provider function' { + Remove-Module IdLE.Provider.Mock -Force -ErrorAction SilentlyContinue + Import-Module $providerMockPsd1 -Force -ErrorAction Stop + + (Get-Command -Module IdLE.Provider.Mock).Name | Should -Contain 'New-IdleMockIdentityProvider' } } diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 new file mode 100644 index 00000000..10fae951 --- /dev/null +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -0,0 +1,86 @@ +Set-StrictMode -Version Latest + +function Invoke-IdleIdentityProviderContractTests { + <# + .SYNOPSIS + Defines provider contract tests for an identity provider implementation. + + .DESCRIPTION + This file intentionally contains no top-level Describe/It blocks. + It provides a function that must be invoked from within a Describe block. + + IMPORTANT (Pester 5): + - The contract must be registered during discovery (Describe/Context scope). + - The provider instance must be created during runtime (BeforeAll), not during discovery. + + Therefore the contract takes a provider factory scriptblock and creates the provider + inside its own BeforeAll. + + .PARAMETER NewProvider + ScriptBlock that creates a new provider instance. + + .PARAMETER ProviderLabel + Optional label for better test output. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [scriptblock] $NewProvider, + + [Parameter()] + [string] $ProviderLabel = 'Identity provider' + ) + + Context "$ProviderLabel contract" { + + BeforeAll { + $script:Provider = & $NewProvider + } + + It 'Exposes required methods' { + $script:Provider.PSObject.Methods.Name | Should -Contain 'GetIdentity' + $script:Provider.PSObject.Methods.Name | Should -Contain 'EnsureAttribute' + $script:Provider.PSObject.Methods.Name | Should -Contain 'DisableIdentity' + } + + It 'GetIdentity returns a hashtable with required keys' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + $identity = $script:Provider.GetIdentity($id) + + $identity | Should -BeOfType [hashtable] + $identity.Keys | Should -Contain 'IdentityKey' + $identity.Keys | Should -Contain 'Enabled' + $identity.Keys | Should -Contain 'Attributes' + + $identity.IdentityKey | Should -Be $id + $identity.Attributes | Should -BeOfType [hashtable] + } + + It 'EnsureAttribute is idempotent and returns a Changed flag' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + $r1 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') + $r2 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') + + $r1.PSObject.Properties.Name | Should -Contain 'Changed' + $r1.Changed | Should -BeTrue + + $r2.PSObject.Properties.Name | Should -Contain 'Changed' + $r2.Changed | Should -BeFalse + } + + It 'DisableIdentity is idempotent and returns a Changed flag' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + $r1 = $script:Provider.DisableIdentity($id) + $r2 = $script:Provider.DisableIdentity($id) + + $r1.PSObject.Properties.Name | Should -Contain 'Changed' + $r1.Changed | Should -BeTrue + + $r2.PSObject.Properties.Name | Should -Contain 'Changed' + $r2.Changed | Should -BeFalse + } + } +} diff --git a/tests/Providers/MockIdentityProvider.Tests.ps1 b/tests/Providers/MockIdentityProvider.Tests.ps1 new file mode 100644 index 00000000..675bb23c --- /dev/null +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -0,0 +1,97 @@ +Set-StrictMode -Version Latest + +Describe 'IdLE.Provider.Mock - Mock identity provider' { + + BeforeAll { + # Use a relative import from the current working directory (repo root) used by the test runner. + # This keeps the test simple and avoids repo-root discovery issues in Pester discovery/runtime. + $modulePath = Join-Path -Path (Get-Location).Path -ChildPath 'src\IdLE.Provider.Mock\IdLE.Provider.Mock.psd1' + if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + throw "Provider module manifest not found at: $modulePath" + } + + Import-Module -Name $modulePath -Force -ErrorAction Stop + } + + It 'Creates a provider instance' { + $provider = New-IdleMockIdentityProvider + + $provider | Should -Not -BeNullOrEmpty + $provider.Name | Should -Be 'MockIdentityProvider' + } + + Context 'Provider contract (inline)' { + + BeforeAll { + $script:Provider = New-IdleMockIdentityProvider + } + + It 'Exposes required methods' { + $script:Provider.PSObject.Methods.Name | Should -Contain 'GetIdentity' + $script:Provider.PSObject.Methods.Name | Should -Contain 'EnsureAttribute' + $script:Provider.PSObject.Methods.Name | Should -Contain 'DisableIdentity' + } + + It 'GetIdentity returns a hashtable with required keys' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + $identity = $script:Provider.GetIdentity($id) + + $identity | Should -BeOfType [hashtable] + $identity.Keys | Should -Contain 'IdentityKey' + $identity.Keys | Should -Contain 'Enabled' + $identity.Keys | Should -Contain 'Attributes' + + $identity.IdentityKey | Should -Be $id + $identity.Attributes | Should -BeOfType [hashtable] + } + + It 'EnsureAttribute is idempotent' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + $r1 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') + $r2 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') + + $r1.Changed | Should -BeTrue + $r2.Changed | Should -BeFalse + } + + It 'DisableIdentity is idempotent' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + $r1 = $script:Provider.DisableIdentity($id) + $r2 = $script:Provider.DisableIdentity($id) + + $r1.Changed | Should -BeTrue + $r2.Changed | Should -BeFalse + } + } + + It 'Keeps state scoped to the provider instance' { + $p1 = New-IdleMockIdentityProvider + $p2 = New-IdleMockIdentityProvider + + $null = $p1.EnsureAttribute('user1', 'Department', 'IT') + + $i1 = $p1.GetIdentity('user1') + $i2 = $p2.GetIdentity('user1') + + $i1.Attributes['Department'] | Should -Be 'IT' + $i2.Attributes.ContainsKey('Department') | Should -BeFalse + } + + It 'Supports pre-seeded identities via InitialStore' { + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ Department = 'HR' } + } + } + + $identity = $provider.GetIdentity('user1') + $identity.Attributes['Department'] | Should -Be 'HR' + + $r = $provider.EnsureAttribute('user1', 'Department', 'HR') + $r.Changed | Should -BeFalse + } +} From 387a619d8be8e3b6278532c4aa1c2ae89b5d0cbf Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:36:13 +0100 Subject: [PATCH 2/2] extend demo, fix registry and steps --- examples/run-demo.ps1 | 82 +++++++------------ .../joiner-minimal-ensureattribute.psd1 | 33 ++++++++ .../Private/Get-IdleStepRegistry.ps1 | 63 ++++++++------ src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 3 +- src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 13 ++- .../Public/Invoke-IdleStepEmitEvent.ps1 | 64 ++++++++++++--- .../Public/Invoke-IdleStepEnsureAttribute.ps1 | 73 +++++++++++++++++ 7 files changed, 237 insertions(+), 94 deletions(-) create mode 100644 examples/workflows/joiner-minimal-ensureattribute.psd1 create mode 100644 src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 diff --git a/examples/run-demo.ps1 b/examples/run-demo.ps1 index 3c07e3fa..bb10a822 100644 --- a/examples/run-demo.ps1 +++ b/examples/run-demo.ps1 @@ -38,11 +38,20 @@ function Format-EventRow { $time = ([DateTime]$Event.TimestampUtc).ToLocalTime().ToString('HH:mm:ss.fff') $step = if ([string]::IsNullOrWhiteSpace($Event.StepName)) { '-' } else { [string]$Event.StepName } + $msg = [string]$Event.Message + + # IMPORTANT: Show error details if the engine attached them. + if ($Event.PSObject.Properties.Name -contains 'Data' -and $Event.Data -is [hashtable]) { + if ($Event.Data.ContainsKey('Error') -and -not [string]::IsNullOrWhiteSpace([string]$Event.Data.Error)) { + $msg = "$msg | ERROR: $([string]$Event.Data.Error)" + } + } + [pscustomobject]@{ Time = $time Type = "$icon $($Event.Type)" Step = $step - Message = $Event.Message + Message = $msg } } @@ -66,63 +75,24 @@ function Write-ResultSummary { Write-Host ("Events: " + ($counts -join ', ')) } -Import-Module (Join-Path $PSScriptRoot '..\src\IdLE\IdLE.psd1') -Force -Import-Module (Join-Path $PSScriptRoot '..\src\IdLE.Steps.Common\IdLE.Steps.Common.psd1') -Force +# Import modules from the repo (path-based import, no global installation required). +Import-Module (Join-Path $PSScriptRoot '..\src\IdLE\IdLE.psd1') -Force -ErrorAction Stop +Import-Module (Join-Path $PSScriptRoot '..\src\IdLE.Steps.Common\IdLE.Steps.Common.psd1') -Force -ErrorAction Stop +Import-Module (Join-Path $PSScriptRoot '..\src\IdLE.Provider.Mock\IdLE.Provider.Mock.psd1') -Force -ErrorAction Stop -$workflowPath = Join-Path $PSScriptRoot 'workflows\joiner-with-when.psd1' +# Select demo workflow. +$workflowPath = Join-Path -Path $PSScriptRoot -ChildPath 'workflows\joiner-minimal-ensureattribute.psd1' -$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'example-user' +# Validate workflow early for clear errors. +Test-IdleWorkflow -WorkflowPath $workflowPath | Out-Null +# Create request and plan. +$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'example-user' $plan = New-IdlePlan -WorkflowPath $workflowPath -Request $request -# Host-provided step registry: -# The handler can be a scriptblock (ideal for tests/examples) or a function name. -$emitHandler = { - param($Context, $Step) - - # Support both hashtable/dictionary and PSCustomObject step shapes. - $stepName = $null - $stepType = $null - $with = $null - - if ($Step -is [System.Collections.IDictionary]) { - $stepName = if ($Step.Contains('Name')) { [string]$Step['Name'] } else { $null } - $stepType = if ($Step.Contains('Type')) { [string]$Step['Type'] } else { $null } - $with = if ($Step.Contains('With')) { $Step['With'] } else { $null } - } - else { - $stepName = if ($Step.PSObject.Properties['Name']) { [string]$Step.Name } else { $null } - $stepType = if ($Step.PSObject.Properties['Type']) { [string]$Step.Type } else { $null } - $with = if ($Step.PSObject.Properties['With']) { $Step.With } else { $null } - } - - $msg = $null - if ($with -is [System.Collections.IDictionary] -and $with.Contains('Message')) { - $msg = [string]$with['Message'] - } - elseif ($null -ne $with -and $with.PSObject.Properties['Message']) { - $msg = [string]$with.Message - } - - if ([string]::IsNullOrWhiteSpace($msg)) { - $msg = 'EmitEvent executed.' - } - - & $Context.WriteEvent 'Custom' $msg $stepName @{ StepType = $stepType } - - [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'Completed' - Error = $null - } -} - +# Host-provided providers. $providers = @{ - StepRegistry = @{ - 'IdLE.Step.EmitEvent' = $emitHandler - } + Identity = New-IdleMockIdentityProvider } $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -130,6 +100,14 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers Write-DemoHeader "IdLE Demo – Plan Execution" Write-ResultSummary -Result $result +Write-Host "" +Write-DemoHeader "Step Results" +$result.Steps | + Select-Object Name, Type, Status, + @{ Name = 'Changed'; Expression = { if ($_.PSObject.Properties.Name -contains 'Changed') { $_.Changed } else { $null } } }, + Error | + Format-Table -AutoSize + Write-Host "" Write-DemoHeader "Event Stream" $result.Events | diff --git a/examples/workflows/joiner-minimal-ensureattribute.psd1 b/examples/workflows/joiner-minimal-ensureattribute.psd1 new file mode 100644 index 00000000..38e56638 --- /dev/null +++ b/examples/workflows/joiner-minimal-ensureattribute.psd1 @@ -0,0 +1,33 @@ +@{ + Name = 'Joiner - Minimal (EnsureAttribute)' + LifecycleEvent = 'Joiner' + + Steps = @( + @{ + Name = 'Emit start' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Joiner workflow started (minimalpack).' + } + } + + @{ + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + Provider = 'Identity' + IdentityKey = 'user1' + Name = 'Department' + Value = 'IT' + } + } + + @{ + Name = 'Emit done' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Joiner workflow completed (minimalpack).' + } + } + ) +} diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index fc7dda0c..d538d430 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -6,41 +6,49 @@ function Get-IdleStepRegistry { [object] $Providers ) - # Registry maps workflow Step.Type -> handler. + # Registry maps workflow Step.Type -> handler # Handler can be: - # - string : PowerShell function name - # - scriptblock : executable handler (ideal for tests / hosts) + # - [string] : PowerShell function name + # - [scriptblock] : executable handler (useful for tests / hosts) $registry = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - if ($null -eq $Providers) { - return $registry - } + # 1) Copy host-provided StepRegistry (optional) + # We support two shapes for compatibility: + # - StepRegistry['Type'] = 'FunctionName' | { scriptblock } + # - StepRegistry['Type'] = @{ Handler = 'FunctionName' } (legacy/demo style) + if ($null -ne $Providers) { + + $source = $null - # 1) Providers as hashtable / dictionary (most common in tests) - if ($Providers -is [hashtable] -or $Providers -is [System.Collections.IDictionary]) { - if ($Providers.Contains('StepRegistry') -and $Providers['StepRegistry'] -is [hashtable]) { - # Clone to avoid mutating host-provided hashtable during execution. - $source = $Providers['StepRegistry'] - foreach ($k in $source.Keys) { - $registry[[string]$k] = $source[$k] + if ($Providers -is [System.Collections.IDictionary]) { + if ($Providers.Contains('StepRegistry')) { + $source = $Providers['StepRegistry'] + } + } + else { + $prop = $Providers.PSObject.Properties['StepRegistry'] + if ($null -ne $prop) { + $source = $prop.Value } } - return $registry - } + if ($null -ne $source -and ($source -is [System.Collections.IDictionary])) { + foreach ($k in @($source.Keys)) { + + $v = $source[$k] - # 2) Providers as object with property StepRegistry (host objects) - # StrictMode-safe: do NOT access $Providers.StepRegistry unless the property exists. - $prop = $Providers.PSObject.Properties['StepRegistry'] - if ($null -ne $prop -and $prop.Value -is [hashtable]) { - $source = $prop.Value - foreach ($k in $source.Keys) { - $registry[[string]$k] = $source[$k] + # Allow legacy shape: @{ Handler = 'Invoke-...' } + if ($v -is [hashtable] -and $v.ContainsKey('Handler')) { + $v = $v['Handler'] + } + + $registry[[string]$k] = $v + } } } - # Add built-in defaults only if the step implementation is actually available. - # This keeps IdLE's public surface minimal: steps are optional modules. + # 2) Built-in defaults (only if commands are available) + # Do not overwrite host-provided entries. if (-not $registry.ContainsKey('IdLE.Step.EmitEvent')) { $cmd = Get-Command -Name 'Invoke-IdleStepEmitEvent' -ErrorAction SilentlyContinue if ($null -ne $cmd) { @@ -48,5 +56,12 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.EnsureAttribute')) { + $cmd = Get-Command -Name 'Invoke-IdleStepEnsureAttribute' -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + $registry['IdLE.Step.EnsureAttribute'] = $cmd.Name + } + } + return $registry } diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 86736da7..abffbc99 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -8,7 +8,8 @@ PowerShellVersion = '7.0' FunctionsToExport = @( - 'Invoke-IdleStepEmitEvent' + 'Invoke-IdleStepEmitEvent', + 'Invoke-IdleStepEnsureAttribute' ) PrivateData = @{ diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 2f8fdaa9..45b8a777 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -3,11 +3,16 @@ Set-StrictMode -Version Latest $PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' if (Test-Path -Path $PublicPath) { - Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | - Sort-Object -Property FullName | - ForEach-Object { . $_.FullName } + + # Materialize first to avoid enumeration issues during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } } Export-ModuleMember -Function @( - 'Invoke-IdleStepEmitEvent' + 'Invoke-IdleStepEmitEvent', + 'Invoke-IdleStepEnsureAttribute' ) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 index 4bbde3fa..b1b8f5ce 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 @@ -4,11 +4,14 @@ function Invoke-IdleStepEmitEvent { Emits a custom event (demo step). .DESCRIPTION - This step does not change any external state. It simply emits a custom event message. - It is used as a reference implementation for the step plugin contract. + This step does not change external state. It emits a custom event message. + If the execution context provides an EventSink, the step will write to it. + If no EventSink is available, the step will still succeed (no-op). + + This keeps the step host-agnostic and safe to use in demos/tests. .PARAMETER Context - Execution context (Request, Plan, Providers, EventSink, CorrelationId, Actor). + Execution context created by IdLE.Core. .PARAMETER Step The plan step object (Name, Type, With, When). @@ -27,20 +30,55 @@ function Invoke-IdleStepEmitEvent { [object] $Step ) - $message = $null - if ($Step.PSObject.Properties.Name -contains 'With' -and $null -ne $Step.With) { - if ($Step.With -is [hashtable] -and $Step.With.ContainsKey('Message')) { - $message = [string]$Step.With.Message - } + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + $with = @{} } - if ([string]::IsNullOrWhiteSpace($message)) { - $message = "EmitEvent step executed." + $message = if ($with.ContainsKey('Message') -and -not [string]::IsNullOrWhiteSpace([string]$with.Message)) { + [string]$with.Message + } + else { + "Custom event emitted by step '$([string]$Step.Name)'." } - # Emit a custom event through the engine event sink. - if ($Context.PSObject.Properties.Name -contains 'WriteEvent') { - $Context.WriteEvent('Custom', $message, $Step.Name, @{ StepType = $Step.Type }) + # EventSink is optional. If it exists, it should accept an event object. + # We deliberately do not assume a specific method name on the context itself. + $sinkProp = $Context.PSObject.Properties['EventSink'] + if ($null -ne $sinkProp -and $null -ne $sinkProp.Value) { + + $eventObject = [pscustomobject]@{ + PSTypeName = 'IdLE.Event' + TimestampUtc = [DateTime]::UtcNow + Type = 'Custom' + StepName = [string]$Step.Name + Message = $message + Data = @{ + StepType = [string]$Step.Type + } + } + + # Support common sink shapes: + # - ScriptBlock: & $EventSink $event + # - Object with method 'Add' or 'Write' or 'Emit' + $sink = $sinkProp.Value + + if ($sink -is [scriptblock]) { + & $sink $eventObject + } + elseif ($sink.PSObject.Methods.Name -contains 'Add') { + $sink.Add($eventObject) + } + elseif ($sink.PSObject.Methods.Name -contains 'Write') { + $sink.Write($eventObject) + } + elseif ($sink.PSObject.Methods.Name -contains 'Emit') { + $sink.Emit($eventObject) + } + else { + # Sink is present but has an unknown shape -> do not fail the step. + # Host can decide how strictly it wants to enforce sink contract. + } } return [pscustomobject]@{ diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 new file mode 100644 index 00000000..31f6d4ec --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 @@ -0,0 +1,73 @@ +function Invoke-IdleStepEnsureAttribute { + <# + .SYNOPSIS + Ensures that an identity attribute matches the desired value. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[]. The provider must implement an EnsureAttribute + method with the signature (IdentityKey, Name, Value) and return an object that + contains a boolean property 'Changed'. + + The step is idempotent by design: it converges state to the desired value. + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "EnsureAttribute requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'Name', 'Value')) { + if (-not $with.ContainsKey($key)) { + throw "EnsureAttribute requires With.$key." + } + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + $provider = $Context.Providers[$providerAlias] + $result = $provider.EnsureAttribute([string]$with.IdentityKey, [string]$with.Name, $with.Value) + + $changed = $false + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } +}