diff --git a/docs/about/concepts.md b/docs/about/concepts.md index 22d7eec7..61c38e0c 100644 --- a/docs/about/concepts.md +++ b/docs/about/concepts.md @@ -69,7 +69,7 @@ IdLE consists of the following elements and components: A **LifecycleRequest** represents the business intent (for example: Joiner, Mover, Leaver). It is the input to planning. ```powershell -$Request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ +$Request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ key = 'first.last' } -DesiredState @{ Firstname = 'First' @@ -217,3 +217,4 @@ Hosts may optionally provide an external sink to stream events live: - `Invoke-IdlePlan -EventSink ` - The sink must implement `WriteEvent(event)` - ScriptBlock sinks are rejected (secure default) + diff --git a/docs/reference/cmdlets.md b/docs/reference/cmdlets.md index 54c2435c..c2fe08ba 100644 --- a/docs/reference/cmdlets.md +++ b/docs/reference/cmdlets.md @@ -10,6 +10,6 @@ This page links the generated per-cmdlet reference pages and includes their syno | [Export-IdlePlan](cmdlets/Export-IdlePlan.md) | Exports an IdLE LifecyclePlan as a canonical JSON artifact. | | [Invoke-IdlePlan](cmdlets/Invoke-IdlePlan.md) | Executes an IdLE plan. | | [New-IdleAuthSession](cmdlets/New-IdleAuthSession.md) | Creates a simple AuthSessionBroker for use with IdLE providers. | -| [New-IdleLifecycleRequest](cmdlets/New-IdleLifecycleRequest.md) | Creates a lifecycle request object. | | [New-IdlePlan](cmdlets/New-IdlePlan.md) | Creates a deterministic plan from a lifecycle request and a workflow definition. | +| [New-IdleRequest](cmdlets/New-IdleRequest.md) | Creates a lifecycle request object. | | [Test-IdleWorkflow](cmdlets/Test-IdleWorkflow.md) | Validates an IdLE workflow definition file. | diff --git a/docs/reference/cmdlets/New-IdlePlan.md b/docs/reference/cmdlets/New-IdlePlan.md index b8fe3568..1f202724 100644 --- a/docs/reference/cmdlets/New-IdlePlan.md +++ b/docs/reference/cmdlets/New-IdlePlan.md @@ -46,7 +46,7 @@ Accept wildcard characters: False ``` ### -Request -The lifecycle request object created by New-IdleLifecycleRequest. +The lifecycle request object created by New-IdleRequest. ```yaml Type: Object diff --git a/docs/reference/cmdlets/New-IdleLifecycleRequest.md b/docs/reference/cmdlets/New-IdleRequest.md similarity index 91% rename from docs/reference/cmdlets/New-IdleLifecycleRequest.md rename to docs/reference/cmdlets/New-IdleRequest.md index 50c071a9..f4aa9015 100644 --- a/docs/reference/cmdlets/New-IdleLifecycleRequest.md +++ b/docs/reference/cmdlets/New-IdleRequest.md @@ -5,7 +5,7 @@ online version: schema: 2.0.0 --- -# New-IdleLifecycleRequest +# New-IdleRequest ## SYNOPSIS Creates a lifecycle request object. @@ -13,7 +13,7 @@ Creates a lifecycle request object. ## SYNTAX ``` -New-IdleLifecycleRequest [-LifecycleEvent] <String> [[-CorrelationId] <String>] [[-Actor] <String>] +New-IdleRequest [-LifecycleEvent] <String> [[-CorrelationId] <String>] [[-Actor] <String>] [[-IdentityKeys] <Hashtable>] [[-DesiredState] <Hashtable>] [[-Changes] <Hashtable>] [-ProgressAction <ActionPreference>] [<CommonParameters>] ``` @@ -30,7 +30,7 @@ Changes is optional and stays $null when omitted. ### EXAMPLE 1 ``` -New-IdleLifecycleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } +New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } ``` ## PARAMETERS diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 5f78f26f..9a3a0c0d 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -229,7 +229,7 @@ if (-not $mgr) { } # 2. Build request with manager data in DesiredState -$req = New-IdleLifecycleRequest ` +$req = New-IdleRequest ` -LifecycleEvent 'Leaver' ` -Actor $env:USERNAME ` -Input @{ UserPrincipalName = 'max.power@contoso.com' } ` @@ -283,7 +283,7 @@ if (-not $mgr) { } } -$req = New-IdleLifecycleRequest ` +$req = New-IdleRequest ` -LifecycleEvent 'Leaver' ` -Actor $env:USERNAME ` -Input @{ UserPrincipalName = 'max.power@contoso.com' } ` @@ -375,7 +375,7 @@ $internalMessageTemplate = Get-Content -Path './templates/oof-internal.html' -Ra $externalMessageTemplate = Get-Content -Path './templates/oof-external.html' -Raw -Encoding UTF8 # Build request with template content -$req = New-IdleLifecycleRequest ` +$req = New-IdleRequest ` -LifecycleEvent 'Leaver' ` -Actor $env:USERNAME ` -Input @{ UserPrincipalName = 'user@contoso.com' } ` @@ -433,3 +433,4 @@ This approach keeps workflow definitions clean, allows template reuse, and maint - **Unit tests:** `tests/Providers/ExchangeOnlineProvider.Tests.ps1` - **Contract tests:** Provider contract tests validate implementation compliance - **Known CI constraints:** Tests use mock cmdlet layer; no live Exchange Online calls in CI + diff --git a/docs/use/plan-export.md b/docs/use/plan-export.md index 6f8ace40..5e5d5986 100644 --- a/docs/use/plan-export.md +++ b/docs/use/plan-export.md @@ -46,7 +46,7 @@ For the exact format and normative rules, see [Plan Export Specification](../ref ```powershell # Example only. Adjust parameters to your environment. -$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ EmployeeId = 'jdoe' } +$request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ EmployeeId = 'jdoe' } $providers = @{ Identity = New-IdleMockIdentityProvider } $plan = New-IdlePlan -WorkflowPath './workflows/joiner.psd1' -Request $request -Providers $providers Export-IdlePlan -Plan $plan -Path './artifacts/joiner.plan.json' @@ -72,3 +72,4 @@ Plan export can be used as a build artifact: - Generate a plan export from a known input set. - Validate the export with schema checks (if available). - Compare against a known-good baseline (golden file) to detect unexpected drift. + diff --git a/docs/use/quickstart.md b/docs/use/quickstart.md index 5b144245..df75e471 100644 --- a/docs/use/quickstart.md +++ b/docs/use/quickstart.md @@ -121,7 +121,7 @@ $workflow = Join-Path 'C:\path\to\IdentityLifecycleEngine' 'examples\workflows\< With the following command we create a simple 'Joiner' request. ```powershell -$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' +$request = New-IdleRequest -LifecycleEvent 'Joiner' ``` ### 4. Select providers @@ -165,3 +165,4 @@ $result.Events | Select-Object Type, StepName, Message - You can override providers at execution time by passing `-Providers` to `Invoke-IdlePlan`. ::: + diff --git a/docs/use/workflows.md b/docs/use/workflows.md index 9a0d2148..f29f0096 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -188,7 +188,7 @@ When you create a lifecycle request, you provide data in the request object (via **Creating a request with values:** ```powershell -$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ +$req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserPrincipalName = 'jdoe@example.com' DisplayName = 'John Doe' GivenName = 'John' @@ -306,3 +306,4 @@ Typical validation rules: - required keys must exist - condition schemas must be valid - `*From` paths must reference allowed roots + diff --git a/examples/Invoke-IdleDemo.ps1 b/examples/Invoke-IdleDemo.ps1 index f8d7f34d..4ac20bb7 100644 --- a/examples/Invoke-IdleDemo.ps1 +++ b/examples/Invoke-IdleDemo.ps1 @@ -263,7 +263,7 @@ foreach ($wf in $selected) { Write-Host "" Write-DemoHeader "Plan" $lifecycleEvent = Get-IdleLifecycleEventFromWorkflowName -Name $wf.Name - $request = New-IdleLifecycleRequest -LifecycleEvent $lifecycleEvent -Actor 'example-user' + $request = New-IdleRequest -LifecycleEvent $lifecycleEvent -Actor 'example-user' $plan = New-IdlePlan -WorkflowPath $wf.Path -Request $request -Providers $providers Write-Host ("Plan created: LifecycleEvent={0} | Steps={1}" -f $lifecycleEvent, ($plan.Steps | Measure-Object).Count) @@ -305,3 +305,4 @@ if ($selected.Count -gt 1 -or $Repeat -gt 1) { ForEach-Object { [pscustomobject]@{ Status = $_.Name; Count = $_.Count } } | Format-Table -AutoSize } + diff --git a/examples/Invoke-LeaverWithManagerOOF.ps1 b/examples/Invoke-LeaverWithManagerOOF.ps1 index e74f246e..bf23be82 100644 --- a/examples/Invoke-LeaverWithManagerOOF.ps1 +++ b/examples/Invoke-LeaverWithManagerOOF.ps1 @@ -129,7 +129,7 @@ else { } } -$request = New-IdleLifecycleRequest ` +$request = New-IdleRequest ` -LifecycleEvent 'Leaver' ` -Actor $env:USERNAME ` -Input @{ @@ -204,3 +204,4 @@ foreach ($step in $result.Steps) { Write-Host "" Write-Host "==> Done." -ForegroundColor Cyan + diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index 8f5f256a..8be76b90 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -14,7 +14,7 @@ 'Invoke-IdlePlanObject', 'Invoke-IdleProviderMethod', 'New-IdleAuthSessionBroker', - 'New-IdleLifecycleRequestObject', + 'New-IdleRequestObject', 'New-IdlePlanObject', 'Test-IdleProviderMethodParameter', 'Test-IdleWorkflowDefinitionObject' @@ -34,3 +34,4 @@ } } } + diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 831c7ce5..1bd09aac 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -45,8 +45,9 @@ Export-ModuleMember -Function @( 'Invoke-IdlePlanObject', 'Invoke-IdleProviderMethod', 'New-IdleAuthSessionBroker', - 'New-IdleLifecycleRequestObject', + 'New-IdleRequestObject', 'New-IdlePlanObject', 'Test-IdleProviderMethodParameter', 'Test-IdleWorkflowDefinitionObject' ) -Alias @() + diff --git a/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 index f9a01a5f..2389a48e 100644 --- a/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1 @@ -6,6 +6,72 @@ $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT = 10 $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT = 60000 $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT = 300000 +function Assert-IdleRetryParameters { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [int] $MaxAttempts, + + [Parameter(Mandatory)] + [int] $InitialDelayMilliseconds, + + [Parameter(Mandatory)] + [double] $BackoffFactor, + + [Parameter(Mandatory)] + [int] $MaxDelayMilliseconds, + + [Parameter(Mandatory)] + [double] $JitterRatio, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceName + ) + + if ($MaxAttempts -lt 0 -or $MaxAttempts -gt $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT) { + throw [System.ArgumentException]::new( + "${SourceName}: MaxAttempts must be an integer between 0 and $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT (inclusive).", + 'MaxAttempts' + ) + } + + if ($InitialDelayMilliseconds -lt 0 -or $InitialDelayMilliseconds -gt $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT) { + throw [System.ArgumentException]::new( + "${SourceName}: InitialDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT (inclusive).", + 'InitialDelayMilliseconds' + ) + } + + if ($BackoffFactor -lt 1.0) { + throw [System.ArgumentException]::new( + "${SourceName}: BackoffFactor must be a number >= 1.0.", + 'BackoffFactor' + ) + } + + if ($MaxDelayMilliseconds -lt 0 -or $MaxDelayMilliseconds -gt $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT) { + throw [System.ArgumentException]::new( + "${SourceName}: MaxDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT (inclusive).", + 'MaxDelayMilliseconds' + ) + } + + if ($MaxDelayMilliseconds -lt $InitialDelayMilliseconds) { + throw [System.ArgumentException]::new( + "${SourceName}: MaxDelayMilliseconds ($MaxDelayMilliseconds) must be >= InitialDelayMilliseconds ($InitialDelayMilliseconds).", + 'MaxDelayMilliseconds' + ) + } + + if ($JitterRatio -lt 0.0 -or $JitterRatio -gt 1.0) { + throw [System.ArgumentException]::new( + "${SourceName}: JitterRatio must be a number between 0.0 and 1.0 (inclusive).", + 'JitterRatio' + ) + } +} + function Assert-IdleExecutionOptions { [CmdletBinding()] param( diff --git a/src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1 b/src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1 index 2dd1a91e..622fc9a8 100644 --- a/src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1 +++ b/src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1 @@ -33,12 +33,12 @@ function Assert-IdlePlanCapabilitiesSatisfied { continue } - $stepName = Get-IdleOptionalPropertyValue -Object $s -Name 'Name' + $stepName = Get-IdlePropertyValue -Object $s -Name 'Name' if ($null -eq $stepName -or [string]::IsNullOrWhiteSpace([string]$stepName)) { $stepName = '' } - $capsRaw = Get-IdleOptionalPropertyValue -Object $s -Name 'RequiresCapabilities' + $capsRaw = Get-IdlePropertyValue -Object $s -Name 'RequiresCapabilities' $caps = if ($null -eq $capsRaw) { @() } else { @($capsRaw) } if (@($caps).Count -gt 0) { diff --git a/src/IdLE.Core/Private/ConvertTo-IdleCapabilityIdentifier.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleCapabilityIdentifier.ps1 new file mode 100644 index 00000000..bbcf4027 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleCapabilityIdentifier.ps1 @@ -0,0 +1,21 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleCapabilityIdentifier { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $null + } + + $cap = ($Value -as [string]).Trim() + if ([string]::IsNullOrWhiteSpace($cap)) { + return $null + } + + return $cap +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleCapabilityList.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleCapabilityList.ps1 new file mode 100644 index 00000000..12a3a9b2 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleCapabilityList.ps1 @@ -0,0 +1,62 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleCapabilityList { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object[]] $Capabilities, + + [Parameter()] + [switch] $Validate, + + [Parameter()] + [switch] $Normalize, + + [Parameter()] + [switch] $Unique, + + [Parameter()] + [switch] $Sort, + + [Parameter()] + [AllowEmptyString()] + [string] $ErrorPrefix = 'Capability' + ) + + $items = @() + + foreach ($c in @($Capabilities)) { + if ($null -eq $c) { + continue + } + + $s = ConvertTo-IdleCapabilityIdentifier -Value $c + if ($null -eq $s) { + continue + } + + if ($Validate -and -not (Test-IdleCapabilityIdentifier -Capability $s)) { + throw [System.ArgumentException]::new( + "$ErrorPrefix '$s' is invalid. Expected dot-separated segments like 'IdLE.Identity.Read' or 'IdLE.Entitlement.Write'.", + 'Capabilities' + ) + } + + if ($Normalize) { + $s = ConvertTo-IdleNormalizedCapability -Capability $s + } + + $items += $s + } + + if ($Unique) { + $items = @($items | Sort-Object -Unique) + } + + if ($Sort) { + $items = @($items | Sort-Object) + } + + return @($items) +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index fd507ca4..1876070f 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -50,24 +50,6 @@ function ConvertTo-IdlePlanExportObject { return $null } - function ConvertTo-NullIfEmptyString { - [CmdletBinding()] - param( - [Parameter()] - [object] $Value - ) - - if ($null -eq $Value) { - return $null - } - - if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { - return $null - } - - return $Value - } - # ---- Engine block -------------------------------------------------------- $engineMap = New-OrderedMap $engineMap.name = 'IdLE' diff --git a/src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1 index 30d260bc..60153766 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1 @@ -51,8 +51,8 @@ function ConvertTo-IdleRequiredCapabilities { continue } - $s = ([string]$c).Trim() - if ([string]::IsNullOrWhiteSpace($s)) { + $s = ConvertTo-IdleCapabilityIdentifier -Value $c + if ($null -eq $s) { continue } @@ -60,7 +60,7 @@ function ConvertTo-IdleRequiredCapabilities { # - dot-separated segments # - no whitespace # - starts with a letter - if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + if (-not (Test-IdleCapabilityIdentifier -Capability $s)) { throw [System.ArgumentException]::new( ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'IdLE.Identity.Read'." -f $StepName, $s), 'Workflow' diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 61d8b612..a4448264 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -35,7 +35,7 @@ function ConvertTo-IdleWorkflowSteps { foreach ($s in @($WorkflowSteps)) { $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') + [string](Get-IdlePropertyValue -Object $s -Name 'Name') } else { '' @@ -46,7 +46,7 @@ function ConvertTo-IdleWorkflowSteps { } $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') + [string](Get-IdlePropertyValue -Object $s -Name 'Type') } else { '' @@ -64,7 +64,7 @@ function ConvertTo-IdleWorkflowSteps { } $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { - Get-IdleWorkflowStepValue -Step $s -Key 'Condition' + Get-IdlePropertyValue -Object $s -Name 'Condition' } else { $null @@ -103,14 +103,14 @@ function ConvertTo-IdleWorkflowSteps { } $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') + [string](Get-IdlePropertyValue -Object $s -Name 'Description') } else { '' } $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { - Copy-IdleDataObject -Value (Get-IdleWorkflowStepValue -Step $s -Key 'With') + Copy-IdleDataObject -Value (Get-IdlePropertyValue -Object $s -Name 'With') } else { @{} @@ -120,7 +120,7 @@ function ConvertTo-IdleWorkflowSteps { $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'RetryProfile') + [string](Get-IdlePropertyValue -Object $s -Name 'RetryProfile') } else { $null diff --git a/src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1 b/src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1 index 82a500c0..62aa7ae5 100644 --- a/src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1 @@ -5,14 +5,14 @@ function ConvertTo-NullIfEmptyString { param( [Parameter()] [AllowNull()] - [string] $Value + [object] $Value ) if ($null -eq $Value) { return $null } - if ([string]::IsNullOrWhiteSpace($Value)) { + if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { return $null } diff --git a/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 index bfefe540..804fa1b3 100644 --- a/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 +++ b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 @@ -27,14 +27,7 @@ function Copy-IdleDataObject { # Primitive / immutable types should be returned as-is before property inspection. # This prevents strings from being converted to PSCustomObject with Length property. - if ($Value -is [string] -or - $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]) { + if (Test-IdlePrimitiveValue -Value $Value) { return $Value } @@ -46,7 +39,7 @@ function Copy-IdleDataObject { return $copy } - if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + if (Test-IdleEnumerableValue -Value $Value) { $arr = @() foreach ($item in $Value) { $arr += Copy-IdleDataObject -Value $item diff --git a/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 b/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 index 7a4d8626..a4abb85a 100644 --- a/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 +++ b/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 @@ -119,14 +119,7 @@ function Copy-IdleRedactedObject { } # Primitive / immutable-ish types can be returned as-is. - if ($InnerValue -is [string] -or - $InnerValue -is [int] -or - $InnerValue -is [long] -or - $InnerValue -is [double] -or - $InnerValue -is [decimal] -or - $InnerValue -is [bool] -or - $InnerValue -is [datetime] -or - $InnerValue -is [guid]) { + if (Test-IdlePrimitiveValue -Value $InnerValue) { return $InnerValue } @@ -167,7 +160,7 @@ function Copy-IdleRedactedObject { } # Enumerables (except string) -> clone recursively. - if ($InnerValue -is [System.Collections.IEnumerable] -and -not ($InnerValue -is [string])) { + if (Test-IdleEnumerableValue -Value $InnerValue) { $items = @() foreach ($item in $InnerValue) { $items += Copy-IdleRedactedInternal -InnerValue $item diff --git a/src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1 index 3f74a9b7..3e3b6704 100644 --- a/src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1 @@ -43,18 +43,7 @@ function Get-IdleAvailableCapabilities { if ($null -eq $caps) { return @() } - return @( - $caps | - Where-Object { $null -ne $_ } | - ForEach-Object { - $rawCap = ([string]$_).Trim() - if (-not [string]::IsNullOrWhiteSpace($rawCap)) { - ConvertTo-IdleNormalizedCapability -Capability $rawCap - } - } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | - Sort-Object -Unique - ) + return @(ConvertTo-IdleCapabilityList -Capabilities $caps -Normalize -Unique) } return @() diff --git a/src/IdLE.Core/Private/Get-IdleOptionalPropertyValue.ps1 b/src/IdLE.Core/Private/Get-IdleOptionalPropertyValue.ps1 deleted file mode 100644 index 3e7c5a9f..00000000 --- a/src/IdLE.Core/Private/Get-IdleOptionalPropertyValue.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -Set-StrictMode -Version Latest - -function Get-IdleOptionalPropertyValue { - <# - .SYNOPSIS - Safely reads an optional property from an object. - - .DESCRIPTION - Works with: - - IDictionary (hashtables / ordered dictionaries) - - PSCustomObject / objects with note properties - - Returns $null when the property does not exist. - Uses Get-Member to avoid PropertyNotFoundException in strict mode. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowNull()] - [object] $Object, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Name - ) - - if ($null -eq $Object) { - return $null - } - - if ($Object -is [System.Collections.IDictionary]) { - if ($Object.ContainsKey($Name)) { - return $Object[$Name] - } - return $null - } - - $m = $Object | Get-Member -Name $Name -MemberType NoteProperty, Property -ErrorAction SilentlyContinue - if ($null -eq $m) { - return $null - } - - return $Object.$Name -} diff --git a/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 b/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 new file mode 100644 index 00000000..7d55df18 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 @@ -0,0 +1,32 @@ +Set-StrictMode -Version Latest + +function Get-IdlePropertyValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name + ) + + if ($null -eq $Object) { + return $null + } + + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($Name)) { + return $Object[$Name] + } + return $null + } + + $prop = $Object.PSObject.Properties[$Name] + if ($null -ne $prop) { + return $prop.Value + } + + return $null +} diff --git a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 index ec3e4a77..80cee347 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -74,31 +74,7 @@ function Get-IdleProviderCapabilities { } # Normalize, validate, and return a stable list. - $normalized = New-Object System.Collections.Generic.List[string] - $seen = New-Object System.Collections.Generic.HashSet[string] - foreach ($c in @($capabilities)) { - if ($null -eq $c) { - continue - } - - $s = ($c -as [string]).Trim() - if ([string]::IsNullOrWhiteSpace($s)) { - continue - } - - # Capability naming convention: - # - dot-separated segments - # - no whitespace - # - starts with a letter - # Example: 'IdLE.Entitlement.Write', 'IdLE.Identity.Attribute.Ensure' - if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { - throw "Provider capability '$s' is invalid. Expected dot-separated segments like 'IdLE.Identity.Read' or 'IdLE.Entitlement.Write'." - } - - if ($seen.Add($s)) { - $null = $normalized.Add($s) - } - } + $normalized = ConvertTo-IdleCapabilityList -Capabilities $capabilities -Validate -Unique -ErrorPrefix 'Provider capability' if ($capabilitySource -eq 'explicit') { return @($normalized | Sort-Object -Unique) diff --git a/src/IdLE.Core/Private/Get-IdleStepField.ps1 b/src/IdLE.Core/Private/Get-IdleStepField.ps1 deleted file mode 100644 index 6789e8e9..00000000 --- a/src/IdLE.Core/Private/Get-IdleStepField.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -Set-StrictMode -Version Latest - -function Get-IdleStepField { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowNull()] - [object] $Step, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Name - ) - - if ($null -eq $Step) { return $null } - - if ($Step -is [System.Collections.IDictionary]) { - if ($Step.Contains($Name)) { - return $Step[$Name] - } - return $null - } - - $propNames = @($Step.PSObject.Properties.Name) - if ($propNames -contains $Name) { - return $Step.$Name - } - - return $null -} diff --git a/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 index 9cba88d6..ecc34bc4 100644 --- a/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 +++ b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 @@ -15,10 +15,8 @@ function Get-IdleValueByPath { foreach ($segment in ($Path -split '\.')) { if ($null -eq $current) { return $null } - $prop = $current.PSObject.Properties[$segment] - if ($null -eq $prop) { return $null } - - $current = $prop.Value + $current = Get-IdlePropertyValue -Object $current -Name $segment + if ($null -eq $current) { return $null } } return $current diff --git a/src/IdLE.Core/Private/Get-IdleWorkflowStepValue.ps1 b/src/IdLE.Core/Private/Get-IdleWorkflowStepValue.ps1 deleted file mode 100644 index ba23bcf7..00000000 --- a/src/IdLE.Core/Private/Get-IdleWorkflowStepValue.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -Set-StrictMode -Version Latest - -function Get-IdleWorkflowStepValue { - <# - .SYNOPSIS - Gets a value from a workflow step by key. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Step, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Key - ) - - if ($Step -is [System.Collections.IDictionary]) { - return $Step[$Key] - } - - return $Step.$Key -} diff --git a/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 b/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 index d97e8896..825b0795 100644 --- a/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 @@ -82,23 +82,18 @@ function Invoke-IdleWithRetry { [scriptblock] $Operation, [Parameter()] - [ValidateRange(0, 50)] [int] $MaxAttempts = 3, [Parameter()] - [ValidateRange(0, 600000)] [int] $InitialDelayMilliseconds = 250, [Parameter()] - [ValidateRange(1.0, 100.0)] [double] $BackoffFactor = 2.0, [Parameter()] - [ValidateRange(0, 600000)] [int] $MaxDelayMilliseconds = 5000, [Parameter()] - [ValidateRange(0.0, 1.0)] [double] $JitterRatio = 0.2, [Parameter()] @@ -118,6 +113,14 @@ function Invoke-IdleWithRetry { [string] $DeterministicSeed = '' ) + Assert-IdleRetryParameters ` + -MaxAttempts $MaxAttempts ` + -InitialDelayMilliseconds $InitialDelayMilliseconds ` + -BackoffFactor $BackoffFactor ` + -MaxDelayMilliseconds $MaxDelayMilliseconds ` + -JitterRatio $JitterRatio ` + -SourceName 'Invoke-IdleWithRetry' + # Handle MaxAttempts = 0 (no retry): run once and propagate any error if ($MaxAttempts -eq 0) { $value = & $Operation diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 83caf0b6..af043e30 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -19,33 +19,6 @@ function Resolve-IdleStepMetadataCatalog { $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - # Helper: Validate a single capability identifier format. - function Test-IdleCapabilityIdentifier { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string] $Capability, - - [Parameter(Mandatory)] - [string] $StepType, - - [Parameter(Mandatory)] - [string] $SourceName - ) - - $cap = $Capability.Trim() - if ([string]::IsNullOrWhiteSpace($cap)) { - return - } - - if ($cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", - 'Providers' - ) - } - } - # Helper: Validate RequiredCapabilities value. function Test-IdleRequiredCapabilities { [CmdletBinding()] @@ -66,7 +39,13 @@ function Resolve-IdleStepMetadataCatalog { } if ($Value -is [string]) { - Test-IdleCapabilityIdentifier -Capability $Value -StepType $StepType -SourceName $SourceName + $cap = ConvertTo-IdleCapabilityIdentifier -Value $Value + if ($null -ne $cap -and -not (Test-IdleCapabilityIdentifier -Capability $cap)) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", + 'Providers' + ) + } return } @@ -91,7 +70,17 @@ function Resolve-IdleStepMetadataCatalog { ) } - Test-IdleCapabilityIdentifier -Capability $c -StepType $StepType -SourceName $SourceName + $cap = ConvertTo-IdleCapabilityIdentifier -Value $c + if ($null -eq $cap) { + continue + } + + if (-not (Test-IdleCapabilityIdentifier -Capability $cap)) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", + 'Providers' + ) + } } return } diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index ec4b7d18..93a6e6c2 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -107,14 +107,14 @@ function Resolve-IdleTemplateString { # Helper function to resolve a template path to its value $resolvePath = { param([string]$Path) - + # Handle Request.Input.* alias to Request.DesiredState.* $targetPath = $Path $hasInputProperty = $false if ($Request.PSObject.Properties['Input']) { $hasInputProperty = $true } - + if ($Path.StartsWith('Request.Input.')) { if (-not $hasInputProperty) { # Alias to DesiredState @@ -127,36 +127,9 @@ function Resolve-IdleTemplateString { } } - # Resolve the value (using custom logic that handles hashtables) + # Resolve the value (shared path resolver handles hashtables and objects) $contextWrapper = [pscustomobject]@{ Request = $Request } - $current = $contextWrapper - foreach ($segment in ($targetPath -split '\.')) { - if ($null -eq $current) { - $resolvedValue = $null - break - } - - # Handle hashtables/dictionaries - if ($current -is [System.Collections.IDictionary]) { - if ($current.ContainsKey($segment)) { - $current = $current[$segment] - } - else { - $current = $null - } - } - # Handle PSCustomObjects and class instances - else { - $prop = $current.PSObject.Properties[$segment] - if ($null -eq $prop) { - $current = $null - } - else { - $current = $prop.Value - } - } - } - $resolvedValue = $current + $resolvedValue = Get-IdleValueByPath -Object $contextWrapper -Path $targetPath # Fail fast on null/missing values if ($null -eq $resolvedValue) { diff --git a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 index cbf391bd..e8d2b7ce 100644 --- a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 @@ -48,13 +48,7 @@ function Resolve-IdleWorkflowTemplates { } # 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]) { + if (Test-IdlePrimitiveValue -Value $Value) { return $Value } @@ -68,7 +62,7 @@ function Resolve-IdleWorkflowTemplates { } # Arrays/lists: recurse on items - if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + if (Test-IdleEnumerableValue -Value $Value) { $resolved = @() foreach ($item in $Value) { $resolved += Resolve-IdleWorkflowTemplates -Value $item -Request $Request -StepName $StepName diff --git a/src/IdLE.Core/Private/Test-IdleCapabilityIdentifier.ps1 b/src/IdLE.Core/Private/Test-IdleCapabilityIdentifier.ps1 new file mode 100644 index 00000000..e4932bb6 --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleCapabilityIdentifier.ps1 @@ -0,0 +1,12 @@ +Set-StrictMode -Version Latest + +function Test-IdleCapabilityIdentifier { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Capability + ) + + return ($Capability -match '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') +} diff --git a/src/IdLE.Core/Private/Test-IdleEnumerableValue.ps1 b/src/IdLE.Core/Private/Test-IdleEnumerableValue.ps1 new file mode 100644 index 00000000..71b9dcf6 --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleEnumerableValue.ps1 @@ -0,0 +1,16 @@ +Set-StrictMode -Version Latest + +function Test-IdleEnumerableValue { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $false + } + + return ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) +} diff --git a/src/IdLE.Core/Private/Test-IdlePrimitiveValue.ps1 b/src/IdLE.Core/Private/Test-IdlePrimitiveValue.ps1 new file mode 100644 index 00000000..7818fd05 --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdlePrimitiveValue.ps1 @@ -0,0 +1,23 @@ +Set-StrictMode -Version Latest + +function Test-IdlePrimitiveValue { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $false + } + + return ($Value -is [string] -or + $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]) +} diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 57bd50be..bd0b1af2 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -87,9 +87,9 @@ function Invoke-IdlePlanObject { if ($null -ne $planSteps -and ($planSteps -is [System.Collections.IEnumerable]) -and ($planSteps -isnot [string])) { $i = 0 foreach ($step in $planSteps) { - $stepType = [string](Get-IdleStepField -Step $step -Name 'Type') + $stepType = [string](Get-IdlePropertyValue -Object $step -Name 'Type') if ($stepType -eq 'IdLE.Step.AcquireAuthSession') { - $with = Get-IdleStepField -Step $step -Name 'With' + $with = Get-IdlePropertyValue -Object $step -Name 'With' $options = $null if ($null -ne $with) { if ($with -is [System.Collections.IDictionary]) { @@ -278,12 +278,12 @@ function Invoke-IdlePlanObject { continue } - $stepName = [string](Get-IdleStepField -Step $step -Name 'Name') + $stepName = [string](Get-IdlePropertyValue -Object $step -Name 'Name') if ($null -eq $stepName) { $stepName = '' } - $stepType = Get-IdleStepField -Step $step -Name 'Type' - $stepWith = Get-IdleStepField -Step $step -Name 'With' - $stepStatus = [string](Get-IdleStepField -Step $step -Name 'Status') + $stepType = Get-IdlePropertyValue -Object $step -Name 'Type' + $stepWith = Get-IdlePropertyValue -Object $step -Name 'With' + $stepStatus = [string](Get-IdlePropertyValue -Object $step -Name 'Status') if ($null -eq $stepStatus) { $stepStatus = '' } # Conditions are evaluated during planning and represented as Step.Status. diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index a1dedeb6..12268cfb 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -96,7 +96,7 @@ function New-IdlePlanObject { # Load StepMetadataCatalog (trusted extension point). $stepMetadataCatalog = Resolve-IdleStepMetadataCatalog -Providers $Providers - $workflowOnFailureSteps = Get-IdleOptionalPropertyValue -Object $workflow -Name 'OnFailureSteps' + $workflowOnFailureSteps = Get-IdlePropertyValue -Object $workflow -Name 'OnFailureSteps' # Normalize primary and OnFailure steps. # IMPORTANT: diff --git a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 similarity index 88% rename from src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 rename to src/IdLE.Core/Public/New-IdleRequestObject.ps1 index dfd66eb8..cc28c12a 100644 --- a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -1,4 +1,4 @@ -function New-IdleLifecycleRequestObject { +function New-IdleRequestObject { <# .SYNOPSIS Creates a lifecycle request object (core factory). @@ -39,17 +39,17 @@ function New-IdleLifecycleRequestObject { what changed from the previous state). Remains $null when omitted. Must not contain ScriptBlocks. .EXAMPLE - $request = New-IdleLifecycleRequestObject -LifecycleEvent 'Joiner' + $request = New-IdleRequestObject -LifecycleEvent 'Joiner' Creates a minimal Joiner request with auto-generated CorrelationId and empty IdentityKeys/DesiredState. .EXAMPLE - $request = New-IdleLifecycleRequestObject -LifecycleEvent 'Joiner' -CorrelationId (New-Guid).Guid -IdentityKeys @{ EmployeeId = '12345' } -DesiredState @{ Department = 'Engineering'; MailNickname = 'jdoe'; Title = 'Engineer' } + $request = New-IdleRequestObject -LifecycleEvent 'Joiner' -CorrelationId (New-Guid).Guid -IdentityKeys @{ EmployeeId = '12345' } -DesiredState @{ Department = 'Engineering'; MailNickname = 'jdoe'; Title = 'Engineer' } Creates a Joiner request with specific identity keys and desired state attributes for a typical onboarding workflow. .EXAMPLE - $request = New-IdleLifecycleRequestObject -LifecycleEvent 'Mover' -IdentityKeys @{ UPN = 'user@contoso.com' } -Changes @{ Department = 'Sales' } -Actor 'admin@contoso.com' + $request = New-IdleRequestObject -LifecycleEvent 'Mover' -IdentityKeys @{ UPN = 'user@contoso.com' } -Changes @{ Department = 'Sales' } -Actor 'admin@contoso.com' Creates a Mover request with identity keys, changes, and actor information for a department transfer workflow. @@ -65,7 +65,7 @@ function New-IdleLifecycleRequestObject { - Sensitive data in request objects may be logged or emitted in events. Rely on redaction boundaries defined in the engine's event sink and logging layers. - This is a core engine function. For the user-facing API, use New-IdleLifecycleRequest from + This is a core engine function. For the user-facing API, use New-IdleRequest from the IdLE module, which delegates to this function. #> [CmdletBinding()] @@ -111,3 +111,4 @@ function New-IdleLifecycleRequestObject { $Actor ) } + diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 7c8da290..0ce48612 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -140,7 +140,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { # Mail = 'servicedesk@contoso.com' # } # } - # $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -DesiredState @{ + # $req = New-IdleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -DesiredState @{ # Manager = @{ DisplayName = $mgr.DisplayName; Mail = $mgr.Mail } # } @@ -258,3 +258,4 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { Error = $null } } + diff --git a/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 index f7b56e04..27b1dfba 100644 --- a/src/IdLE/IdLE.psd1 +++ b/src/IdLE/IdLE.psd1 @@ -28,7 +28,7 @@ FunctionsToExport = @( 'Test-IdleWorkflow', - 'New-IdleLifecycleRequest', + 'New-IdleRequest', 'New-IdlePlan', 'Invoke-IdlePlan', 'Export-IdlePlan', @@ -49,3 +49,4 @@ } } } + diff --git a/src/IdLE/IdLE.psm1 b/src/IdLE/IdLE.psm1 index 13c95214..7f7d8a25 100644 --- a/src/IdLE/IdLE.psm1 +++ b/src/IdLE/IdLE.psm1 @@ -17,9 +17,10 @@ if (Test-Path -Path $PublicPath) { # Export exactly the public API cmdlets (contract). Export-ModuleMember -Function @( 'Test-IdleWorkflow', - 'New-IdleLifecycleRequest', + 'New-IdleRequest', 'New-IdlePlan', 'Invoke-IdlePlan', 'Export-IdlePlan', 'New-IdleAuthSession' ) + diff --git a/src/IdLE/Public/New-IdlePlan.ps1 b/src/IdLE/Public/New-IdlePlan.ps1 index 841d6587..2df10267 100644 --- a/src/IdLE/Public/New-IdlePlan.ps1 +++ b/src/IdLE/Public/New-IdlePlan.ps1 @@ -11,7 +11,7 @@ function New-IdlePlan { Path to the workflow definition file (PSD1). .PARAMETER Request - The lifecycle request object created by New-IdleLifecycleRequest. + The lifecycle request object created by New-IdleRequest. .PARAMETER Providers Provider registry/collection passed through to planning. (Structure to be defined later.) @@ -40,3 +40,4 @@ function New-IdlePlan { # Keep meta module thin: delegate planning to IdLE.Core. return New-IdlePlanObject -WorkflowPath $WorkflowPath -Request $Request -Providers $Providers } + diff --git a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 b/src/IdLE/Public/New-IdleRequest.ps1 similarity index 88% rename from src/IdLE/Public/New-IdleLifecycleRequest.ps1 rename to src/IdLE/Public/New-IdleRequest.ps1 index 53f5323d..14df2119 100644 --- a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 +++ b/src/IdLE/Public/New-IdleRequest.ps1 @@ -1,4 +1,4 @@ -function New-IdleLifecycleRequest { +function New-IdleRequest { <# .SYNOPSIS Creates a lifecycle request object. @@ -27,7 +27,7 @@ function New-IdleLifecycleRequest { Optional hashtable describing changes (typically used for Mover lifecycle events). .EXAMPLE - New-IdleLifecycleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } + New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } .OUTPUTS IdleLifecycleRequest @@ -55,5 +55,6 @@ function New-IdleLifecycleRequest { ) # Use core-exported factory to construct the domain object. Keeps domain model inside IdLE.Core. - New-IdleLifecycleRequestObject @PSBoundParameters + New-IdleRequestObject @PSBoundParameters } + diff --git a/tests/Core/CapabilityDeprecation.Tests.ps1 b/tests/Core/CapabilityDeprecation.Tests.ps1 index f88d9e5e..cdbbe6a3 100644 --- a/tests/Core/CapabilityDeprecation.Tests.ps1 +++ b/tests/Core/CapabilityDeprecation.Tests.ps1 @@ -4,11 +4,10 @@ BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule Import-IdleTestMailboxModule - - # Import mailbox steps module for capability metadata - $mailboxStepsPath = Join-Path $PSScriptRoot '..' '..' 'src' 'IdLE.Steps.Mailbox' 'IdLE.Steps.Mailbox.psd1' - if (Test-Path $mailboxStepsPath) { - Import-Module $mailboxStepsPath -Force -ErrorAction SilentlyContinue + + $script:MailboxStepsPath = Join-Path $PSScriptRoot '..' '..' 'src' 'IdLE.Steps.Mailbox' 'IdLE.Steps.Mailbox.psd1' + if (Test-Path $script:MailboxStepsPath) { + Import-Module $script:MailboxStepsPath -Force -ErrorAction SilentlyContinue } } @@ -33,7 +32,7 @@ Describe 'Capability Deprecation and Migration' { # Verify the workflow file exists $wfPath | Should -Exist - $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -DesiredState @{ Manager = @{ DisplayName = 'IT Support' Mail = 'support@contoso.com' @@ -73,7 +72,7 @@ Describe 'Capability Deprecation and Migration' { # Use a real workflow file $wfPath = Join-Path $PSScriptRoot '..' '..' 'examples' 'workflows' 'templates' 'exo-leaver-mailbox-offboarding.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -DesiredState @{ Manager = @{ DisplayName = 'IT Support' Mail = 'support@contoso.com' @@ -97,3 +96,4 @@ Describe 'Capability Deprecation and Migration' { } } } + diff --git a/tests/Core/Copy-IdleRedactedObject.Tests.ps1 b/tests/Core/Copy-IdleRedactedObject.Tests.ps1 index c1d7e24c..0597752d 100644 --- a/tests/Core/Copy-IdleRedactedObject.Tests.ps1 +++ b/tests/Core/Copy-IdleRedactedObject.Tests.ps1 @@ -1,10 +1,11 @@ +Set-StrictMode -Version Latest + BeforeDiscovery { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule } Describe 'Copy-IdleRedactedObject - deterministic redaction utility' { - InModuleScope 'IdLE.Core' { BeforeAll { @@ -12,205 +13,207 @@ Describe 'Copy-IdleRedactedObject - deterministic redaction utility' { Get-Command Copy-IdleRedactedObject -ErrorAction Stop | Out-Null } - It 'redacts known keys in nested dictionaries and objects' { - $input = [ordered]@{ - userName = 'alice' - password = 'SuperSecret!' - profile = [pscustomobject]@{ - token = 'abc123' - city = 'Berlin' - } - meta = @{ - accessToken = 'token-value' - note = 'ok' + Context 'Redaction behavior' { + It 'redacts known keys in nested dictionaries and objects' { + $input = [ordered]@{ + userName = 'alice' + password = 'SuperSecret!' + profile = [pscustomobject]@{ + token = 'abc123' + city = 'Berlin' + } + meta = @{ + accessToken = 'token-value' + note = 'ok' + } } - } - $copy = Copy-IdleRedactedObject -Value $input + $copy = Copy-IdleRedactedObject -Value $input - $copy.userName | Should -Be 'alice' - $copy.password | Should -Be '[REDACTED]' + $copy.userName | Should -Be 'alice' + $copy.password | Should -Be '[REDACTED]' - $copy.profile.token | Should -Be '[REDACTED]' - $copy.profile.city | Should -Be 'Berlin' + $copy.profile.token | Should -Be '[REDACTED]' + $copy.profile.city | Should -Be 'Berlin' - $copy.meta.accessToken | Should -Be '[REDACTED]' - $copy.meta.note | Should -Be 'ok' - } - - It 'redacts PSCredential and SecureString regardless of key name' { - $secure = ConvertTo-SecureString -String 'SecretValue' -AsPlainText -Force - $cred = [pscredential]::new('user', $secure) - - $input = @{ - anyName = $cred - values = @( - 'ok' - $secure - ) + $copy.meta.accessToken | Should -Be '[REDACTED]' + $copy.meta.note | Should -Be 'ok' } - $copy = Copy-IdleRedactedObject -Value $input - - $copy.anyName | Should -Be '[REDACTED]' - $copy.values[0] | Should -Be 'ok' - $copy.values[1] | Should -Be '[REDACTED]' - } + It 'redacts PSCredential and SecureString regardless of key name' { + $secure = ConvertTo-SecureString -String 'SecretValue' -AsPlainText -Force + $cred = [pscredential]::new('user', $secure) - It 'does not mutate the input object' { - $input = @{ - password = 'DoNotTouch' - nested = @{ - token = 'StillDoNotTouch' + $input = @{ + anyName = $cred + values = @( + 'ok' + $secure + ) } + + $copy = Copy-IdleRedactedObject -Value $input + + $copy.anyName | Should -Be '[REDACTED]' + $copy.values[0] | Should -Be 'ok' + $copy.values[1] | Should -Be '[REDACTED]' } - $copy = Copy-IdleRedactedObject -Value $input + It 'does not mutate the input object' { + $input = @{ + password = 'DoNotTouch' + nested = @{ + token = 'StillDoNotTouch' + } + } - # Input must remain unchanged. - $input.password | Should -Be 'DoNotTouch' - $input.nested.token | Should -Be 'StillDoNotTouch' + $copy = Copy-IdleRedactedObject -Value $input - # Copy must be redacted. - $copy.password | Should -Be '[REDACTED]' - $copy.nested.token | Should -Be '[REDACTED]' - } + # Input must remain unchanged. + $input.password | Should -Be 'DoNotTouch' + $input.nested.token | Should -Be 'StillDoNotTouch' - It 'uses exact key matching and does not redact partial matches' { - $input = @{ - password = 'secret' - myPassword = 'should-stay' - Token = 'value' - tokenize = 'should-stay' + # Copy must be redacted. + $copy.password | Should -Be '[REDACTED]' + $copy.nested.token | Should -Be '[REDACTED]' } - $copy = Copy-IdleRedactedObject -Value $input + It 'uses exact key matching and does not redact partial matches' { + $input = @{ + password = 'secret' + myPassword = 'should-stay' + Token = 'value' + tokenize = 'should-stay' + } - $copy.password | Should -Be '[REDACTED]' - $copy.Token | Should -Be '[REDACTED]' + $copy = Copy-IdleRedactedObject -Value $input - $copy.myPassword | Should -Be 'should-stay' - $copy.tokenize | Should -Be 'should-stay' - } + $copy.password | Should -Be '[REDACTED]' + $copy.Token | Should -Be '[REDACTED]' - It 'handles cyclic graphs by replacing the cyclic reference with the redaction marker' { - $cycle = @{} - $cycle.self = $cycle + $copy.myPassword | Should -Be 'should-stay' + $copy.tokenize | Should -Be 'should-stay' + } - $copy = Copy-IdleRedactedObject -Value $cycle + It 'handles cyclic graphs by replacing the cyclic reference with the redaction marker' { + $cycle = @{} + $cycle.self = $cycle - # We cannot represent cycles in a stable export/event model, so we redact the recursive edge. - $copy.self | Should -Be '[REDACTED]' - } + $copy = Copy-IdleRedactedObject -Value $cycle - It 'supports a custom redaction marker' { - $input = @{ - password = 'secret' + # We cannot represent cycles in a stable export/event model, so we redact the recursive edge. + $copy.self | Should -Be '[REDACTED]' } - $copy = Copy-IdleRedactedObject -Value $input -RedactionMarker '' + It 'supports a custom redaction marker' { + $input = @{ + password = 'secret' + } - $copy.password | Should -Be '' - } + $copy = Copy-IdleRedactedObject -Value $input -RedactionMarker '' - It 'redacts AccountPassword key' { - $input = @{ - userName = 'testuser' - AccountPassword = 'ProtectedStringValue' - otherField = 'visible' + $copy.password | Should -Be '' } - $copy = Copy-IdleRedactedObject -Value $input + It 'redacts AccountPassword key' { + $input = @{ + userName = 'testuser' + AccountPassword = 'ProtectedStringValue' + otherField = 'visible' + } - $copy.userName | Should -Be 'testuser' - $copy.AccountPassword | Should -Be '[REDACTED]' - $copy.otherField | Should -Be 'visible' - } + $copy = Copy-IdleRedactedObject -Value $input - It 'redacts AccountPasswordAsPlainText key' { - $input = @{ - userName = 'testuser' - AccountPasswordAsPlainText = 'PlainTextPassword' - otherField = 'visible' + $copy.userName | Should -Be 'testuser' + $copy.AccountPassword | Should -Be '[REDACTED]' + $copy.otherField | Should -Be 'visible' } - $copy = Copy-IdleRedactedObject -Value $input + It 'redacts AccountPasswordAsPlainText key' { + $input = @{ + userName = 'testuser' + AccountPasswordAsPlainText = 'PlainTextPassword' + otherField = 'visible' + } - $copy.userName | Should -Be 'testuser' - $copy.AccountPasswordAsPlainText | Should -Be '[REDACTED]' - $copy.otherField | Should -Be 'visible' - } + $copy = Copy-IdleRedactedObject -Value $input - It 'redacts both password fields if present in nested structure' { - $input = @{ - userDetails = @{ - name = 'alice' - AccountPassword = 'SecureValue' - } - settings = [pscustomobject]@{ - AccountPasswordAsPlainText = 'PlainTextValue' - enabled = $true - } + $copy.userName | Should -Be 'testuser' + $copy.AccountPasswordAsPlainText | Should -Be '[REDACTED]' + $copy.otherField | Should -Be 'visible' } - $copy = Copy-IdleRedactedObject -Value $input + It 'redacts both password fields if present in nested structure' { + $input = @{ + userDetails = @{ + name = 'alice' + AccountPassword = 'SecureValue' + } + settings = [pscustomobject]@{ + AccountPasswordAsPlainText = 'PlainTextValue' + enabled = $true + } + } - $copy.userDetails.name | Should -Be 'alice' - $copy.userDetails.AccountPassword | Should -Be '[REDACTED]' - $copy.settings.AccountPasswordAsPlainText | Should -Be '[REDACTED]' - $copy.settings.enabled | Should -Be $true - } + $copy = Copy-IdleRedactedObject -Value $input - It 'redacts GeneratedAccountPasswordPlainText key' { - $input = @{ - userName = 'testuser' - GeneratedAccountPasswordPlainText = 'GeneratedPlainTextPassword123!' - otherField = 'visible' + $copy.userDetails.name | Should -Be 'alice' + $copy.userDetails.AccountPassword | Should -Be '[REDACTED]' + $copy.settings.AccountPasswordAsPlainText | Should -Be '[REDACTED]' + $copy.settings.enabled | Should -Be $true } - $copy = Copy-IdleRedactedObject -Value $input + It 'redacts GeneratedAccountPasswordPlainText key' { + $input = @{ + userName = 'testuser' + GeneratedAccountPasswordPlainText = 'GeneratedPlainTextPassword123!' + otherField = 'visible' + } - $copy.userName | Should -Be 'testuser' - $copy.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' - $copy.otherField | Should -Be 'visible' - } + $copy = Copy-IdleRedactedObject -Value $input - It 'redacts GeneratedAccountPasswordProtected key' { - $input = @{ - userName = 'testuser' - GeneratedAccountPasswordProtected = '76492d1116743f0423413b16050a5345MgB8AHcAYwBVAG0AawBlAEoAZgBMAGIARABlAEIASQBvAA==' - otherField = 'visible' + $copy.userName | Should -Be 'testuser' + $copy.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' + $copy.otherField | Should -Be 'visible' } - $copy = Copy-IdleRedactedObject -Value $input + It 'redacts GeneratedAccountPasswordProtected key' { + $input = @{ + userName = 'testuser' + GeneratedAccountPasswordProtected = '76492d1116743f0423413b16050a5345MgB8AHcAYwBVAG0AawBlAEoAZgBMAGIARABlAEIASQBvAA==' + otherField = 'visible' + } - $copy.userName | Should -Be 'testuser' - $copy.GeneratedAccountPasswordProtected | Should -Be '[REDACTED]' - $copy.otherField | Should -Be 'visible' - } + $copy = Copy-IdleRedactedObject -Value $input - It 'redacts generated password fields in nested structures' { - $input = @{ - result = @{ - IdentityKey = 'user@contoso.com' - GeneratedAccountPasswordPlainText = 'PlainPassword' - GeneratedAccountPasswordProtected = 'ProtectedPassword' - Changed = $true - } - metadata = [pscustomobject]@{ - GeneratedAccountPasswordPlainText = 'AnotherPlain' - timestamp = '2024-01-01' - } + $copy.userName | Should -Be 'testuser' + $copy.GeneratedAccountPasswordProtected | Should -Be '[REDACTED]' + $copy.otherField | Should -Be 'visible' } - $copy = Copy-IdleRedactedObject -Value $input + It 'redacts generated password fields in nested structures' { + $input = @{ + result = @{ + IdentityKey = 'user@contoso.com' + GeneratedAccountPasswordPlainText = 'PlainPassword' + GeneratedAccountPasswordProtected = 'ProtectedPassword' + Changed = $true + } + metadata = [pscustomobject]@{ + GeneratedAccountPasswordPlainText = 'AnotherPlain' + timestamp = '2024-01-01' + } + } + + $copy = Copy-IdleRedactedObject -Value $input - $copy.result.IdentityKey | Should -Be 'user@contoso.com' - $copy.result.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' - $copy.result.GeneratedAccountPasswordProtected | Should -Be '[REDACTED]' - $copy.result.Changed | Should -Be $true - $copy.metadata.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' - $copy.metadata.timestamp | Should -Be '2024-01-01' + $copy.result.IdentityKey | Should -Be 'user@contoso.com' + $copy.result.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' + $copy.result.GeneratedAccountPasswordProtected | Should -Be '[REDACTED]' + $copy.result.Changed | Should -Be $true + $copy.metadata.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' + $copy.metadata.timestamp | Should -Be '2024-01-01' + } } } } diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index a83ffe78..8a550491 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -1,4 +1,3 @@ -# Requires -Version 7.0 Set-StrictMode -Version Latest BeforeAll { @@ -7,15 +6,11 @@ BeforeAll { } Describe 'Export-IdlePlan' { - Context 'JSON contract export' { - It 'exports a stable, canonical JSON representation of a plan' { - # Arrange $cid = '11111111-1111-1111-1111-111111111111' - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-export.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-export.psd1' -Content @' @{ Name = 'Joiner - Export Fixture' LifecycleEvent = 'Joiner' @@ -31,44 +26,35 @@ Describe 'Export-IdlePlan' { } '@ - # IMPORTANT: Provide request intent payload so the export can include request.input. - $req = New-IdleLifecycleRequest ` - -LifecycleEvent 'Joiner' ` - -CorrelationId $cid ` - -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` - -DesiredState ([ordered]@{ department = 'IT' }) + $req = New-IdleTestRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` + -DesiredState ([ordered]@{ department = 'IT' }) $providers = @{ Dummy = $true - StepRegistry = @{ - 'EnsureMailbox' = 'Invoke-IdleTestNoopStep' - } + StepRegistry = @{ 'EnsureMailbox' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('EnsureMailbox') } - + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $expectedPath = Join-Path $PSScriptRoot '..' 'fixtures/plan-export/expected/plan-export.json' $expectedJson = Get-Content -Path $expectedPath -Raw -Encoding utf8 - # Act $actualJson = $plan | Export-IdlePlan - # Assert - # Normalize trailing whitespace (EOF newline differences) and line endings (Windows/Linux). ($actualJson -replace "`r`n", "`n").TrimEnd() | Should -Be (($expectedJson -replace "`r`n", "`n").TrimEnd()) } } Context 'File output (-Path)' { - It 'writes the JSON artifact to disk (TestDrive) using UTF-8 without BOM' { - # Arrange $cid = '11111111-1111-1111-1111-111111111111' - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-export-empty.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-export-empty.psd1' -Content @' @{ Name = 'Joiner - Export Fixture Empty' LifecycleEvent = 'Joiner' @@ -76,19 +62,17 @@ Describe 'Export-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest ` - -LifecycleEvent 'Joiner' ` - -CorrelationId $cid ` - -IdentityKeys ([ordered]@{ userId = 'jdoe' }) + $req = New-IdleTestRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } $outFile = Join-Path $TestDrive 'plan.json' - # Act $null = $plan | Export-IdlePlan -Path $outFile - # Assert Test-Path -LiteralPath $outFile | Should -BeTrue $content = Get-Content -Path $outFile -Raw -Encoding utf8 @@ -97,13 +81,10 @@ Describe 'Export-IdlePlan' { } Context 'Contract invariants' { - It 'always includes schemaVersion 1.0' { - # Arrange $cid = '11111111-1111-1111-1111-111111111111' - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-export-empty.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-export-empty.psd1' -Content @' @{ Name = 'Joiner - Export Fixture Empty' LifecycleEvent = 'Joiner' @@ -111,17 +92,15 @@ Describe 'Export-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest ` - -LifecycleEvent 'Joiner' ` - -CorrelationId $cid ` - -IdentityKeys ([ordered]@{ userId = 'jdoe' }) + $req = New-IdleTestRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } - # Act $json = $plan | Export-IdlePlan | ConvertFrom-Json - # Assert $json.schemaVersion | Should -Be '1.0' } } diff --git a/tests/Core/Get-IdleProviderCapabilities.Tests.ps1 b/tests/Core/Get-IdleProviderCapabilities.Tests.ps1 index 3c995126..47f020c5 100644 --- a/tests/Core/Get-IdleProviderCapabilities.Tests.ps1 +++ b/tests/Core/Get-IdleProviderCapabilities.Tests.ps1 @@ -14,95 +14,94 @@ Describe 'IdLE.Core - Get-IdleProviderCapabilities (provider capability discover Get-Command Get-IdleProviderCapabilities -ErrorAction Stop | Out-Null } - It 'returns explicitly advertised capabilities (sorted and unique)' { - $provider = [pscustomobject]@{ - Name = 'TestProvider' - } - - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @( + Context 'Explicit advertisement' { + It 'returns explicitly advertised capabilities (sorted and unique)' { + $provider = [pscustomobject]@{ + Name = 'TestProvider' + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @( + 'IdLE.Identity.Disable' + 'IdLE.Identity.Read' + 'IdLE.Identity.Read' # duplicate on purpose + 'IdLE.Identity.Attribute.Ensure' + ) + } -Force + + $caps = Get-IdleProviderCapabilities -Provider $provider + + $caps | Should -Be @( + 'IdLE.Identity.Attribute.Ensure' 'IdLE.Identity.Disable' 'IdLE.Identity.Read' - 'IdLE.Identity.Read' # duplicate on purpose - 'IdLE.Identity.Attribute.Ensure' ) - } -Force + } - $caps = Get-IdleProviderCapabilities -Provider $provider + It 'throws when provider advertises invalid capability identifiers' { + $provider = [pscustomobject]@{ + Name = 'BadProvider' + } - $caps | Should -Be @( - 'IdLE.Identity.Attribute.Ensure' - 'IdLE.Identity.Disable' - 'IdLE.Identity.Read' - ) - } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @( + 'Identity Read' # whitespace => invalid + ) + } -Force - It 'throws when provider advertises invalid capability identifiers' { - $provider = [pscustomobject]@{ - Name = 'BadProvider' + { Get-IdleProviderCapabilities -Provider $provider } | Should -Throw } - - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @( - 'Identity Read' # whitespace => invalid - ) - } -Force - - { Get-IdleProviderCapabilities -Provider $provider } | Should -Throw } - It 'returns an empty list when no GetCapabilities exists and inference is disabled' { - $provider = [pscustomobject]@{ - Name = 'LegacyProvider' - } + Context 'Inference' { + It 'returns an empty list when no GetCapabilities exists and inference is disabled' { + $provider = [pscustomobject]@{ + Name = 'LegacyProvider' + } - # No GetCapabilities method here. + $caps = Get-IdleProviderCapabilities -Provider $provider + @($caps).Count | Should -Be 0 + } - $caps = Get-IdleProviderCapabilities -Provider $provider - @($caps).Count | Should -Be 0 - } + It 'can infer minimal capabilities when inference is enabled' { + $provider = [pscustomobject]@{ + Name = 'LegacyProvider' + } - It 'can infer minimal capabilities when inference is enabled' { - $provider = [pscustomobject]@{ - Name = 'LegacyProvider' - } + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param([string] $IdentityKey) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { param([string] $IdentityKey, [string] $Name, [object] $Value) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { param([string] $IdentityKey) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { param([string] $IdentityKey) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { param([string] $IdentityKey, [object] $Entitlement) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { param([string] $IdentityKey, [object] $Entitlement) } -Force - # Simulate a legacy provider by adding known methods. - $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param([string] $IdentityKey) } -Force - $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { param([string] $IdentityKey, [string] $Name, [object] $Value) } -Force - $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { param([string] $IdentityKey) } -Force - $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { param([string] $IdentityKey) } -Force - $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { param([string] $IdentityKey, [object] $Entitlement) } -Force - $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { param([string] $IdentityKey, [object] $Entitlement) } -Force - - $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference - - $caps | Should -Be @( - 'IdLE.Entitlement.Grant' - 'IdLE.Entitlement.List' - 'IdLE.Entitlement.Revoke' - 'IdLE.Identity.Attribute.Ensure' - 'IdLE.Identity.Disable' - 'IdLE.Identity.Read' - ) - } + $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference - It 'prefers explicit advertisement over inference when both are available' { - $provider = [pscustomobject]@{ - Name = 'HybridProvider' + $caps | Should -Be @( + 'IdLE.Entitlement.Grant' + 'IdLE.Entitlement.List' + 'IdLE.Entitlement.Revoke' + 'IdLE.Identity.Attribute.Ensure' + 'IdLE.Identity.Disable' + 'IdLE.Identity.Read' + ) } - # Add legacy methods (would be inferred) - $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param([string] $IdentityKey) } -Force + It 'prefers explicit advertisement over inference when both are available' { + $provider = [pscustomobject]@{ + Name = 'HybridProvider' + } - # Also add explicit GetCapabilities (must win) - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Read') - } -Force + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param([string] $IdentityKey) } -Force - $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Read') + } -Force - $caps | Should -Be @('IdLE.Identity.Read') + $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference + + $caps | Should -Be @('IdLE.Identity.Read') + } } } } diff --git a/tests/Core/ImportIdLE.Tests.ps1 b/tests/Core/ImportIdLE.Tests.ps1 index f64f4e89..b12946f1 100644 --- a/tests/Core/ImportIdLE.Tests.ps1 +++ b/tests/Core/ImportIdLE.Tests.ps1 @@ -2,36 +2,34 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') - $repoRoot = Get-RepoRootPath - $importScript = Join-Path -Path $repoRoot -ChildPath 'tools/import-idle.ps1' + $script:RepoRoot = Get-RepoRootPath + $script:ImportScript = Join-Path -Path $script:RepoRoot -ChildPath 'tools/import-idle.ps1' } Describe 'Import-IdLE helper script' { - It 'import-idle.ps1 script exists' { - $importScript | Should -Exist - } - - It 'import-idle.ps1 finds workflows in subdirectories' { - # The script should find workflows in examples/workflows/mock and templates subdirectories - # This test validates that the script can discover workflows after the directory restructuring + Context 'Script discovery' { + It 'import-idle.ps1 script exists' { + $script:ImportScript | Should -Exist + } - $workflowDir = Join-Path -Path $repoRoot -ChildPath 'examples/workflows' + It 'import-idle.ps1 finds workflows in subdirectories' { + $workflowDir = Join-Path -Path $script:RepoRoot -ChildPath 'examples/workflows' - # Verify workflows exist in subdirectories - $mockWorkflows = Get-ChildItem -Path (Join-Path $workflowDir 'mock') -Filter '*.psd1' -File -ErrorAction SilentlyContinue - $templateWorkflows = Get-ChildItem -Path (Join-Path $workflowDir 'templates') -Filter '*.psd1' -File -ErrorAction SilentlyContinue + $mockWorkflows = Get-ChildItem -Path (Join-Path $workflowDir 'mock') -Filter '*.psd1' -File -ErrorAction SilentlyContinue + $templateWorkflows = Get-ChildItem -Path (Join-Path $workflowDir 'templates') -Filter '*.psd1' -File -ErrorAction SilentlyContinue - $mockWorkflows | Should -Not -BeNullOrEmpty - $templateWorkflows | Should -Not -BeNullOrEmpty + $mockWorkflows | Should -Not -BeNullOrEmpty + $templateWorkflows | Should -Not -BeNullOrEmpty - # Verify the script logic for finding workflows recursively - $allWorkflows = Get-ChildItem -Path $workflowDir -Filter '*.psd1' -File -Recurse - $allWorkflows | Should -Not -BeNullOrEmpty - $allWorkflows.Count | Should -BeGreaterThan 5 + $allWorkflows = Get-ChildItem -Path $workflowDir -Filter '*.psd1' -File -Recurse + $allWorkflows | Should -Not -BeNullOrEmpty + $allWorkflows.Count | Should -BeGreaterThan 5 + } } - It 'import-idle.ps1 executes without errors' { - # Execute the import script and verify it completes successfully - { & $importScript -ErrorAction Stop } | Should -Not -Throw + Context 'Execution' { + It 'import-idle.ps1 executes without errors' { + { & $script:ImportScript -ErrorAction Stop } | Should -Not -Throw + } } } diff --git a/tests/Core/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Condition.Tests.ps1 index e2ded0c5..329ca9f2 100644 --- a/tests/Core/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Condition.Tests.ps1 @@ -1,9 +1,9 @@ +Set-StrictMode -Version Latest + BeforeDiscovery { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule -} -BeforeAll { function global:Invoke-IdleConditionTestEmitStep { [CmdletBinding()] param( @@ -29,15 +29,15 @@ BeforeAll { } AfterAll { - # Cleanup global test functions to avoid polluting the session. Remove-Item -Path 'Function:\Invoke-IdleConditionTestEmitStep' -ErrorAction SilentlyContinue } -InModuleScope IdLE.Core { - Describe 'Invoke-IdlePlan - Condition applicability' { - It 'does not execute a step when plan marks it as NotApplicable' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' +InModuleScope 'IdLE.Core' { + Describe 'Invoke-IdlePlan - Condition applicability' { + Context 'Condition evaluation' { + It 'does not execute a step when plan marks it as NotApplicable' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Condition Demo' LifecycleEvent = 'Joiner' @@ -56,26 +56,22 @@ InModuleScope IdLE.Core { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' - } - } + $providers = @{ StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'NotApplicable' - @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 - @($result.Events | Where-Object Type -eq 'StepNotApplicable').Count | Should -Be 1 - } + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'NotApplicable' + @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'StepNotApplicable').Count | Should -Be 1 + } - It 'runs a step when condition is met' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition2.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'runs a step when condition is met' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition2.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Condition Demo' LifecycleEvent = 'Joiner' @@ -94,20 +90,17 @@ InModuleScope IdLE.Core { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' - } - } + $providers = @{ StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' - ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } + } } - } -} \ No newline at end of file +} diff --git a/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 index 1780e3f0..0d26acbf 100644 --- a/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 @@ -1,13 +1,9 @@ -BeforeDiscovery { - . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') - Import-IdleTestModule -} +Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - # Create a dedicated test module for retry profile testing $script:RetryProfileTestModuleName = 'IdLE.RetryProfileTest' $script:RetryProfileTestModule = New-Module -Name $script:RetryProfileTestModuleName -ScriptBlock { Set-StrictMode -Version Latest @@ -31,7 +27,6 @@ BeforeAll { Timestamp = [DateTimeOffset]::UtcNow }) - # Succeed on first attempt return [pscustomobject]@{ PSTypeName = 'IdLE.StepResult' Name = [string]$Step.Name @@ -62,7 +57,6 @@ BeforeAll { Timestamp = [DateTimeOffset]::UtcNow }) - # Fail on first attempt, succeed on second if ($attempts -eq 1) { $ex = [System.Exception]::new("Transient failure for $stepName") $ex.Data['Idle.IsTransient'] = $true @@ -107,14 +101,10 @@ AfterAll { Remove-Module -Name $script:RetryProfileTestModuleName -Force -ErrorAction SilentlyContinue } -Describe 'Invoke-IdlePlan - ExecutionOptions validation' { - - It 'rejects ExecutionOptions with invalid type' -Skip { - # Note: PowerShell parameter type validation catches this before our validation, - # so this test is skipped. The validation still exists and would catch it if - # the parameter type was changed to [object]. - $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' +Describe 'Invoke-IdlePlan - ExecutionOptions' { + Context 'Validation' { + It 'rejects ExecutionOptions with invalid type (parameter binding)' { + $wfPath = New-IdleTestWorkflowFile -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -122,15 +112,14 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - { Invoke-IdlePlan -Plan $plan -ExecutionOptions 'invalid' } | Should -Throw -ExpectedMessage '*must be a hashtable or IDictionary*' - } + { Invoke-IdlePlan -Plan $plan -ExecutionOptions 'invalid' } | Should -Throw -ExpectedMessage '*Cannot convert*Hashtable*' + } - It 'rejects ExecutionOptions with ScriptBlocks' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'rejects ExecutionOptions with ScriptBlocks' { + $wfPath = New-IdleTestWorkflowFile -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -138,20 +127,17 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $providers = @{ StepRegistry = @{} } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $opts = @{ - SomeKey = { Write-Host 'test' } - } + $opts = @{ SomeKey = { Write-Host 'test' } } - { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' - } + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' + } - It 'rejects RetryProfile with invalid MaxAttempts' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'rejects RetryProfile with invalid MaxAttempts' { + $wfPath = New-IdleTestWorkflowFile -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -159,22 +145,17 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $providers = @{ StepRegistry = @{} } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $opts = @{ - RetryProfiles = @{ - Invalid = @{ MaxAttempts = 50 } - } - } + $opts = @{ RetryProfiles = @{ Invalid = @{ MaxAttempts = 50 } } } - { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxAttempts must be an integer between 0 and 10*' - } + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxAttempts must be an integer between 0 and 10*' + } - It 'rejects DefaultRetryProfile that does not exist' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'rejects DefaultRetryProfile that does not exist' { + $wfPath = New-IdleTestWorkflowFile -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -182,23 +163,20 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $providers = @{ StepRegistry = @{} } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $opts = @{ - RetryProfiles = @{ - Default = @{ MaxAttempts = 3 } + $opts = @{ + RetryProfiles = @{ Default = @{ MaxAttempts = 3 } } + DefaultRetryProfile = 'Unknown' } - DefaultRetryProfile = 'Unknown' - } - { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*does not exist*' - } + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*does not exist*' + } - It 'rejects MaxDelayMilliseconds less than engine default InitialDelayMilliseconds' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'test.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'rejects MaxDelayMilliseconds less than engine default InitialDelayMilliseconds' { + $wfPath = New-IdleTestWorkflowFile -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -206,31 +184,27 @@ Describe 'Invoke-IdlePlan - ExecutionOptions validation' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $providers = @{ StepRegistry = @{} } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ StepRegistry = @{} } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $opts = @{ - RetryProfiles = @{ - Invalid = @{ - MaxDelayMilliseconds = 100 # Less than engine default InitialDelayMilliseconds (250) + $opts = @{ + RetryProfiles = @{ + Invalid = @{ MaxDelayMilliseconds = 100 } } } - } - { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxDelayMilliseconds*must be >= InitialDelayMilliseconds*' + { Invoke-IdlePlan -Plan $plan -ExecutionOptions $opts } | Should -Throw -ExpectedMessage '*MaxDelayMilliseconds*must be >= InitialDelayMilliseconds*' + } } -} - -Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { - BeforeEach { - & "$script:RetryProfileTestModuleName\Reset-IdleRetryProfileTestState" - } + Context 'Retry profiles' { + BeforeEach { + & "$script:RetryProfileTestModuleName\Reset-IdleRetryProfileTestState" + } - It 'executes successfully without ExecutionOptions (backward compatibility)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'no-opts.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'executes successfully without ExecutionOptions (backward compatibility)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'no-opts.psd1' -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -240,27 +214,24 @@ Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'Test.Step' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTestStep" + $providers = @{ + StepRegistry = @{ 'Test.Step' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTestStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Step') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Step') - } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' - } + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + } - It 'executes with custom RetryProfile on step' { - Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + It 'executes with custom RetryProfile on step' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } - $wfPath = Join-Path -Path $TestDrive -ChildPath 'custom-profile.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'custom-profile.psd1' -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -270,40 +241,32 @@ Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + $providers = @{ + StepRegistry = @{ 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') - } - $opts = @{ - RetryProfiles = @{ - Custom = @{ - MaxAttempts = 5 - InitialDelayMilliseconds = 100 + $opts = @{ + RetryProfiles = @{ + Custom = @{ MaxAttempts = 5; InitialDelayMilliseconds = 100 } } } - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' - $result.Steps[0].Attempts | Should -Be 2 + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts - # Verify retry event was emitted - @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 1 - } + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + $result.Steps[0].Attempts | Should -Be 2 + @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 1 + } - It 'uses DefaultRetryProfile when step does not specify RetryProfile' { - Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + It 'uses DefaultRetryProfile when step does not specify RetryProfile' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } - $wfPath = Join-Path -Path $TestDrive -ChildPath 'default-profile.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'default-profile.psd1' -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -313,35 +276,27 @@ Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + $providers = @{ + StepRegistry = @{ 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') - } - $opts = @{ - RetryProfiles = @{ - Default = @{ - MaxAttempts = 10 - InitialDelayMilliseconds = 50 - } + $opts = @{ + RetryProfiles = @{ Default = @{ MaxAttempts = 10; InitialDelayMilliseconds = 50 } } + DefaultRetryProfile = 'Default' } - DefaultRetryProfile = 'Default' - } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts - $result.Status | Should -Be 'Completed' - $result.Steps[0].Attempts | Should -Be 2 - } + $result.Status | Should -Be 'Completed' + $result.Steps[0].Attempts | Should -Be 2 + } - It 'fails fast when step references unknown RetryProfile' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'unknown-profile.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'fails fast when step references unknown RetryProfile' { + $wfPath = New-IdleTestWorkflowFile -FileName 'unknown-profile.psd1' -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -351,35 +306,27 @@ Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'Test.Step' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTestStep" + $providers = @{ + StepRegistry = @{ 'Test.Step' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTestStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Step') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Step') - } - $opts = @{ - RetryProfiles = @{ - Default = @{ MaxAttempts = 3 } - } - } + $opts = @{ RetryProfiles = @{ Default = @{ MaxAttempts = 3 } } } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts - # The error should be caught and reported in the result - $result.Status | Should -Be 'Failed' - $result.Steps[0].Status | Should -Be 'Failed' - $result.Steps[0].Error | Should -Match 'unknown RetryProfile.*Unknown' - } + $result.Status | Should -Be 'Failed' + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Match 'unknown RetryProfile.*Unknown' + } - It 'supports MaxAttempts = 0 (no retry)' { - Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + It 'supports MaxAttempts = 0 (no retry)' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } - $wfPath = Join-Path -Path $TestDrive -ChildPath 'no-retry.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'no-retry.psd1' -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -389,63 +336,50 @@ Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + $providers = @{ + StepRegistry = @{ 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.TransientStep') - } - $opts = @{ - RetryProfiles = @{ - NoRetry = @{ - MaxAttempts = 0 - } - } + $opts = @{ RetryProfiles = @{ NoRetry = @{ MaxAttempts = 0 } } } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + + $result.Status | Should -Be 'Failed' + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Attempts | Should -Be 1 + @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 0 + Should -Invoke -ModuleName IdLE.Core -CommandName Start-Sleep -Times 0 } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + It 'applies RetryProfile to OnFailureSteps' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } - # With MaxAttempts = 0, the step runs once and fails without retry - $result.Status | Should -Be 'Failed' - $result.Steps[0].Status | Should -Be 'Failed' - $result.Steps[0].Attempts | Should -Be 1 + $failingModuleName = 'IdLE.FailingTest' + $failingModule = New-Module -Name $failingModuleName -ScriptBlock { + function Invoke-IdleFailingStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, - # No retry event should be emitted - @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 0 - Should -Invoke -ModuleName IdLE.Core -CommandName Start-Sleep -Times 0 - } + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) - It 'applies RetryProfile to OnFailureSteps' { - Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } - - # Create a module with a failing step - $failingModuleName = 'IdLE.FailingTest' - $failingModule = New-Module -Name $failingModuleName -ScriptBlock { - function Invoke-IdleFailingStep { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Context, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Step - ) - - throw [System.Exception]::new('Intentional failure for test') - } + throw [System.Exception]::new('Intentional failure for test') + } - Export-ModuleMember -Function 'Invoke-IdleFailingStep' - } - Import-Module -ModuleInfo $failingModule -Force -ErrorAction Stop + Export-ModuleMember -Function 'Invoke-IdleFailingStep' + } + Import-Module -ModuleInfo $failingModule -Force -ErrorAction Stop - $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-profile.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'onfailure-profile.psd1' -Content @' @{ Name = 'Test Workflow' LifecycleEvent = 'Joiner' @@ -458,32 +392,28 @@ Describe 'Invoke-IdlePlan - ExecutionOptions with RetryProfiles' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'Test.Failing' = "$failingModuleName\Invoke-IdleFailingStep" - 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + $providers = @{ + StepRegistry = @{ + 'Test.Failing' = "$failingModuleName\Invoke-IdleFailingStep" + 'Test.TransientStep' = "$script:RetryProfileTestModuleName\Invoke-IdleRetryProfileTransientStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Failing', 'Test.TransientStep') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.Failing', 'Test.TransientStep') - } - $opts = @{ - RetryProfiles = @{ - Cleanup = @{ - MaxAttempts = 3 - InitialDelayMilliseconds = 100 - } + $opts = @{ + RetryProfiles = @{ Cleanup = @{ MaxAttempts = 3; InitialDelayMilliseconds = 100 } } } - } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $opts - $result.Status | Should -Be 'Failed' - $result.OnFailure.Status | Should -Be 'Completed' - $result.OnFailure.Steps[0].Attempts | Should -Be 2 + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'Completed' + $result.OnFailure.Steps[0].Attempts | Should -Be 2 - Remove-Module -Name $failingModuleName -Force -ErrorAction SilentlyContinue + Remove-Module -Name $failingModuleName -Force -ErrorAction SilentlyContinue + } } } diff --git a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 index acda9970..83328af9 100644 --- a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 @@ -5,7 +5,6 @@ BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule Import-IdleTestMailboxModule - } AfterAll { @@ -13,54 +12,50 @@ AfterAll { } Describe 'Mailbox OutOfOffice step - template resolution' { - - BeforeEach { - # Create mock ExchangeOnline provider - $script:Provider = [pscustomobject]@{ - PSTypeName = 'Mock.ExchangeOnlineProvider' - Store = @{} - } - - $script:Provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') - } -Force - - $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { - param($IdentityKey, $Config, $AuthSession) - - if (-not $this.Store.ContainsKey($IdentityKey)) { - $this.Store[$IdentityKey] = @{ - OOFMode = 'Disabled' - OOFInternalMessage = '' - OOFExternalMessage = '' - } + Context 'Template resolution' { + BeforeEach { + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} } - - $mailbox = $this.Store[$IdentityKey] - - # Store the config for test validation - $mailbox['OOFMode'] = $Config['Mode'] - $mailbox['OOFInternalMessage'] = if ($Config.ContainsKey('InternalMessage')) { $Config['InternalMessage'] } else { '' } - $mailbox['OOFExternalMessage'] = if ($Config.ContainsKey('ExternalMessage')) { $Config['ExternalMessage'] } else { '' } - $mailbox['OOFExternalAudience'] = if ($Config.ContainsKey('ExternalAudience')) { $Config['ExternalAudience'] } else { '' } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'EnsureOutOfOffice' - IdentityKey = $IdentityKey - Changed = $true - } - } -Force - - # Create mock AuthSessionBroker - $script:AuthBroker = New-IdleAuthSessionBroker ` - -AuthSessionType 'OAuth' ` - -DefaultAuthSession 'mock-token-string' - } - - It 'resolves template variables in InternalMessage and ExternalMessage' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'oof-with-templates.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + + $script:Provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') + } -Force + + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { + param($IdentityKey, $Config, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + OOFMode = 'Disabled' + OOFInternalMessage = '' + OOFExternalMessage = '' + } + } + + $mailbox = $this.Store[$IdentityKey] + + $mailbox['OOFMode'] = $Config['Mode'] + $mailbox['OOFInternalMessage'] = if ($Config.ContainsKey('InternalMessage')) { $Config['InternalMessage'] } else { '' } + $mailbox['OOFExternalMessage'] = if ($Config.ContainsKey('ExternalMessage')) { $Config['ExternalMessage'] } else { '' } + $mailbox['OOFExternalAudience'] = if ($Config.ContainsKey('ExternalAudience')) { $Config['ExternalAudience'] } else { '' } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureOutOfOffice' + IdentityKey = $IdentityKey + Changed = $true + } + } -Force + + $script:AuthBroker = New-IdleAuthSessionBroker ` + -AuthSessionType 'OAuth' ` + -DefaultAuthSession 'mock-token-string' + } + + It 'resolves template variables in InternalMessage and ExternalMessage' { + $wfPath = New-IdleTestWorkflowFile -FileName 'oof-with-templates.psd1' -Content @' @{ Name = 'OOF with Templates' LifecycleEvent = 'Leaver' @@ -72,9 +67,9 @@ Describe 'Mailbox OutOfOffice step - template resolution' { Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' Config = @{ - Mode = 'Enabled' - InternalMessage = 'Please contact {{Request.DesiredState.Manager.DisplayName}} at {{Request.DesiredState.Manager.Mail}}.' - ExternalMessage = 'Please contact {{Request.DesiredState.Manager.Mail}}.' + Mode = 'Enabled' + InternalMessage = 'Please contact {{Request.DesiredState.Manager.DisplayName}} at {{Request.DesiredState.Manager.Mail}}.' + ExternalMessage = 'Please contact {{Request.DesiredState.Manager.Mail}}.' ExternalAudience = 'All' } } @@ -83,44 +78,37 @@ Describe 'Mailbox OutOfOffice step - template resolution' { } '@ - $req = New-IdleLifecycleRequest ` - -LifecycleEvent 'Leaver' ` - -Actor 'admin@contoso.com' ` - -DesiredState @{ - Manager = @{ - DisplayName = 'Jane Smith' - Mail = 'jane.smith@contoso.com' + $req = New-IdleTestRequest ` + -LifecycleEvent 'Leaver' ` + -Actor 'admin@contoso.com' ` + -DesiredState @{ + Manager = @{ + DisplayName = 'Jane Smith' + Mail = 'jane.smith@contoso.com' + } } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ ExchangeOnline = $script:Provider } + $plan | Should -Not -BeNullOrEmpty + + $plan.Steps[0].With.Config.InternalMessage | Should -Be 'Please contact Jane Smith at jane.smith@contoso.com.' + $plan.Steps[0].With.Config.ExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' + + $providers = @{ + ExchangeOnline = $script:Provider + AuthSessionBroker = $script:AuthBroker } - - # Plan creation doesn't need providers with brokers (they contain ScriptBlocks) - # Pass providers only to Invoke-IdlePlan - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ - ExchangeOnline = $script:Provider - } - $plan | Should -Not -BeNullOrEmpty - - # Verify templates were resolved in the plan - $plan.Steps[0].With.Config.InternalMessage | Should -Be 'Please contact Jane Smith at jane.smith@contoso.com.' - $plan.Steps[0].With.Config.ExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' - - # Execute and verify provider received resolved values - # AuthSessionBroker is passed here for execution - $providers = @{ - ExchangeOnline = $script:Provider - AuthSessionBroker = $script:AuthBroker + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result.Status | Should -Be 'Completed' + + $mailbox = $script:Provider.Store['user@contoso.com'] + $mailbox.OOFInternalMessage | Should -Be 'Please contact Jane Smith at jane.smith@contoso.com.' + $mailbox.OOFExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' } - $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - - $mailbox = $script:Provider.Store['user@contoso.com'] - $mailbox.OOFInternalMessage | Should -Be 'Please contact Jane Smith at jane.smith@contoso.com.' - $mailbox.OOFExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' - } - - It 'resolves nested template variables from Request.DesiredState' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'oof-nested-templates.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + + It 'resolves nested template variables from Request.DesiredState' { + $wfPath = New-IdleTestWorkflowFile -FileName 'oof-nested-templates.psd1' -Content @' @{ Name = 'OOF with Nested Templates' LifecycleEvent = 'Leaver' @@ -142,28 +130,24 @@ Describe 'Mailbox OutOfOffice step - template resolution' { } '@ - $req = New-IdleLifecycleRequest ` - -LifecycleEvent 'Leaver' ` - -Actor 'admin@contoso.com' ` - -DesiredState @{ - Handover = @{ - Name = 'Bob Johnson' - Email = 'bob.johnson@contoso.com' + $req = New-IdleTestRequest ` + -LifecycleEvent 'Leaver' ` + -Actor 'admin@contoso.com' ` + -DesiredState @{ + Handover = @{ + Name = 'Bob Johnson' + Email = 'bob.johnson@contoso.com' + } } - } - - # Plan creation doesn't need providers with brokers (they contain ScriptBlocks) - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ - ExchangeOnline = $script:Provider + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ ExchangeOnline = $script:Provider } + + $plan.Steps[0].With.Config.InternalMessage | Should -Be 'User has left. Contact: Bob Johnson (bob.johnson@contoso.com)' + $plan.Steps[0].With.Config.ExternalMessage | Should -Be 'For assistance: bob.johnson@contoso.com' } - - $plan.Steps[0].With.Config.InternalMessage | Should -Be 'User has left. Contact: Bob Johnson (bob.johnson@contoso.com)' - $plan.Steps[0].With.Config.ExternalMessage | Should -Be 'For assistance: bob.johnson@contoso.com' - } - - It 'works with new step type naming and templates' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'oof-new-naming-templates.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + + It 'works with new step type naming and templates' { + $wfPath = New-IdleTestWorkflowFile -FileName 'oof-new-naming-templates.psd1' -Content @' @{ Name = 'OOF New Naming with Templates' LifecycleEvent = 'Leaver' @@ -185,32 +169,30 @@ Describe 'Mailbox OutOfOffice step - template resolution' { } '@ - $req = New-IdleLifecycleRequest ` - -LifecycleEvent 'Leaver' ` - -Actor 'admin@contoso.com' ` - -DesiredState @{ - TeamLead = @{ - Name = 'Alice Brown' - Email = 'alice.brown@contoso.com' + $req = New-IdleTestRequest ` + -LifecycleEvent 'Leaver' ` + -Actor 'admin@contoso.com' ` + -DesiredState @{ + TeamLead = @{ + Name = 'Alice Brown' + Email = 'alice.brown@contoso.com' + } } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ ExchangeOnline = $script:Provider } + $plan.Steps[0].Type | Should -Be 'IdLE.Step.Mailbox.EnsureOutOfOffice' + $plan.Steps[0].With.Config.InternalMessage | Should -Be 'Contact Alice Brown' + + $providers = @{ + ExchangeOnline = $script:Provider + AuthSessionBroker = $script:AuthBroker } - - # Plan creation doesn't need providers with brokers (they contain ScriptBlocks) - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ - ExchangeOnline = $script:Provider - } - $plan.Steps[0].Type | Should -Be 'IdLE.Step.Mailbox.EnsureOutOfOffice' - $plan.Steps[0].With.Config.InternalMessage | Should -Be 'Contact Alice Brown' - - # Execute with full providers including broker - $providers = @{ - ExchangeOnline = $script:Provider - AuthSessionBroker = $script:AuthBroker + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result.Status | Should -Be 'Completed' + + $mailbox = $script:Provider.Store['user@contoso.com'] + $mailbox.OOFInternalMessage | Should -Be 'Contact Alice Brown' } - $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - - $mailbox = $script:Provider.Store['user@contoso.com'] - $mailbox.OOFInternalMessage | Should -Be 'Contact Alice Brown' } } diff --git a/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 b/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 index 24d69fdb..9759af47 100644 --- a/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 @@ -1,8 +1,9 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - # Test step handler for provider fallback tests function global:Invoke-IdleTestProviderFallbackStep { [CmdletBinding()] param( @@ -15,9 +16,8 @@ BeforeAll { [object] $Step ) - # Verify Context.Providers is not null (reproduces the original failure scenario) if ($null -eq $Context.Providers) { - throw "Context.Providers must be a hashtable." + throw 'Context.Providers must be a hashtable.' } return [pscustomobject]@{ @@ -35,237 +35,111 @@ AfterAll { } Describe 'Invoke-IdlePlan Provider Fallback' { - It 'uses Plan.Providers when -Providers is not supplied' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + BeforeEach { + $script:WorkflowPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' @{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } - ) + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } + ) } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $script:Request = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ + $script:BaseProviders = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') } - - # Build plan with providers - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - # Execute without passing -Providers (should use Plan.Providers) - $result = Invoke-IdlePlan -Plan $plan - - $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' } - It 'explicit -Providers overrides Plan.Providers' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } - ) -} -'@ + Context 'Provider selection' { + It 'uses Plan.Providers when -Providers is not supplied' { + $plan = New-IdlePlan -WorkflowPath $script:WorkflowPath -Request $script:Request -Providers $script:BaseProviders - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $result = Invoke-IdlePlan -Plan $plan - $planProviders = @{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - TestMarker = 'PlanProviders' + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' } - $explicitProviders = @{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - TestMarker = 'ExplicitProviders' - } + It 'explicit -Providers overrides Plan.Providers' { + $planProviders = $script:BaseProviders.Clone() + $planProviders.TestMarker = 'PlanProviders' - # Build plan with planProviders - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $planProviders + $explicitProviders = $script:BaseProviders.Clone() + $explicitProviders.TestMarker = 'ExplicitProviders' - # Execute with explicit providers (should override Plan.Providers) - $result = Invoke-IdlePlan -Plan $plan -Providers $explicitProviders + $plan = New-IdlePlan -WorkflowPath $script:WorkflowPath -Request $script:Request -Providers $planProviders - $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' - $result.Status | Should -Be 'Completed' - # Verify that explicitProviders were used (check redacted providers) - $result.Providers.TestMarker | Should -Be 'ExplicitProviders' - } + $result = Invoke-IdlePlan -Plan $plan -Providers $explicitProviders - It 'fails with clear error when neither -Providers nor Plan.Providers exist' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } - ) -} -'@ + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + $result.Providers.TestMarker | Should -Be 'ExplicitProviders' + } - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + It 'fails with clear error when neither -Providers nor Plan.Providers exist' { + $plan = New-IdlePlan -WorkflowPath $script:WorkflowPath -Request $script:Request -Providers $script:BaseProviders + $plan.PSObject.Properties.Remove('Providers') - # Build plan with providers, then remove Providers property to simulate exported plan scenario - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + { Invoke-IdlePlan -Plan $plan } | Should -Throw '*Providers are required*' } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - # Remove Providers property to simulate an exported plan without provider objects - $plan.PSObject.Properties.Remove('Providers') + It 'uses Plan.Providers when it is a PSCustomObject' { + $providersObject = [pscustomobject]$script:BaseProviders - # Execute without -Providers and without Plan.Providers - { Invoke-IdlePlan -Plan $plan } | Should -Throw '*Providers are required*' - } + $plan = New-IdlePlan -WorkflowPath $script:WorkflowPath -Request $script:Request -Providers $providersObject - It 'regression: does not fail with "Context.Providers must be a hashtable" when using Plan.Providers' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } - ) -} -'@ - - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $result = Invoke-IdlePlan -Plan $plan - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' } + } - # Build plan with providers - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + Context 'Regression coverage' { + It 'does not fail with "Context.Providers must be a hashtable" when using Plan.Providers' { + $plan = New-IdlePlan -WorkflowPath $script:WorkflowPath -Request $script:Request -Providers $script:BaseProviders - # Execute without passing -Providers (should NOT throw "Context.Providers must be a hashtable") - { Invoke-IdlePlan -Plan $plan } | Should -Not -Throw + { Invoke-IdlePlan -Plan $plan } | Should -Not -Throw + } } - It 'applies security validations to Plan.Providers' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } - ) -} -'@ + Context 'Security validation' { + It 'applies security validations to Plan.Providers' { + $providers = $script:BaseProviders.Clone() + $providers.MaliciousCode = { Write-Host 'Malicious' } - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $script:WorkflowPath -Request $script:Request -Providers $providers - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - MaliciousCode = { Write-Host "Malicious" } # ScriptBlock should be rejected + { Invoke-IdlePlan -Plan $plan } | Should -Throw } - - # Build plan with providers containing ScriptBlock - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - # Execute without passing -Providers (should still reject ScriptBlocks in Plan.Providers) - { Invoke-IdlePlan -Plan $plan } | Should -Throw } - It 'redacts Plan.Providers in execution result when used as fallback' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } - ) -} -'@ - - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - TestProvider = @{ + Context 'Redaction' { + It 'redacts Plan.Providers in execution result when used as fallback' { + $providers = $script:BaseProviders.Clone() + $providers.TestProvider = @{ endpoint = 'https://example.test' token = 'SecretToken123' apiKey = 'ApiKey456' } - } - - # Build plan with providers - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - # Execute without passing -Providers - $result = Invoke-IdlePlan -Plan $plan - $result.Status | Should -Be 'Completed' - # Providers should be redacted (sensitive keys should have [REDACTED]) - $result.Providers | Should -Not -BeNullOrEmpty - $result.Providers.TestProvider.token | Should -Be '[REDACTED]' - $result.Providers.TestProvider.apiKey | Should -Be '[REDACTED]' - $result.Providers.TestProvider.endpoint | Should -Be 'https://example.test' - } - - It 'uses Plan.Providers when it is a PSCustomObject (not just IDictionary)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'TestStep'; Type = 'IdLE.Step.Test' } - ) -} -'@ + $plan = New-IdlePlan -WorkflowPath $script:WorkflowPath -Request $script:Request -Providers $providers - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $result = Invoke-IdlePlan -Plan $plan - # Create a PSCustomObject-shaped provider registry (not a hashtable) - $providersObject = [pscustomobject]@{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestProviderFallbackStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + $result.Status | Should -Be 'Completed' + $result.Providers | Should -Not -BeNullOrEmpty + $result.Providers.TestProvider.token | Should -Be '[REDACTED]' + $result.Providers.TestProvider.apiKey | Should -Be '[REDACTED]' + $result.Providers.TestProvider.endpoint | Should -Be 'https://example.test' } - - # Build plan with PSCustomObject providers - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providersObject - - # Execute without passing -Providers (should use Plan.Providers even though it's a PSCustomObject) - $result = Invoke-IdlePlan -Plan $plan - - $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' } } diff --git a/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 index 7eaa5cf2..85e954df 100644 --- a/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 @@ -1,12 +1,9 @@ -BeforeDiscovery { - . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') - Import-IdleTestModule -} +Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - + # Create a dedicated, ephemeral test module that exports the step handlers. # This avoids global scope pollution while ensuring the engine can resolve # handler names deterministically via module-qualified command names. @@ -91,16 +88,15 @@ AfterAll { } Describe 'Invoke-IdlePlan - safe retries for transient failures (fail-fast)' { + Context 'Transient errors' { + BeforeEach { + & "$script:RetryTestModuleName\Reset-IdleRetryTestState" + } - BeforeEach { - & "$script:RetryTestModuleName\Reset-IdleRetryTestState" - } - - It 'retries a step when the error is explicitly marked transient and then succeeds' { - Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + It 'retries a step when the error is explicitly marked transient and then succeeds' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } - $wfPath = Join-Path -Path $TestDrive -ChildPath 'retry-transient.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'retry-transient.psd1' -Content @' @{ Name = 'Retry Transient Demo' LifecycleEvent = 'Joiner' @@ -110,33 +106,38 @@ Describe 'Invoke-IdlePlan - safe retries for transient failures (fail-fast)' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Transient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestTransientStep" + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Transient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestTransientStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Transient') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Transient') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 1 - Should -Invoke -ModuleName IdLE.Core -CommandName Start-Sleep -Times 1 -Exactly + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' - (& "$script:RetryTestModuleName\Get-IdleRetryTestTransientCallCount") | Should -Be 2 + @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 1 + Should -Invoke -ModuleName IdLE.Core -CommandName Start-Sleep -Times 1 -Exactly + + (& "$script:RetryTestModuleName\Get-IdleRetryTestTransientCallCount") | Should -Be 2 + } } - It 'fails fast and does not retry when the error is not marked transient' { - Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } + Context 'Non-transient errors' { + BeforeEach { + & "$script:RetryTestModuleName\Reset-IdleRetryTestState" + } + + It 'fails fast and does not retry when the error is not marked transient' { + Mock -ModuleName IdLE.Core -CommandName Start-Sleep -MockWith { } - $wfPath = Join-Path -Path $TestDrive -ChildPath 'retry-nontransient.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = New-IdleTestWorkflowFile -FileName 'retry-nontransient.psd1' -Content @' @{ Name = 'Retry Non-Transient Demo' LifecycleEvent = 'Joiner' @@ -146,21 +147,23 @@ Describe 'Invoke-IdlePlan - safe retries for transient failures (fail-fast)' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.NonTransient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestNonTransientStep" + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.NonTransient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestNonTransientStep" + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NonTransient') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NonTransient') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Failed' - @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 0 - Should -Invoke -ModuleName IdLE.Core -CommandName Start-Sleep -Times 0 + $result.Status | Should -Be 'Failed' + @($result.Events | Where-Object Type -eq 'StepRetrying').Count | Should -Be 0 + Should -Invoke -ModuleName IdLE.Core -CommandName Start-Sleep -Times 0 + } } } + diff --git a/tests/Core/Invoke-IdlePlan.StepRegistry.Tests.ps1 b/tests/Core/Invoke-IdlePlan.StepRegistry.Tests.ps1 index e219767e..931df6d0 100644 --- a/tests/Core/Invoke-IdlePlan.StepRegistry.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.StepRegistry.Tests.ps1 @@ -1,24 +1,23 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - # The meta module (IdLE) does not automatically import optional step packs. - # For this test we explicitly load the built-in steps module so that - # Get-IdleStepRegistry can discover the handler via Get-Command. - $repoRoot = Get-RepoRootPath - $stepsManifestPath = Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Steps.Common/IdLE.Steps.Common.psd1' - Import-Module -Name $stepsManifestPath -Force -ErrorAction Stop + $script:RepoRoot = Get-RepoRootPath + $script:StepsManifestPath = Join-Path -Path $script:RepoRoot -ChildPath 'src/IdLE.Steps.Common/IdLE.Steps.Common.psd1' + + Import-Module -Name $script:StepsManifestPath -Force -ErrorAction Stop } AfterAll { - # Cleanup to avoid influencing other tests that might rely on a clean module state. Remove-Module -Name 'IdLE.Steps.Common' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan - StepRegistry' { - It 'executes built-in steps without a host-provided StepRegistry' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'emit-built-in.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Built-in handlers' { + It 'executes built-in steps without a host-provided StepRegistry' { + $wfPath = New-IdleTestWorkflowFile -FileName 'emit-built-in.psd1' -Content @' @{ Name = 'Demo' LifecycleEvent = 'Joiner' @@ -28,19 +27,15 @@ Describe 'Invoke-IdlePlan - StepRegistry' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - - # Intentionally no Providers.StepRegistry here. - $providers = @{} - - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $result.Status | Should -Be 'Completed' - @($result.Steps).Count | Should -Be 1 - $result.Steps[0].Status | Should -Be 'Completed' + $result = Invoke-IdlePlan -Plan $plan -Providers @{} - # The built-in EmitEvent step emits a Custom event. - ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + $result.Status | Should -Be 'Completed' + @($result.Steps).Count | Should -Be 1 + $result.Steps[0].Status | Should -Be 'Completed' + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } } } diff --git a/tests/Core/Invoke-IdlePlan.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Tests.ps1 index 6d7c0ff8..7bf25a85 100644 --- a/tests/Core/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Tests.ps1 @@ -1,3 +1,5 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule @@ -148,9 +150,9 @@ AfterAll { } Describe 'Invoke-IdlePlan' { - It 'returns an execution result with events in deterministic order' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Execution results' { + It 'returns an execution result with events in deterministic order' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -161,122 +163,58 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - # Create a dummy provider with the required capability for EnsureAttributes - $dummyProvider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.TestDummy' - } - $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Attribute.Ensure') - } - - $providers = @{ - Identity = $dummyProvider - StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $result = Invoke-IdlePlan -Plan $plan -Providers $providers - - $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' - $result.Status | Should -Be 'Completed' - @($result.Steps).Count | Should -Be 2 - - @($result.Events).Count | Should -BeGreaterThan 0 - $result.Events[0].Type | Should -Be 'RunStarted' - $result.Events[-1].Type | Should -Be 'RunCompleted' - - $result.Steps[0].Status | Should -Be 'Completed' - $result.Steps[1].Status | Should -Be 'Completed' - } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - It 'supports -WhatIf and does not execute' { - $plan = [pscustomobject]@{ - CorrelationId = 'test' - Steps = @( - @{ Name = 'A'; Type = 'X' } - ) - } + # Create a dummy provider with the required capability for EnsureAttributes + $dummyProvider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.TestDummy' + } + $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Attribute.Ensure') + } - $result = Invoke-IdlePlan -Plan $plan -WhatIf - $result.Status | Should -Be 'WhatIf' - @($result.Events).Count | Should -Be 0 - } + $providers = @{ + Identity = $dummyProvider + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') + } - It 'can stream events to an object sink with WriteEvent(event)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } - ) -} -'@ + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $sinkEvents = [System.Collections.Generic.List[object]]::new() - $sinkObject = [pscustomobject]@{} - $writeMethod = { - param($e) - [void]$sinkEvents.Add($e) - }.GetNewClosure() - $null = Add-Member -InputObject $sinkObject -MemberType ScriptMethod -Name 'WriteEvent' -Value $writeMethod -Force - - $result = Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sinkObject - - $sinkEvents.Count | Should -BeGreaterThan 0 - $sinkEvents[0].PSTypeNames | Should -Contain 'IdLE.Event' - $result.Events[0].Type | Should -Be 'RunStarted' - } + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - It 'rejects a ScriptBlock -EventSink (security)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } - ) -} -'@ + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + @($result.Steps).Count | Should -Be 2 - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + @($result.Events).Count | Should -BeGreaterThan 0 + $result.Events[0].Type | Should -Be 'RunStarted' + $result.Events[-1].Type | Should -Be 'RunCompleted' + + $result.Steps[0].Status | Should -Be 'Completed' + $result.Steps[1].Status | Should -Be 'Completed' + } - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + It 'supports -WhatIf and does not execute' { + $plan = [pscustomobject]@{ + CorrelationId = 'test' + Steps = @( + @{ Name = 'A'; Type = 'X' } + ) + } - $sink = { param($e) } - { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw + $result = Invoke-IdlePlan -Plan $plan -WhatIf + $result.Status | Should -Be 'WhatIf' + @($result.Events).Count | Should -Be 0 + } } - It 'rejects ScriptBlock step handlers in the StepRegistry (security)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Event sinks' { + It 'can stream events to an object sink with WriteEvent(event)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -286,23 +224,36 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') + } - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = { param($Context, $Step) } - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw + $sinkEvents = [System.Collections.Generic.List[object]]::new() + $sinkObject = [pscustomobject]@{} + $writeMethod = { + param($e) + [void]$sinkEvents.Add($e) + }.GetNewClosure() + $null = Add-Member -InputObject $sinkObject -MemberType ScriptMethod -Name 'WriteEvent' -Value $writeMethod -Force + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sinkObject + + $sinkEvents.Count | Should -BeGreaterThan 0 + $sinkEvents[0].PSTypeNames | Should -Contain 'IdLE.Event' + $result.Events[0].Type | Should -Be 'RunStarted' + } } - It 'executes a registered step and returns Completed status' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'emit.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Step execution' { + It 'executes a registered step and returns Completed status' { + $wfPath = New-IdleTestWorkflowFile -FileName 'emit.psd1' -Content @' @{ Name = 'Demo' LifecycleEvent = 'Joiner' @@ -312,26 +263,27 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleTestEmitStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.EmitEvent') - } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.EmitEvent' = 'Invoke-IdleTestEmitStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.EmitEvent') + } - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' - ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } } - It 'executes OnFailureSteps when a step fails (best effort)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'OnFailure behavior' { + It 'executes OnFailureSteps when a step fails (best effort)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'onfailure.psd1' -Content @' @{ Name = 'Demo - OnFailure' LifecycleEvent = 'Joiner' @@ -345,43 +297,42 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' - 'IdLE.Step.NeverRuns' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.NeverRuns' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.NeverRuns', 'IdLE.Step.OnFailure1') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.NeverRuns', 'IdLE.Step.OnFailure1') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result.Status | Should -Be 'Failed' - @($result.Steps).Count | Should -Be 1 - $result.Steps[0].Name | Should -Be 'FailPrimary' + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' - $result.OnFailure.Status | Should -Be 'Completed' - @($result.OnFailure.Steps).Count | Should -Be 1 - $result.OnFailure.Steps[0].Status | Should -Be 'Completed' + $result.Status | Should -Be 'Failed' + @($result.Steps).Count | Should -Be 1 + $result.Steps[0].Name | Should -Be 'FailPrimary' - $types = @($result.Events | ForEach-Object { $_.Type }) - $types | Should -Contain 'StepFailed' - $types | Should -Contain 'OnFailureStarted' - $types | Should -Contain 'OnFailureCompleted' + $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' + $result.OnFailure.Status | Should -Be 'Completed' + @($result.OnFailure.Steps).Count | Should -Be 1 + $result.OnFailure.Steps[0].Status | Should -Be 'Completed' - [array]::IndexOf($types, 'StepFailed') | Should -BeLessThan ([array]::IndexOf($types, 'OnFailureStarted')) + $types = @($result.Events | ForEach-Object { $_.Type }) + $types | Should -Contain 'StepFailed' + $types | Should -Contain 'OnFailureStarted' + $types | Should -Contain 'OnFailureCompleted' - ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 - } + [array]::IndexOf($types, 'StepFailed') | Should -BeLessThan ([array]::IndexOf($types, 'OnFailureStarted')) - It 'continues OnFailureSteps when an OnFailure step fails (best effort)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-partial.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } + + It 'continues OnFailureSteps when an OnFailure step fails (best effort)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'onfailure-partial.psd1' -Content @' @{ Name = 'Demo - OnFailure Partial' LifecycleEvent = 'Joiner' @@ -395,35 +346,34 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' - 'IdLE.Step.OnFailureFail' = 'Invoke-IdleTestFailStep' - 'IdLE.Step.OnFailureOk' = 'Invoke-IdleTestEmitStep' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.OnFailureFail' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.OnFailureOk' = 'Invoke-IdleTestEmitStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.OnFailureFail', 'IdLE.Step.OnFailureOk') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.OnFailureFail', 'IdLE.Step.OnFailureOk') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result.Status | Should -Be 'Failed' - $result.OnFailure.Status | Should -Be 'PartiallyFailed' - @($result.OnFailure.Steps).Count | Should -Be 2 - $result.OnFailure.Steps[0].Status | Should -Be 'Failed' - $result.OnFailure.Steps[1].Status | Should -Be 'Completed' + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - ($result.Events | Where-Object Type -eq 'OnFailureStepStarted').Count | Should -Be 2 - ($result.Events | Where-Object Type -eq 'OnFailureStepFailed').Count | Should -Be 1 - ($result.Events | Where-Object Type -eq 'OnFailureStepCompleted').Count | Should -Be 1 - } + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'PartiallyFailed' + @($result.OnFailure.Steps).Count | Should -Be 2 + $result.OnFailure.Steps[0].Status | Should -Be 'Failed' + $result.OnFailure.Steps[1].Status | Should -Be 'Completed' + + ($result.Events | Where-Object Type -eq 'OnFailureStepStarted').Count | Should -Be 2 + ($result.Events | Where-Object Type -eq 'OnFailureStepFailed').Count | Should -Be 1 + ($result.Events | Where-Object Type -eq 'OnFailureStepCompleted').Count | Should -Be 1 + } - It 'does not execute OnFailureSteps when run completes successfully' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-notrun.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'does not execute OnFailureSteps when run completes successfully' { + $wfPath = New-IdleTestWorkflowFile -FileName 'onfailure-notrun.psd1' -Content @' @{ Name = 'Demo - OnFailure NotRun' LifecycleEvent = 'Joiner' @@ -436,31 +386,32 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Ok' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Ok' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Ok', 'IdLE.Step.OnFailure1') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Ok', 'IdLE.Step.OnFailure1') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result.Status | Should -Be 'Completed' - $result.OnFailure.Status | Should -Be 'NotRun' - @($result.OnFailure.Steps).Count | Should -Be 0 + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 - @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + $result.Status | Should -Be 'Completed' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 + + @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + } } - It 'fails planning when a step is missing Type' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Planning validation' { + It 'fails planning when a step is missing Type' { + $wfPath = New-IdleTestWorkflowFile -FileName 'bad.psd1' -Content @' @{ Name = 'Bad' LifecycleEvent = 'Joiner' @@ -470,14 +421,13 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw - } + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw + } - It 'fails planning when When schema is invalid' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-when.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'fails planning when When schema is invalid' { + $wfPath = New-IdleTestWorkflowFile -FileName 'bad-when.psd1' -Content @' @{ Name = 'BadWhen' LifecycleEvent = 'Joiner' @@ -491,262 +441,317 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw + } } - It 'rejects ScriptBlock in Plan object' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - CorrelationId = 'test-corr' - Steps = @( - @{ - Name = 'TestStep' - Type = 'Test' - With = @{ - Payload = { Write-Host 'Should not execute' } - } + Context 'Security validation' { + It 'rejects a ScriptBlock -EventSink (security)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } - ) - } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') + } - { Invoke-IdlePlan -Plan $plan } | Should -Throw '*ScriptBlocks are not allowed*' - } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - It 'rejects ScriptBlock in Providers object' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - CorrelationId = 'test-corr' - Steps = @() + $sink = { param($e) } + { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw } - $providers = @{ - Config = @{ - Secret = { Get-Secret } - } - } + It 'rejects ScriptBlock step handlers in the StepRegistry (security)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ - { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*ScriptBlocks are not allowed*' - } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - It 'throws when AuthSessionBroker is missing (AuthSession acquisition)' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - CorrelationId = 'test-corr' - Steps = @( - @{ - Name = 'Acquire' - Type = 'IdLE.Step.AcquireAuthSession' - With = @{ - Name = 'Demo' - Options = @{} - } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = { param($Context, $Step) } } - ) + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw } - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + It 'rejects ScriptBlock in Plan object' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'TestStep' + Type = 'Test' + With = @{ + Payload = { Write-Host 'Should not execute' } + } + } + ) } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') + + { Invoke-IdlePlan -Plan $plan } | Should -Throw '*ScriptBlocks are not allowed*' } - { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*AuthSessionBroker*' - } + It 'rejects ScriptBlock in Providers object' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @() + } - It 'does not require AuthSessionBroker when AcquireAuthSession steps are NotApplicable' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - CorrelationId = 'test-corr' - Steps = @( - @{ - Name = 'ConditionalAcquire' - Type = 'IdLE.Step.AcquireAuthSession' - Status = 'NotApplicable' - With = @{ - Name = 'Demo' - Options = @{} - } + $providers = @{ + Config = @{ + Secret = { Get-Secret } } - @{ - Name = 'SomeOtherStep' - Type = 'IdLE.Step.Noop' - With = @{} - } - ) - } - - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' - 'IdLE.Step.Noop' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession', 'IdLE.Step.Noop') - } - # Should not throw because the AcquireAuthSession step is NotApplicable - { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Not -Throw + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*ScriptBlocks are not allowed*' + } } - It 'normalizes null options and enriches CorrelationId when acquiring an auth session' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - CorrelationId = 'test-corr' - Actor = 'test-actor' - Steps = @( - @{ - Name = 'Acquire' - Type = 'IdLE.Step.AcquireAuthSession' - With = @{ - Name = 'Demo' - Options = $null + Context 'Auth session acquisition' { + It 'throws when AuthSessionBroker is missing (AuthSession acquisition)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = @{} + } } + ) + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } - ) + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') + } + + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*AuthSessionBroker*' } - $callLog = [pscustomobject]@{ - CallCount = 0 - Name = $null - Options = $null + It 'does not require AuthSessionBroker when AcquireAuthSession steps are NotApplicable' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'ConditionalAcquire' + Type = 'IdLE.Step.AcquireAuthSession' + Status = 'NotApplicable' + With = @{ + Name = 'Demo' + Options = @{} + } + } + @{ + Name = 'SomeOtherStep' + Type = 'IdLE.Step.Noop' + With = @{} + } + ) + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + 'IdLE.Step.Noop' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession', 'IdLE.Step.Noop') + } + + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Not -Throw } - $broker = [pscustomobject]@{} - $acquireMethod = { - param($Name, $Options) - $callLog.CallCount++ - $callLog.Name = $Name - $callLog.Options = $Options + It 'normalizes null options and enriches CorrelationId when acquiring an auth session' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Actor = 'test-actor' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = $null + } + } + ) + } - return [pscustomobject]@{ - PSTypeName = 'IdLE.AuthSession' - Kind = 'Test' + $callLog = [pscustomobject]@{ + CallCount = 0 + Name = $null + Options = $null } - }.GetNewClosure() - $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + $broker = [pscustomobject]@{} + $acquireMethod = { + param($Name, $Options) + $callLog.CallCount++ + $callLog.Name = $Name + $callLog.Options = $Options + + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + } + }.GetNewClosure() + $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') + AuthSessionBroker = $broker } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') - AuthSessionBroker = $broker - } - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $callLog.CallCount | Should -Be 1 - $callLog.Name | Should -Be 'Demo' + $result.Status | Should -Be 'Completed' + $callLog.CallCount | Should -Be 1 + $callLog.Name | Should -Be 'Demo' - $callLog.Options | Should -BeOfType 'hashtable' - $callLog.Options.ContainsKey('CorrelationId') | Should -BeTrue - $callLog.Options['CorrelationId'] | Should -Be 'test-corr' - $callLog.Options.ContainsKey('Actor') | Should -BeTrue - $callLog.Options['Actor'] | Should -Be 'test-actor' - } + $callLog.Options | Should -BeOfType 'hashtable' + $callLog.Options.ContainsKey('CorrelationId') | Should -BeTrue + $callLog.Options['CorrelationId'] | Should -Be 'test-corr' + $callLog.Options.ContainsKey('Actor') | Should -BeTrue + $callLog.Options['Actor'] | Should -Be 'test-actor' + } - It 'rejects ScriptBlocks in auth session options (security)' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - CorrelationId = 'test-corr' - Steps = @( - @{ - Name = 'Acquire' - Type = 'IdLE.Step.AcquireAuthSession' - With = @{ - Name = 'Demo' - Options = @{ - Nested = @{ - Bad = { 'do-not-allow' } + It 'rejects ScriptBlocks in auth session options (security)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = @{ + Nested = @{ + Bad = { 'do-not-allow' } + } } } } - } - ) - } + ) + } - $broker = [pscustomobject]@{} - $acquireMethod = { - param($Name, $Options) - return [pscustomobject]@{ - PSTypeName = 'IdLE.AuthSession' - Kind = 'Test' + $broker = [pscustomobject]@{} + $acquireMethod = { + param($Name, $Options) + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + } } - } - $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force + $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') + AuthSessionBroker = $broker } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') - AuthSessionBroker = $broker - } - { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*auth session options*' - } + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*auth session options*' + } - It 'calls the AuthSessionBroker and returns a completed step result (AuthSession acquisition)' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - CorrelationId = 'test-corr' - Steps = @( - @{ - Name = 'Acquire' - Type = 'IdLE.Step.AcquireAuthSession' - With = @{ - Name = 'Demo' - Options = @{ - Mode = 'Auto' - CacheKey = 'unit-test' + It 'calls the AuthSessionBroker and returns a completed step result (AuthSession acquisition)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = @{ + Mode = 'Auto' + CacheKey = 'unit-test' + } } } - } - ) - } + ) + } - $callLog = [pscustomobject]@{ - CallCount = 0 - Name = $null - Options = $null - } + $callLog = [pscustomobject]@{ + CallCount = 0 + Name = $null + Options = $null + } - $broker = [pscustomobject]@{} - $acquireMethod = { - param($Name, $Options) - $callLog.CallCount++ - $callLog.Name = $Name - $callLog.Options = $Options + $broker = [pscustomobject]@{} + $acquireMethod = { + param($Name, $Options) + $callLog.CallCount++ + $callLog.Name = $Name + $callLog.Options = $Options - return [pscustomobject]@{ - PSTypeName = 'IdLE.AuthSession' - Kind = 'Test' - } - }.GetNewClosure() - $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + } + }.GetNewClosure() + $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') + AuthSessionBroker = $broker } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') - AuthSessionBroker = $broker - } - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $callLog.CallCount | Should -Be 1 - $callLog.Name | Should -Be 'Demo' - $callLog.Options['Mode'] | Should -Be 'Auto' - $callLog.Options['CacheKey'] | Should -Be 'unit-test' + $result.Status | Should -Be 'Completed' + $callLog.CallCount | Should -Be 1 + $callLog.Name | Should -Be 'Demo' + $callLog.Options['Mode'] | Should -Be 'Auto' + $callLog.Options['CacheKey'] | Should -Be 'unit-test' + } } - It 'supports step handlers without Context parameter (backwards compatibility)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'legacy.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Legacy step handlers' { + It 'supports step handlers without Context parameter (backwards compatibility)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'legacy.psd1' -Content @' @{ Name = 'Legacy' LifecycleEvent = 'Joiner' @@ -756,21 +761,23 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Legacy' = 'Invoke-IdleTestLegacyStep' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Legacy' = 'Invoke-IdleTestLegacyStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Legacy') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Legacy') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' - $result.Steps[0].Name | Should -Be 'LegacyStep' + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + $result.Steps[0].Name | Should -Be 'LegacyStep' + } } } + diff --git a/tests/Core/ModuleBootstrap.Tests.ps1 b/tests/Core/ModuleBootstrap.Tests.ps1 index 9467e509..9b51573c 100644 --- a/tests/Core/ModuleBootstrap.Tests.ps1 +++ b/tests/Core/ModuleBootstrap.Tests.ps1 @@ -2,124 +2,104 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') - $repoRoot = Get-RepoRootPath + $script:RepoRoot = Get-RepoRootPath } Describe 'IdLE Module Bootstrap for Repo/Zip Layouts' { BeforeAll { - # Save original PSModulePath $script:originalPSModulePath = $env:PSModulePath + $script:IdleManifest = Join-Path -Path $script:RepoRoot -ChildPath 'src/IdLE/IdLE.psd1' + $script:SrcPath = Join-Path -Path $script:RepoRoot -ChildPath 'src' } AfterAll { - # Restore original PSModulePath $env:PSModulePath = $script:originalPSModulePath - - # Remove any imported IdLE modules (including nested/hidden modules) Get-Module -All IdLE* | Remove-Module -Force -ErrorAction SilentlyContinue } BeforeEach { - # Reset PSModulePath to original before each test $env:PSModulePath = $script:originalPSModulePath - - # Remove any previously imported IdLE modules (including nested/hidden modules) Get-Module -All IdLE* | Remove-Module -Force -ErrorAction SilentlyContinue } Context 'Repo/Zip layout bootstrap' { It 'Imports IdLE from repo layout successfully' { - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - - { Import-Module $idleManifest -Force -ErrorAction Stop } | Should -Not -Throw - + { Import-Module $script:IdleManifest -Force -ErrorAction Stop } | Should -Not -Throw + $idleModule = Get-Module IdLE $idleModule | Should -Not -BeNullOrEmpty $idleModule.Name | Should -Be 'IdLE' } It 'Adds src directory to PSModulePath after importing IdLE' { - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - $srcPath = Join-Path -Path $repoRoot -ChildPath 'src' - - # NOTE: This test may be affected by previous tests that imported IdLE - # We verify that src is in PSModulePath after import, which is the key behavior - - Import-Module $idleManifest -Force -ErrorAction Stop - - # Verify src is now in PSModulePath - $env:PSModulePath | Should -Match ([regex]::Escape($srcPath)) + Import-Module $script:IdleManifest -Force -ErrorAction Stop + + $env:PSModulePath | Should -Match ([regex]::Escape($script:SrcPath)) } It 'Is idempotent - does not add src directory multiple times' { - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - $srcPath = Join-Path -Path $repoRoot -ChildPath 'src' - $resolvedSrcPath = (Resolve-Path -Path $srcPath).Path - - # Import IdLE twice - Import-Module $idleManifest -Force -ErrorAction Stop + $resolvedSrcPath = (Resolve-Path -Path $script:SrcPath).Path + + Import-Module $script:IdleManifest -Force -ErrorAction Stop Remove-Module IdLE -Force - Import-Module $idleManifest -Force -ErrorAction Stop - - # Count occurrences of src path in PSModulePath + Import-Module $script:IdleManifest -Force -ErrorAction Stop + $pathSeparator = [System.IO.Path]::PathSeparator $paths = $env:PSModulePath -split [regex]::Escape($pathSeparator) - - $matchingPaths = @($paths | Where-Object { - if (-not $_) { return $false } - $resolvedPath = Resolve-Path -Path $_ -ErrorAction SilentlyContinue - $resolvedPath -and $resolvedPath.Path -eq $resolvedSrcPath - }) - + + $matchingPaths = @($paths | Where-Object { + if (-not $_) { return $false } + $resolvedPath = Resolve-Path -Path $_ -ErrorAction SilentlyContinue + $resolvedPath -and $resolvedPath.Path -eq $resolvedSrcPath + }) + $matchingPaths.Count | Should -BeExactly 1 } It 'Enables name-based import of provider modules after IdLE import' { - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - - Import-Module $idleManifest -Force -ErrorAction Stop - - # Should be able to import provider by name + Import-Module $script:IdleManifest -Force -ErrorAction Stop + { Import-Module IdLE.Provider.Mock -ErrorAction Stop } | Should -Not -Throw - + $providerModule = Get-Module IdLE.Provider.Mock $providerModule | Should -Not -BeNullOrEmpty $providerModule.Name | Should -Be 'IdLE.Provider.Mock' } It 'Enables name-based import of step modules with RequiredModules after IdLE import' { - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - - Import-Module $idleManifest -Force -ErrorAction Stop - - # Should be able to import step module by name - # IdLE.Steps.Mailbox has RequiredModules = @('IdLE.Steps.Common') + Import-Module $script:IdleManifest -Force -ErrorAction Stop + { Import-Module IdLE.Steps.Mailbox -ErrorAction Stop } | Should -Not -Throw - + $stepsModule = Get-Module IdLE.Steps.Mailbox $stepsModule | Should -Not -BeNullOrEmpty $stepsModule.Name | Should -Be 'IdLE.Steps.Mailbox' - - # Verify that IdLE.Steps.Common was loaded as a dependency + $commonModule = Get-Module IdLE.Steps.Common $commonModule | Should -Not -BeNullOrEmpty } It 'Does not modify PSModulePath when IdLE is imported from a non-repo layout' { - # This test would require mocking a different installation layout - # For now, we skip it as it's hard to test without actually installing the module - Set-ItResult -Skipped -Because 'Requires non-repo installation layout' + $moduleRoot = Join-Path -Path $TestDrive -ChildPath 'psmodules' + $layout = New-IdleTestModuleLayout -DestinationRoot $moduleRoot + + $idleManifest = Join-Path -Path $layout.Root -ChildPath 'IdLE\IdLE.psd1' + $originalPSModulePath = $env:PSModulePath + + Import-Module $idleManifest -Force -ErrorAction Stop + + $env:PSModulePath | Should -Be $originalPSModulePath + Remove-Module IdLE -Force -ErrorAction SilentlyContinue } } Context 'IdLE exports expected public API' { It 'Exports public cmdlets' { - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - Import-Module $idleManifest -Force -ErrorAction Stop - + Import-Module $script:IdleManifest -Force -ErrorAction Stop + $expectedCmdlets = @( 'Test-IdleWorkflow', - 'New-IdleLifecycleRequest', + 'New-IdleRequest', 'New-IdlePlan', 'Invoke-IdlePlan', 'Export-IdlePlan', @@ -135,12 +115,10 @@ Describe 'IdLE Module Bootstrap for Repo/Zip Layouts' { } It 'Has access to IdLE.Core functionality' { - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - Import-Module $idleManifest -Force -ErrorAction Stop - - # IdLE.Core should be imported internally (may not be visible in Get-Module) - # Test by using a cmdlet that depends on IdLE.Core - { Get-Command New-IdleLifecycleRequest -ErrorAction Stop } | Should -Not -Throw + Import-Module $script:IdleManifest -Force -ErrorAction Stop + + { Get-Command New-IdleRequest -ErrorAction Stop } | Should -Not -Throw } } } + diff --git a/tests/Core/ModuleExports.Tests.ps1 b/tests/Core/ModuleExports.Tests.ps1 index d4310677..3e27b885 100644 --- a/tests/Core/ModuleExports.Tests.ps1 +++ b/tests/Core/ModuleExports.Tests.ps1 @@ -2,61 +2,55 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') - $repoRoot = Get-RepoRootPath + $script:RepoRoot = Get-RepoRootPath } Describe 'Module Export Consistency' { Context 'IdLE.Core module exports' { BeforeAll { - $script:originalIdleAllowInternalImport = $env:IDLE_ALLOW_INTERNAL_IMPORT + $script:OriginalIdleAllowInternalImport = $env:IDLE_ALLOW_INTERNAL_IMPORT $env:IDLE_ALLOW_INTERNAL_IMPORT = '1' - $coreModulePath = Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/IdLE.Core.psd1' - Import-Module -Name $coreModulePath -Force -ErrorAction Stop - - $coreModule = Get-Module -Name 'IdLE.Core' + + $script:CoreModulePath = Join-Path -Path $script:RepoRoot -ChildPath 'src/IdLE.Core/IdLE.Core.psd1' + Import-Module -Name $script:CoreModulePath -Force -ErrorAction Stop + + $script:CoreModule = Get-Module -Name 'IdLE.Core' } AfterAll { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $script:originalIdleAllowInternalImport + $env:IDLE_ALLOW_INTERNAL_IMPORT = $script:OriginalIdleAllowInternalImport + Remove-Module -Name 'IdLE.Core' -Force -ErrorAction SilentlyContinue } It 'exports New-IdleAuthSessionBroker function' { - $exportedCommands = $coreModule.ExportedCommands.Keys - $exportedCommands | Should -Contain 'New-IdleAuthSessionBroker' + $script:CoreModule.ExportedCommands.Keys | Should -Contain 'New-IdleAuthSessionBroker' } It 'New-IdleAuthSessionBroker is accessible via module-qualified name' { - # Test that the function can be accessed with module-qualified name $command = Get-Command -Name 'IdLE.Core\New-IdleAuthSessionBroker' -ErrorAction SilentlyContinue $command | Should -Not -BeNullOrEmpty } It 'exported functions match between psm1 Export-ModuleMember and psd1 FunctionsToExport' { - # Read the psm1 file to find Export-ModuleMember calls - $psm1Path = Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/IdLE.Core.psm1' + $psm1Path = Join-Path -Path $script:RepoRoot -ChildPath 'src/IdLE.Core/IdLE.Core.psm1' $psm1Content = Get-Content -Path $psm1Path -Raw - - # Extract Export-ModuleMember function list + if ($psm1Content -match "Export-ModuleMember\s+-Function\s+@\(([\s\S]*?)\)") { $exportedInPsm1Raw = $Matches[1] $exportedInPsm1 = $exportedInPsm1Raw -split "[,\r\n]+" | ForEach-Object { $_.Trim().Trim("'").Trim('"') } | Where-Object { $_ -ne '' } - - # Read the psd1 manifest - $manifest = Import-PowerShellDataFile -Path $coreModulePath + + $manifest = Import-PowerShellDataFile -Path $script:CoreModulePath $exportedInPsd1 = $manifest.FunctionsToExport - - # Compare the two lists + $exportedInPsm1 = $exportedInPsm1 | Sort-Object $exportedInPsd1 = $exportedInPsd1 | Sort-Object - - # Check that all functions in psm1 are in psd1 + foreach ($func in $exportedInPsm1) { $exportedInPsd1 | Should -Contain $func -Because "Function '$func' is exported in psm1 but not listed in psd1 FunctionsToExport" } - - # Check that all functions in psd1 are in psm1 + foreach ($func in $exportedInPsd1) { $exportedInPsm1 | Should -Contain $func -Because "Function '$func' is listed in psd1 FunctionsToExport but not exported in psm1" } @@ -70,29 +64,23 @@ Describe 'Module Export Consistency' { foreach ($cmd in $commands) { $help = Get-Help -Name $cmd.Name -ErrorAction Stop - # Synopsis $help.Synopsis | Should -Not -BeNullOrEmpty -Because "Function '$($cmd.Name)' should have a Synopsis" - # Description (can be structured) $descText = if ($help.Description -and $help.Description.Text) { ($help.Description.Text -join "`n").Trim() } else { '' } $descText | Should -Not -BeNullOrEmpty -Because "Function '$($cmd.Name)' should have a Description" - # Examples (can also be structured) $exampleCount = if ($help.Examples -and $help.Examples.Example) { @($help.Examples.Example).Count - } - else { + } else { 0 } $exampleCount | Should -BeGreaterThan 0 -Because "Function '$($cmd.Name)' should have at least one Example" - # Parameters - check that each non-common parameter has documentation - # Common parameters (Debug, ErrorAction, etc.) are automatically documented by PowerShell $commonParameters = @( 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', @@ -100,7 +88,7 @@ Describe 'Module Export Consistency' { ) $cmdParameters = @($cmd.Parameters.Keys | Where-Object { $_ -notin $commonParameters }) - + if ($cmdParameters.Count -gt 0) { $helpParameters = @() if ($help.parameters -and $help.parameters.parameter) { @@ -117,15 +105,18 @@ Describe 'Module Export Consistency' { Context 'IdLE meta-module exports' { BeforeAll { - $idleManifestPath = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - Import-Module -Name $idleManifestPath -Force -ErrorAction Stop - - $idleModule = Get-Module -Name 'IdLE' + $script:IdleModulePath = Join-Path -Path $script:RepoRoot -ChildPath 'src/IdLE/IdLE.psd1' + Import-Module -Name $script:IdleModulePath -Force -ErrorAction Stop + + $script:IdleModule = Get-Module -Name 'IdLE' + } + + AfterAll { + Remove-Module -Name 'IdLE' -Force -ErrorAction SilentlyContinue } It 'exports New-IdleAuthSession function' { - $exportedCommands = $idleModule.ExportedCommands.Keys - $exportedCommands | Should -Contain 'New-IdleAuthSession' + $script:IdleModule.ExportedCommands.Keys | Should -Contain 'New-IdleAuthSession' } It 'New-IdleAuthSession can be called without module qualification' { @@ -135,32 +126,25 @@ Describe 'Module Export Consistency' { } It 'exported functions match between psm1 Export-ModuleMember and psd1 FunctionsToExport' { - # Read the psm1 file to find Export-ModuleMember calls - $psm1Path = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psm1' + $psm1Path = Join-Path -Path $script:RepoRoot -ChildPath 'src/IdLE/IdLE.psm1' $psm1Content = Get-Content -Path $psm1Path -Raw - - # Extract Export-ModuleMember function list + if ($psm1Content -match "Export-ModuleMember\s+-Function\s+@\(([\s\S]*?)\)") { $exportedInPsm1Raw = $Matches[1] $exportedInPsm1 = $exportedInPsm1Raw -split "[,\r\n]+" | ForEach-Object { $_.Trim().Trim("'").Trim('"') } | Where-Object { $_ -ne '' } - - # Read the psd1 manifest (use fresh path, not the imported module variable) - $manifestPath = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - $manifest = Import-PowerShellDataFile -Path $manifestPath + + $manifest = Import-PowerShellDataFile -Path $script:IdleModulePath $exportedInPsd1 = $manifest.FunctionsToExport - - # Compare the two lists + $exportedInPsm1 = $exportedInPsm1 | Sort-Object $exportedInPsd1 = $exportedInPsd1 | Sort-Object - - # Check that all functions in psm1 are in psd1 + foreach ($func in $exportedInPsm1) { $exportedInPsd1 | Should -Contain $func -Because "Function '$func' is exported in psm1 but not listed in psd1 FunctionsToExport" } - - # Check that all functions in psd1 are in psm1 + foreach ($func in $exportedInPsd1) { $exportedInPsm1 | Should -Contain $func -Because "Function '$func' is listed in psd1 FunctionsToExport but not exported in psm1" } diff --git a/tests/Core/New-IdleAuthSession.Tests.ps1 b/tests/Core/New-IdleAuthSession.Tests.ps1 index 7a2d0643..06620ca5 100644 --- a/tests/Core/New-IdleAuthSession.Tests.ps1 +++ b/tests/Core/New-IdleAuthSession.Tests.ps1 @@ -1,3 +1,5 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule @@ -243,11 +245,13 @@ Describe 'New-IdleAuthSession' { } } - It 'is available as exported command from IdLE module' { - $command = Get-Command -Name New-IdleAuthSession -ErrorAction SilentlyContinue - - $command | Should -Not -BeNullOrEmpty - $command.Name | Should -Be 'New-IdleAuthSession' - $command.Module.Name | Should -Be 'IdLE' + Context 'Module export' { + It 'is available as exported command from IdLE module' { + $command = Get-Command -Name New-IdleAuthSession -ErrorAction SilentlyContinue + + $command | Should -Not -BeNullOrEmpty + $command.Name | Should -Be 'New-IdleAuthSession' + $command.Module.Name | Should -Be 'IdLE' + } } } diff --git a/tests/Core/New-IdleLifecycleRequest.Tests.ps1 b/tests/Core/New-IdleLifecycleRequest.Tests.ps1 deleted file mode 100644 index e20982fe..00000000 --- a/tests/Core/New-IdleLifecycleRequest.Tests.ps1 +++ /dev/null @@ -1,115 +0,0 @@ -BeforeAll { - . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') - Import-IdleTestModule -} - -Describe 'New-IdleLifecycleRequest' { - It 'creates a request object with the expected type' { - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $req | Should -Not -BeNullOrEmpty - $req.GetType().Name | Should -Be 'IdleLifecycleRequest' - } - - It 'generates CorrelationId when missing' { - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $req.CorrelationId | Should -Not -BeNullOrEmpty - { [guid]::Parse($req.CorrelationId) } | Should -Not -Throw - } - - It 'preserves CorrelationId when provided' { - $cid = ([guid]::NewGuid()).Guid - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid - $req.CorrelationId | Should -Be $cid - } - - It 'defaults IdentityKeys and DesiredState to empty hashtables when omitted' { - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $req.IdentityKeys | Should -BeOfType 'hashtable' - $req.DesiredState | Should -BeOfType 'hashtable' - $req.IdentityKeys.Count | Should -Be 0 - $req.DesiredState.Count | Should -Be 0 - } - - It 'leaves Changes as null when omitted' { - $req = New-IdleLifecycleRequest -LifecycleEvent 'Mover' - $req.Changes | Should -BeNullOrEmpty - } - - It 'accepts Changes when provided' { - $req = New-IdleLifecycleRequest -LifecycleEvent 'Mover' -Changes @{ - Attributes = @{ - Department = @{ - From = 'Sales' - To = 'IT' - } - } - } - - $req.Changes | Should -BeOfType 'hashtable' - $req.Changes.Attributes.Department.From | Should -Be 'Sales' - $req.Changes.Attributes.Department.To | Should -Be 'IT' - } - - It 'treats Actor as optional (null when omitted)' { - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $req.Actor | Should -BeNullOrEmpty - } - - It 'accepts Actor when provided' { - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'alice@contoso.com' - $req.Actor | Should -Be 'alice@contoso.com' - } -} - -Describe 'New-IdleLifecycleRequest - data-only validation' { - - It 'rejects ScriptBlock in DesiredState when provided' { - try { - New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ - Attributes = @{ Department = { 'IT' } } - } - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'DesiredState' - } - } - - It 'rejects ScriptBlock nested in arrays' { - try { - New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ - Entitlements = @( - @{ Type = 'Group'; Value = 'APP-CRM-Users' } - @{ Type = 'Custom'; Value = { 'NOPE' } } - ) - } - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'DesiredState' - } - } - - It 'rejects ScriptBlock in Changes when provided' { - try { - New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Changes @{ - Attributes = @{ - Department = @{ - From = 'Sales' - To = { 'IT' } - } - } - } - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'Changes' - } - } -} - diff --git a/tests/Core/New-IdlePlan.Capabilities.Tests.ps1 b/tests/Core/New-IdlePlan.Capabilities.Tests.ps1 index b28d11fe..02e944ee 100644 --- a/tests/Core/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Capabilities.Tests.ps1 @@ -1,220 +1,229 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - $fixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows' + $script:FixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows' } Describe 'New-IdlePlan - required provider capabilities' { + Context 'Metadata requirements' { + It 'fails fast when a step type has no metadata entry (MissingStepTypeMetadata)' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' - It 'fails fast when a step type has no metadata entry (MissingStepTypeMetadata)' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' - - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - # Provide a custom StepRegistry for the unknown step type - $providers = @{ - StepRegistry = @{ - 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } } - } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'MissingStepTypeMetadata' - $_.Exception.Message | Should -Match 'Custom.Step.Unknown' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingStepTypeMetadata' + $_.Exception.Message | Should -Match 'Custom.Step.Unknown' + } } } - It 'derives capabilities from built-in step metadata' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' + Context 'Capability derivation' { + It 'derives capabilities from built-in step metadata' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-builtin.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $provider = [pscustomobject]@{ Name = 'IdentityProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable') - } -Force + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force - $providers = @{ - IdentityProvider = $provider - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan | Should -Not -BeNullOrEmpty - $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' - } - - It 'fails fast when required capabilities are missing' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-missing-caps.psd1' + $providers = @{ + IdentityProvider = $provider + } - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'MissingCapabilities' - $_.Exception.Message | Should -Match 'IdLE\.Identity\.Disable' - $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' } - } - It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-override.psd1' + It 'validates OnFailureSteps capabilities from metadata' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-onfailure.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $provider = [pscustomobject]@{ Name = 'IdentityProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('Custom.Capability.Override') - } -Force + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force - $providers = @{ - IdentityProvider = $provider - StepMetadata = @{ - 'IdLE.Step.DisableIdentity' = @{ - RequiredCapabilities = @('Custom.Capability.Override') - } + $providers = @{ + IdentityProvider = $provider } - } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' - $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.OnFailureSteps.Count | Should -Be 1 + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' } - } - It 'validates OnFailureSteps capabilities from metadata' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-onfailure.psd1' + It 'validates entitlement capabilities from metadata' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-entitlements.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Entitlement' + } - $provider = [pscustomobject]@{ Name = 'IdentityProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable') - } -Force + $provider = [pscustomobject]@{ Name = 'EntProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + } -Force - $providers = @{ - IdentityProvider = $provider - } + $providers = @{ Entitlement = $provider } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $plan | Should -Not -BeNullOrEmpty - $plan.OnFailureSteps.Count | Should -Be 1 - $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.List' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Grant' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Revoke' + } } - It 'validates entitlement capabilities from metadata' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-entitlements.psd1' + Context 'Missing capabilities' { + It 'fails fast when required capabilities are missing' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-missing-caps.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'MissingCapabilities' - $_.Exception.Message | Should -Match 'IdLE\.Entitlement' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' + } } + } - $provider = [pscustomobject]@{ Name = 'EntProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') - } -Force - - $providers = @{ Entitlement = $provider } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + Context 'Metadata validation' { + It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-override.psd1' - $plan | Should -Not -BeNullOrEmpty - $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.List' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Grant' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Revoke' - } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - It 'rejects metadata with ScriptBlock values' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-scriptblock.psd1' + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Override') + } -Force - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $providers = @{ + IdentityProvider = $provider + StepMetadata = @{ + 'IdLE.Step.DisableIdentity' = @{ + RequiredCapabilities = @('Custom.Capability.Override') + } + } + } - $providers = @{ - StepRegistry = @{ - 'Custom.Step.Test' = 'Invoke-CustomStep' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' } - StepMetadata = @{ - 'Custom.Step.Test' = @{ - RequiredCapabilities = { 'Dynamic.Capability' } - } + catch { + $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' } } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'ScriptBlock' - } - } + It 'rejects metadata with ScriptBlock values' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-scriptblock.psd1' - It 'rejects invalid metadata shapes' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-invalid.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = @{ + RequiredCapabilities = { 'Dynamic.Capability' } + } + } + } - $providers = @{ - StepRegistry = @{ - 'Custom.Step.Test' = 'Invoke-CustomStep' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' } - StepMetadata = @{ - 'Custom.Step.Test' = 'not-a-hashtable' + catch { + $_.Exception.Message | Should -Match 'ScriptBlock' } } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'must be a hashtable' - } - } + It 'rejects invalid metadata shapes' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-invalid.psd1' - It 'rejects invalid capability identifiers' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-invalid-cap.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = 'not-a-hashtable' + } + } - $providers = @{ - StepRegistry = @{ - 'Custom.Step.Test' = 'Invoke-CustomStep' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' } - StepMetadata = @{ - 'Custom.Step.Test' = @{ - RequiredCapabilities = @('Invalid Capability With Spaces') - } + catch { + $_.Exception.Message | Should -Match 'must be a hashtable' } } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'invalid capability' + It 'rejects invalid capability identifiers' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-invalid-cap.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = @{ + RequiredCapabilities = @('Invalid Capability With Spaces') + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'invalid capability' + } } } } + diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 143c27f5..29556a3a 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -1,13 +1,39 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + + function global:Invoke-IdleTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } } -Describe 'New-IdlePlan' { +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue +} - It 'creates a plan with normalized steps' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' +Describe 'New-IdlePlan' { + Context 'Plan normalization' { + It 'creates a plan with normalized steps' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -18,52 +44,49 @@ Describe 'New-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - # Create a dummy provider with the required capability for EnsureAttributes - $dummyProvider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.TestDummy' - } - $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Attribute.Ensure') - } - - $providers = @{ - Dummy = $true - Identity = $dummyProvider - StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $dummyProvider = [pscustomobject]@{ PSTypeName = 'IdLE.Provider.TestDummy' } + $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Attribute.Ensure') + } + + $providers = @{ + Dummy = $true + Identity = $dummyProvider + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') - } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $plan | Should -Not -BeNullOrEmpty - $plan.PSTypeNames | Should -Contain 'IdLE.Plan' - $plan.WorkflowName | Should -Be 'Joiner - Standard' - $plan.LifecycleEvent | Should -Be 'Joiner' - $plan.CorrelationId | Should -Be $req.CorrelationId + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - @($plan.Steps).Count | Should -Be 2 - $plan.Steps[0].PSTypeNames | Should -Contain 'IdLE.PlanStep' - $plan.Steps[0].Name | Should -Be 'ResolveIdentity' - $plan.Steps[0].Type | Should -Be 'IdLE.Step.ResolveIdentity' + $plan | Should -Not -BeNullOrEmpty + $plan.PSTypeNames | Should -Contain 'IdLE.Plan' + $plan.WorkflowName | Should -Be 'Joiner - Standard' + $plan.LifecycleEvent | Should -Be 'Joiner' + $plan.CorrelationId | Should -Be $req.CorrelationId - @($plan.Actions).Count | Should -Be 0 - @($plan.Warnings).Count | Should -Be 0 + @($plan.Steps).Count | Should -Be 2 + $plan.Steps[0].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.Steps[0].Name | Should -Be 'ResolveIdentity' + $plan.Steps[0].Type | Should -Be 'IdLE.Step.ResolveIdentity' - $plan.Providers.Dummy | Should -BeTrue + @($plan.Actions).Count | Should -Be 0 + @($plan.Warnings).Count | Should -Be 0 - # OnFailureSteps are optional in workflows, but the plan should always expose the property - # to keep downstream execution deterministic and avoid "property exists?" checks. - $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' - @($plan.OnFailureSteps).Count | Should -Be 0 + $plan.Providers.Dummy | Should -BeTrue + + $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' + @($plan.OnFailureSteps).Count | Should -Be 0 + } } - It 'normalizes OnFailureSteps and evaluates their conditions during planning' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'OnFailureSteps normalization' { + It 'normalizes OnFailureSteps and evaluates their conditions during planning' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-onfailure.psd1' -Content @' @{ Name = 'Joiner - OnFailureSteps' LifecycleEvent = 'Joiner' @@ -86,37 +109,43 @@ Describe 'New-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $providers = @{ - Dummy = $true - StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.Containment' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.NeverApplicable' = 'Invoke-IdleTestNoopStep' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.NeverApplicable' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @( + 'IdLE.Step.ResolveIdentity', + 'IdLE.Step.Containment', + 'IdLE.Step.NeverApplicable' + ) } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.Containment', 'IdLE.Step.NeverApplicable') + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' + @($plan.OnFailureSteps).Count | Should -Be 2 + + $plan.OnFailureSteps[0].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.OnFailureSteps[0].Name | Should -Be 'Containment' + $plan.OnFailureSteps[0].Type | Should -Be 'IdLE.Step.Containment' + $plan.OnFailureSteps[0].Status | Should -Be 'Planned' + $plan.OnFailureSteps[0].With.Mode | Should -Be 'Quarantine' + + $plan.OnFailureSteps[1].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.OnFailureSteps[1].Name | Should -Be 'NeverApplicable' + $plan.OnFailureSteps[1].Type | Should -Be 'IdLE.Step.NeverApplicable' + $plan.OnFailureSteps[1].Status | Should -Be 'NotApplicable' } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan | Should -Not -BeNullOrEmpty - $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' - @($plan.OnFailureSteps).Count | Should -Be 2 - - $plan.OnFailureSteps[0].PSTypeNames | Should -Contain 'IdLE.PlanStep' - $plan.OnFailureSteps[0].Name | Should -Be 'Containment' - $plan.OnFailureSteps[0].Type | Should -Be 'IdLE.Step.Containment' - $plan.OnFailureSteps[0].Status | Should -Be 'Planned' - $plan.OnFailureSteps[0].With.Mode | Should -Be 'Quarantine' - - $plan.OnFailureSteps[1].PSTypeNames | Should -Contain 'IdLE.PlanStep' - $plan.OnFailureSteps[1].Name | Should -Be 'NeverApplicable' - $plan.OnFailureSteps[1].Type | Should -Be 'IdLE.Step.NeverApplicable' - $plan.OnFailureSteps[1].Status | Should -Be 'NotApplicable' } - It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Validation' { + It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -126,14 +155,9 @@ Describe 'New-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'does not match request LifecycleEvent' + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw -ExpectedMessage '*does not match request LifecycleEvent*' } } } diff --git a/tests/Core/New-IdleRequest.Tests.ps1 b/tests/Core/New-IdleRequest.Tests.ps1 new file mode 100644 index 00000000..c71ed887 --- /dev/null +++ b/tests/Core/New-IdleRequest.Tests.ps1 @@ -0,0 +1,123 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'New-IdleRequest' { + Context 'Creation and defaults' { + It 'creates a request object with the expected type' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req | Should -Not -BeNullOrEmpty + $req.GetType().Name | Should -Be 'IdleLifecycleRequest' + } + + It 'generates CorrelationId when missing' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req.CorrelationId | Should -Not -BeNullOrEmpty + { [guid]::Parse($req.CorrelationId) } | Should -Not -Throw + } + + It 'preserves CorrelationId when provided' { + $cid = ([guid]::NewGuid()).Guid + $req = New-IdleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + $req.CorrelationId | Should -Be $cid + } + + It 'defaults IdentityKeys and DesiredState to empty hashtables when omitted' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req.IdentityKeys | Should -BeOfType 'hashtable' + $req.DesiredState | Should -BeOfType 'hashtable' + $req.IdentityKeys.Count | Should -Be 0 + $req.DesiredState.Count | Should -Be 0 + } + } + + Context 'Optional properties' { + It 'leaves Changes as null when omitted' { + $req = New-IdleRequest -LifecycleEvent 'Mover' + $req.Changes | Should -BeNullOrEmpty + } + + It 'accepts Changes when provided' { + $req = New-IdleRequest -LifecycleEvent 'Mover' -Changes @{ + Attributes = @{ + Department = @{ + From = 'Sales' + To = 'IT' + } + } + } + + $req.Changes | Should -BeOfType 'hashtable' + $req.Changes.Attributes.Department.From | Should -Be 'Sales' + $req.Changes.Attributes.Department.To | Should -Be 'IT' + } + + It 'treats Actor as optional (null when omitted)' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req.Actor | Should -BeNullOrEmpty + } + + It 'accepts Actor when provided' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Actor 'alice@contoso.com' + $req.Actor | Should -Be 'alice@contoso.com' + } + } +} + +Describe 'New-IdleRequest - data-only validation' { + Context 'ScriptBlock rejection' { + It 'rejects ScriptBlock in DesiredState when provided' { + try { + New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Attributes = @{ Department = { 'IT' } } + } + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception | Should -BeOfType ([System.ArgumentException]) + $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'DesiredState' + } + } + + It 'rejects ScriptBlock nested in arrays' { + try { + New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Entitlements = @( + @{ Type = 'Group'; Value = 'APP-CRM-Users' } + @{ Type = 'Custom'; Value = { 'NOPE' } } + ) + } + } + catch { + $_.Exception | Should -BeOfType ([System.ArgumentException]) + $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'DesiredState' + } + } + + It 'rejects ScriptBlock in Changes when provided' { + try { + New-IdleRequest -LifecycleEvent 'Joiner' -Changes @{ + Attributes = @{ + Department = @{ + From = 'Sales' + To = { 'IT' } + } + } + } + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception | Should -BeOfType ([System.ArgumentException]) + $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'Changes' + } + } + } +} + + diff --git a/tests/Core/Redaction.Boundaries.Tests.ps1 b/tests/Core/Redaction.Boundaries.Tests.ps1 index a77e39e5..7a69f509 100644 --- a/tests/Core/Redaction.Boundaries.Tests.ps1 +++ b/tests/Core/Redaction.Boundaries.Tests.ps1 @@ -1,3 +1,5 @@ +Set-StrictMode -Version Latest + BeforeDiscovery { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule @@ -7,179 +9,175 @@ Describe 'Redaction at output boundaries (events, exports, execution results)' { InModuleScope 'IdLE.Core' { - It 'redacts sensitive values before buffering and before sending to external event sinks' { - $buffer = [System.Collections.Generic.List[object]]::new() + Context 'Event sinks' { + It 'redacts sensitive values before buffering and before sending to external event sinks' { + $buffer = [System.Collections.Generic.List[object]]::new() - $received = [System.Collections.Generic.List[object]]::new() + $received = [System.Collections.Generic.List[object]]::new() - # IMPORTANT: Write-IdleEvent requires a *method* WriteEvent(event), not a NoteProperty. - $sink = [pscustomobject]@{ - PSTypeName = 'Tests.EventSink' - } + # IMPORTANT: Write-IdleEvent requires a *method* WriteEvent(event), not a NoteProperty. + $sink = [pscustomobject]@{ + PSTypeName = 'Tests.EventSink' + } - $sink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { - param([Parameter(Mandatory)][object] $evt) - [void]$received.Add($evt) - } -Force - - $evt = [pscustomobject]@{ - PSTypeName = 'IdLE.Event' - Name = 'Custom' - Message = 'hello' - StepName = 'Step 1' - Data = @{ - password = 'SuperSecret!' - token = 'abc123' - note = 'ok' + $sink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { + param([Parameter(Mandatory)][object] $evt) + [void]$received.Add($evt) + } -Force + + $evt = [pscustomobject]@{ + PSTypeName = 'IdLE.Event' + Name = 'Custom' + Message = 'hello' + StepName = 'Step 1' + Data = @{ + password = 'SuperSecret!' + token = 'abc123' + note = 'ok' + } } - } - # Act - Write-IdleEvent -Event $evt -EventSink $sink -EventBuffer $buffer + Write-IdleEvent -Event $evt -EventSink $sink -EventBuffer $buffer - # Original must not be mutated - $evt.Data.password | Should -Be 'SuperSecret!' - $evt.Data.token | Should -Be 'abc123' + $evt.Data.password | Should -Be 'SuperSecret!' + $evt.Data.token | Should -Be 'abc123' - # Buffer gets redacted copy - @($buffer).Count | Should -Be 1 - $buffer[0].Data.password | Should -Be '[REDACTED]' - $buffer[0].Data.token | Should -Be '[REDACTED]' - $buffer[0].Data.note | Should -Be 'ok' + @($buffer).Count | Should -Be 1 + $buffer[0].Data.password | Should -Be '[REDACTED]' + $buffer[0].Data.token | Should -Be '[REDACTED]' + $buffer[0].Data.note | Should -Be 'ok' - # External sink gets redacted copy - @($received).Count | Should -Be 1 - $received[0].Data.password | Should -Be '[REDACTED]' - $received[0].Data.token | Should -Be '[REDACTED]' - $received[0].Data.note | Should -Be 'ok' + @($received).Count | Should -Be 1 + $received[0].Data.password | Should -Be '[REDACTED]' + $received[0].Data.token | Should -Be '[REDACTED]' + $received[0].Data.note | Should -Be 'ok' + } } - It 'redacts request.input, step.inputs and step.expectedState in plan export JSON' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - Request = [pscustomobject]@{ - PSTypeName = 'IdLE.LifecycleRequest' - Type = 'Joiner' - CorrelationId = 'corr-001' - Actor = 'tester' - Input = @{ - userName = 'alice' - password = 'SuperSecret!' - clientSecret = 'shhh' - note = 'ok' - } - } - Steps = @( - [pscustomobject]@{ - Name = 'Step A' - Type = 'EnsureAttribute' - With = @{ - mail = 'alice@example.test' - accessToken = 'token-value' - } - ExpectedState = @{ - password = 'ShouldNeverAppear' - city = 'Berlin' + Context 'Plan export' { + It 'redacts request.input, step.inputs and step.expectedState in plan export JSON' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + Type = 'Joiner' + CorrelationId = 'corr-001' + Actor = 'tester' + Input = @{ + userName = 'alice' + password = 'SuperSecret!' + clientSecret = 'shhh' + note = 'ok' } } - ) - } + Steps = @( + [pscustomobject]@{ + Name = 'Step A' + Type = 'EnsureAttribute' + With = @{ + mail = 'alice@example.test' + accessToken = 'token-value' + } + ExpectedState = @{ + password = 'ShouldNeverAppear' + city = 'Berlin' + } + } + ) + } - $json = Export-IdlePlanObject -Plan $plan + $json = Export-IdlePlanObject -Plan $plan - # Secrets must not appear - $json | Should -Not -Match 'SuperSecret!' - $json | Should -Not -Match 'shhh' - $json | Should -Not -Match 'token-value' - $json | Should -Not -Match 'ShouldNeverAppear' + $json | Should -Not -Match 'SuperSecret!' + $json | Should -Not -Match 'shhh' + $json | Should -Not -Match 'token-value' + $json | Should -Not -Match 'ShouldNeverAppear' - # Marker must appear for each surface - $json | Should -Match '"password"\s*:\s*"\[REDACTED\]"' - $json | Should -Match '"clientSecret"\s*:\s*"\[REDACTED\]"' - $json | Should -Match '"accessToken"\s*:\s*"\[REDACTED\]"' + $json | Should -Match '"password"\s*:\s*"\[REDACTED\]"' + $json | Should -Match '"clientSecret"\s*:\s*"\[REDACTED\]"' + $json | Should -Match '"accessToken"\s*:\s*"\[REDACTED\]"' + } } - It 'redacts provider secrets in the returned execution result (Providers surface)' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - Request = [pscustomobject]@{ - PSTypeName = 'IdLE.LifecycleRequest' - Type = 'Joiner' - CorrelationId = 'corr-002' - Actor = 'tester' + Context 'Execution results' { + It 'redacts provider secrets in the returned execution result (Providers surface)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + Type = 'Joiner' + CorrelationId = 'corr-002' + Actor = 'tester' + } + Steps = @() } - Steps = @() - } - $providers = @{ - Directory = @{ - endpoint = 'https://example.test' - clientSecret = 'TopSecret' - token = 'abc123' - } - Mail = @{ - apiKey = 'ShouldNotLeak' + $providers = @{ + Directory = @{ + endpoint = 'https://example.test' + clientSecret = 'TopSecret' + token = 'abc123' + } + Mail = @{ + apiKey = 'ShouldNotLeak' + } } - } - $result = Invoke-IdlePlanObject -Plan $plan -Providers $providers -EventSink $null + $result = Invoke-IdlePlanObject -Plan $plan -Providers $providers -EventSink $null - # Original must remain unchanged - $providers.Directory.clientSecret | Should -Be 'TopSecret' - $providers.Directory.token | Should -Be 'abc123' - $providers.Mail.apiKey | Should -Be 'ShouldNotLeak' + $providers.Directory.clientSecret | Should -Be 'TopSecret' + $providers.Directory.token | Should -Be 'abc123' + $providers.Mail.apiKey | Should -Be 'ShouldNotLeak' - # Result must be redacted - $result.Providers.Directory.clientSecret | Should -Be '[REDACTED]' - $result.Providers.Directory.token | Should -Be '[REDACTED]' - $result.Providers.Mail.apiKey | Should -Be '[REDACTED]' - } - - It 'redacts AuthSessionBroker secrets and does not leak broker methods in the returned execution result (Providers surface)' { - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - Request = [pscustomobject]@{ - PSTypeName = 'IdLE.LifecycleRequest' - Type = 'Joiner' - CorrelationId = 'corr-003' - Actor = 'tester' - } - Steps = @() + $result.Providers.Directory.clientSecret | Should -Be '[REDACTED]' + $result.Providers.Directory.token | Should -Be '[REDACTED]' + $result.Providers.Mail.apiKey | Should -Be '[REDACTED]' } - $broker = [pscustomobject]@{ - PSTypeName = 'Tests.AuthSessionBroker' - token = 'abc123' - note = 'ok' - } + It 'redacts AuthSessionBroker secrets and does not leak broker methods in the returned execution result (Providers surface)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + Type = 'Joiner' + CorrelationId = 'corr-003' + Actor = 'tester' + } + Steps = @() + } - $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][hashtable] $Options) - return [pscustomobject]@{ - PSTypeName = 'IdLE.AuthSession' - Kind = 'Test' - Name = $Name + $broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + token = 'abc123' + note = 'ok' } - } -Force - $providers = @{ - Directory = @{ - clientSecret = 'TopSecret' + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][hashtable] $Options) + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + Name = $Name + } + } -Force + + $providers = @{ + Directory = @{ + clientSecret = 'TopSecret' + } + AuthSessionBroker = $broker } - AuthSessionBroker = $broker - } - $result = Invoke-IdlePlanObject -Plan $plan -Providers $providers -EventSink $null + $result = Invoke-IdlePlanObject -Plan $plan -Providers $providers -EventSink $null - # Original broker must remain unchanged - $providers.AuthSessionBroker.token | Should -Be 'abc123' - $providers.AuthSessionBroker.note | Should -Be 'ok' - $providers.AuthSessionBroker.PSObject.Methods.Name | Should -Contain 'AcquireAuthSession' + $providers.AuthSessionBroker.token | Should -Be 'abc123' + $providers.AuthSessionBroker.note | Should -Be 'ok' + $providers.AuthSessionBroker.PSObject.Methods.Name | Should -Contain 'AcquireAuthSession' - # Result must be redacted (token) and must not include broker methods - $result.Providers.AuthSessionBroker.token | Should -Be '[REDACTED]' - $result.Providers.AuthSessionBroker.note | Should -Be 'ok' - $result.Providers.AuthSessionBroker.PSObject.Methods.Name | Should -Not -Contain 'AcquireAuthSession' + $result.Providers.AuthSessionBroker.token | Should -Be '[REDACTED]' + $result.Providers.AuthSessionBroker.note | Should -Be 'ok' + $result.Providers.AuthSessionBroker.PSObject.Methods.Name | Should -Not -Contain 'AcquireAuthSession' + } } } } diff --git a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 index bee647e6..4492eb40 100644 --- a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 +++ b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 @@ -1,259 +1,259 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - $fixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows' + $script:FixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows' } Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { + Context 'Step pack discovery' { + It 'discovers loaded step packs exporting Get-IdleStepMetadataCatalog' { + $commonModule = Get-Module -Name 'IdLE.Steps.Common' + $commonModule | Should -Not -BeNullOrEmpty + $commonModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue + + $dirSyncModule = Get-Module -Name 'IdLE.Steps.DirectorySync' + $dirSyncModule | Should -Not -BeNullOrEmpty + $dirSyncModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue + + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } - It 'discovers loaded step packs exporting Get-IdleStepMetadataCatalog' { - # Both IdLE.Steps.Common and IdLE.Steps.DirectorySync should be loaded - $commonModule = Get-Module -Name 'IdLE.Steps.Common' - $commonModule | Should -Not -BeNullOrEmpty - $commonModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue - - $dirSyncModule = Get-Module -Name 'IdLE.Steps.DirectorySync' - $dirSyncModule | Should -Not -BeNullOrEmpty - $dirSyncModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue - - # Create a minimal workflow to trigger catalog resolution - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - $provider = [pscustomobject]@{ Name = 'IdentityProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable') - } -Force - - $providers = @{ - IdentityProvider = $provider - } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - # This will internally call Resolve-IdleStepMetadataCatalog - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - # Verify the catalog was used - the step should have capabilities from metadata - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' - } - - It 'merges catalogs from multiple step packs deterministically' { - # Create workflows that use steps from both Common and DirectorySync - $wfPathDirSync = Join-Path -Path $fixturesPath -ChildPath 'joiner-with-dirsync.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') - } -Force - - $providers = @{ - DirectorySync = $provider + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' } - $plan = New-IdlePlan -WorkflowPath $wfPathDirSync -Request $req -Providers $providers + It 'merges catalogs from multiple step packs deterministically' { + $wfPathDirSync = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-with-dirsync.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - # DirectorySync step should have correct capabilities - $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' - } + $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } -Force - It 'allows host to supplement with new step types not in step packs' { - # Create a workflow with a custom step type - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - $provider = [pscustomobject]@{ Name = 'CustomProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('Custom.Capability.Test') - } -Force - - $providers = @{ - StepRegistry = @{ - 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + $providers = @{ + DirectorySync = $provider } - StepMetadata = @{ - 'Custom.Step.Unknown' = @{ - RequiredCapabilities = @('Custom.Capability.Test') - } - } - CustomProvider = $provider - } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - # Custom step should have host-provided capabilities - $plan.Steps[0].RequiresCapabilities | Should -Contain 'Custom.Capability.Test' + $plan = New-IdlePlan -WorkflowPath $wfPathDirSync -Request $req -Providers $providers + + $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' + } } - It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + Context 'Host metadata supplements' { + It 'allows host to supplement with new step types not in step packs' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $provider = [pscustomobject]@{ Name = 'IdentityProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('Custom.Capability.Override') - } -Force + $provider = [pscustomobject]@{ Name = 'CustomProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Test') + } -Force - $providers = @{ - IdentityProvider = $provider - StepMetadata = @{ - 'IdLE.Step.DisableIdentity' = @{ - RequiredCapabilities = @('Custom.Capability.Override') + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + StepMetadata = @{ + 'Custom.Step.Unknown' = @{ + RequiredCapabilities = @('Custom.Capability.Test') + } } + CustomProvider = $provider } - } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' - $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' - $_.Exception.Message | Should -Match 'IdLE.Steps.Common' - $_.Exception.Message | Should -Match 'supplement' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].RequiresCapabilities | Should -Contain 'Custom.Capability.Test' } - } - It 'validates metadata does not contain ScriptBlocks (host supplement)' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Override') + } -Force + + $providers = @{ + IdentityProvider = $provider + StepMetadata = @{ + 'IdLE.Step.DisableIdentity' = @{ + RequiredCapabilities = @('Custom.Capability.Override') + } + } + } - $providers = @{ - StepRegistry = @{ - 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' } - StepMetadata = @{ - 'Custom.Step.Unknown' = @{ - RequiredCapabilities = { 'Dynamic.Cap' } - } + catch { + $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + $_.Exception.Message | Should -Match 'IdLE.Steps.Common' + $_.Exception.Message | Should -Match 'supplement' } } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'ScriptBlock' + It 'validates metadata does not contain ScriptBlocks (host supplement)' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + StepMetadata = @{ + 'Custom.Step.Unknown' = @{ + RequiredCapabilities = { 'Dynamic.Cap' } + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'ScriptBlock' + } } } } Describe 'New-IdlePlan - step metadata catalog integration' { - - It 'fails fast with MissingStepTypeMetadata when step type has no metadata' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - # Provide a custom StepRegistry for the unknown step type - $providers = @{ - StepRegistry = @{ - 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + Context 'Missing metadata' { + It 'fails fast with MissingStepTypeMetadata when step type has no metadata' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } } - } - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'MissingStepTypeMetadata' - $_.Exception.Message | Should -Match 'Custom.Step.Unknown' - $_.Exception.Message | Should -Match 'Import/load the step pack' - $_.Exception.Message | Should -Match 'Providers.StepMetadata' + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingStepTypeMetadata' + $_.Exception.Message | Should -Match 'Custom.Step.Unknown' + $_.Exception.Message | Should -Match 'Import/load the step pack' + $_.Exception.Message | Should -Match 'Providers.StepMetadata' + } } } - It 'derives capabilities from step pack metadata (IdLE.Step.DisableIdentity)' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + Context 'Step pack metadata' { + It 'derives capabilities from step pack metadata (IdLE.Step.DisableIdentity)' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force - $provider = [pscustomobject]@{ Name = 'IdentityProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable') - } -Force + $providers = @{ + IdentityProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $providers = @{ - IdentityProvider = $provider + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + It 'derives capabilities from DirectorySync step pack (IdLE.Step.TriggerDirectorySync)' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-with-dirsync.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $plan | Should -Not -BeNullOrEmpty - $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' - } + $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } -Force - It 'derives capabilities from DirectorySync step pack (IdLE.Step.TriggerDirectorySync)' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-with-dirsync.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $providers = @{ + DirectorySync = $provider + } - $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') - } -Force + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $providers = @{ - DirectorySync = $provider + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + It 'validates OnFailureSteps capabilities from metadata' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-onfailure.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - $plan | Should -Not -BeNullOrEmpty - $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' - } + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force - It 'validates OnFailureSteps capabilities from metadata' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-onfailure.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $providers = @{ + IdentityProvider = $provider + } - $provider = [pscustomobject]@{ Name = 'IdentityProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Disable') - } -Force + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $providers = @{ - IdentityProvider = $provider + $plan | Should -Not -BeNullOrEmpty + $plan.OnFailureSteps.Count | Should -Be 1 + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan | Should -Not -BeNullOrEmpty - $plan.OnFailureSteps.Count | Should -Be 1 - $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' - } - - It 'validates entitlement capabilities from metadata' { - $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-entitlements.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + It 'validates entitlement capabilities from metadata' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-entitlements.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'MissingCapabilities' - $_.Exception.Message | Should -Match 'IdLE\.Entitlement' - } + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Entitlement' + } - $provider = [pscustomobject]@{ Name = 'EntProvider' } - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') - } -Force + $provider = [pscustomobject]@{ Name = 'EntProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + } -Force - $providers = @{ Entitlement = $provider } + $providers = @{ Entitlement = $provider } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $plan | Should -Not -BeNullOrEmpty - $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.List' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Grant' - $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Revoke' + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.List' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Grant' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Revoke' + } } } + diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 67e444a2..d6e23675 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -1,20 +1,48 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - - # Helper to get fixture workflow path + + function global:Invoke-IdleTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + $script:FixtureRoot = Join-Path $PSScriptRoot '..' 'fixtures/workflows/template-tests' + function Get-TemplateTestFixture { param([string]$Name) - return Join-Path $PSScriptRoot ".." "fixtures/workflows/template-tests/$Name.psd1" + return Join-Path $script:FixtureRoot "$Name.psd1" } } +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue +} + 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 @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserPrincipalName = 'jdoe@example.com' } $providers = @{ @@ -32,7 +60,7 @@ Describe 'Template Substitution' { It 'resolves Request.DesiredState placeholder directly' { $wfPath = Get-TemplateTestFixture 'template-desiredstate' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Department = 'Engineering' } $providers = @{ @@ -52,7 +80,7 @@ Describe 'Template Substitution' { It 'resolves multiple placeholders in a single string' { $wfPath = Get-TemplateTestFixture 'template-multiple' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ DisplayName = 'John Doe' UserPrincipalName = 'jdoe@example.com' } @@ -73,7 +101,7 @@ Describe 'Template Substitution' { It 'resolves templates in nested hashtables' { $wfPath = Get-TemplateTestFixture 'template-nested-hash' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ DisplayName = 'Jane Smith' Mail = 'jsmith@example.com' } @@ -93,7 +121,7 @@ Describe 'Template Substitution' { It 'resolves templates in arrays' { $wfPath = Get-TemplateTestFixture 'template-array' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ PrimaryEmail = 'primary@example.com' SecondaryEmail = 'secondary@example.com' } @@ -115,7 +143,7 @@ Describe 'Template Substitution' { It 'throws on unbalanced opening brace' { $wfPath = Get-TemplateTestFixture 'template-unbalanced-open' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -128,7 +156,7 @@ Describe 'Template Substitution' { It 'throws on unbalanced closing brace' { $wfPath = Get-TemplateTestFixture 'template-unbalanced-close' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -143,7 +171,7 @@ Describe 'Template Substitution' { It 'throws on path with spaces' { $wfPath = Get-TemplateTestFixture 'template-path-spaces' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -156,7 +184,7 @@ Describe 'Template Substitution' { It 'throws on path with special characters' { $wfPath = Get-TemplateTestFixture 'template-path-special' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -171,7 +199,7 @@ Describe 'Template Substitution' { It 'throws when path does not exist' { $wfPath = Get-TemplateTestFixture 'template-missing-path' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -186,7 +214,7 @@ Describe 'Template Substitution' { It 'throws when resolved value is null' { $wfPath = Get-TemplateTestFixture 'template-null-value' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ NullField = $null } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ NullField = $null } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -201,7 +229,7 @@ Describe 'Template Substitution' { It 'throws when accessing Plan root' { $wfPath = Get-TemplateTestFixture 'template-plan-root' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -214,7 +242,7 @@ Describe 'Template Substitution' { It 'throws when accessing Providers root' { $wfPath = Get-TemplateTestFixture 'template-providers-root' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -227,7 +255,7 @@ Describe 'Template Substitution' { It 'throws when accessing Workflow root' { $wfPath = Get-TemplateTestFixture 'template-workflow-root' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -265,7 +293,7 @@ Describe 'Template Substitution' { $wfPath = Get-TemplateTestFixture 'template-input-alias' # Use standard request without explicit Input property - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'FromDesiredState' } @@ -284,7 +312,7 @@ Describe 'Template Substitution' { It 'handles escaped opening braces' { $wfPath = Get-TemplateTestFixture 'template-escaped' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -298,7 +326,7 @@ Describe 'Template Substitution' { It 'handles escaped braces mixed with templates' { $wfPath = Get-TemplateTestFixture 'template-escaped-mixed' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'TestName' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'TestName' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -314,7 +342,7 @@ Describe 'Template Substitution' { It 'resolves templates in OnFailureSteps' { $wfPath = Get-TemplateTestFixture 'template-onfailure' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'John Doe' UserPrincipalName = 'jdoe@example.com' } @@ -336,7 +364,7 @@ Describe 'Template Substitution' { It 'allows Request.LifecycleEvent' { $wfPath = Get-TemplateTestFixture 'template-lifecycle-event' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -350,7 +378,7 @@ Describe 'Template Substitution' { It 'allows Request.CorrelationId' { $wfPath = Get-TemplateTestFixture 'template-correlation-id' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -364,7 +392,7 @@ Describe 'Template Substitution' { It 'allows Request.Actor' { $wfPath = Get-TemplateTestFixture 'template-actor' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } -Actor 'admin@example.com' + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } -Actor 'admin@example.com' $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -380,7 +408,7 @@ Describe 'Template Substitution' { It 'resolves numeric types to strings' { $wfPath = Get-TemplateTestFixture 'template-numeric' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -394,7 +422,7 @@ Describe 'Template Substitution' { It 'resolves boolean types to strings' { $wfPath = Get-TemplateTestFixture 'template-boolean' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsEnabled = $true } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsEnabled = $true } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -408,7 +436,7 @@ Describe 'Template Substitution' { It 'throws when resolving to a hashtable' { $wfPath = Get-TemplateTestFixture 'template-hashtable' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserData = @{ Name = 'John'; Age = 30 } } $providers = @{ @@ -423,7 +451,7 @@ Describe 'Template Substitution' { It 'throws when resolving to an array' { $wfPath = Get-TemplateTestFixture 'template-array-value' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Tags = @('tag1', 'tag2') } $providers = @{ @@ -440,7 +468,7 @@ Describe 'Template Substitution' { It 'preserves boolean false type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-boolean-false' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -457,7 +485,7 @@ Describe 'Template Substitution' { It 'preserves boolean true type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-boolean-true' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsActive = $true } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsActive = $true } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -472,7 +500,7 @@ Describe 'Template Substitution' { It 'preserves integer type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-integer' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -488,7 +516,7 @@ Describe 'Template Substitution' { $wfPath = Get-TemplateTestFixture 'template-pure-datetime' $testDate = Get-Date '2026-01-15T10:00:00' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ StartDate = $testDate } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ StartDate = $testDate } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -504,7 +532,7 @@ Describe 'Template Substitution' { $wfPath = Get-TemplateTestFixture 'template-pure-guid' $testGuid = [guid]::NewGuid() - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ ObjectId = $testGuid } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ ObjectId = $testGuid } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -519,7 +547,7 @@ Describe 'Template Substitution' { It 'converts to string for mixed template (string interpolation)' { $wfPath = Get-TemplateTestFixture 'template-mixed-boolean' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -536,3 +564,4 @@ Describe 'Template Substitution' { # through direct unit testing due to test harness limitations. The security checks # are applied regardless of pure/mixed template mode as verified by manual testing. } + diff --git a/tests/Core/Test-IdleCondition.Tests.ps1 b/tests/Core/Test-IdleCondition.Tests.ps1 index 6d173597..544c71d1 100644 --- a/tests/Core/Test-IdleCondition.Tests.ps1 +++ b/tests/Core/Test-IdleCondition.Tests.ps1 @@ -1,3 +1,5 @@ +Set-StrictMode -Version Latest + BeforeDiscovery { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule @@ -13,7 +15,7 @@ Describe 'Condition DSL (schema + evaluator)' { Get-Command Test-IdleCondition -ErrorAction Stop | Out-Null } - Describe 'Test-IdleConditionSchema' { + Context 'Schema validation' { It 'accepts an Equals operator with Path + Value' { $condition = @{ @@ -119,7 +121,7 @@ Describe 'Condition DSL (schema + evaluator)' { } } - Describe 'Test-IdleCondition' { + Context 'Evaluation' { It 'returns true when Equals matches' { $context = [pscustomobject]@{ diff --git a/tests/Core/Test-IdleWorkflow.Tests.ps1 b/tests/Core/Test-IdleWorkflow.Tests.ps1 index 44ab4881..10a34ad5 100644 --- a/tests/Core/Test-IdleWorkflow.Tests.ps1 +++ b/tests/Core/Test-IdleWorkflow.Tests.ps1 @@ -1,13 +1,14 @@ +Set-StrictMode -Version Latest + BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule } Describe 'Test-IdleWorkflow' { - - It 'returns a valid result for a minimal correct workflow' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Validation' { + It 'returns a valid result for a minimal correct workflow' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -17,18 +18,17 @@ Describe 'Test-IdleWorkflow' { } '@ - $result = Test-IdleWorkflow -WorkflowPath $wfPath + $result = Test-IdleWorkflow -WorkflowPath $wfPath - $result | Should -Not -BeNullOrEmpty - $result.IsValid | Should -BeTrue - $result.WorkflowName | Should -Be 'Joiner - Standard' - $result.LifecycleEvent | Should -Be 'Joiner' - $result.StepCount | Should -Be 1 - } + $result | Should -Not -BeNullOrEmpty + $result.IsValid | Should -BeTrue + $result.WorkflowName | Should -Be 'Joiner - Standard' + $result.LifecycleEvent | Should -Be 'Joiner' + $result.StepCount | Should -Be 1 + } - It 'accepts OnFailureSteps as an optional top-level section' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'accepts OnFailureSteps as an optional top-level section' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-onfailure.psd1' -Content @' @{ Name = 'Joiner - OnFailure' LifecycleEvent = 'Joiner' @@ -41,20 +41,19 @@ Describe 'Test-IdleWorkflow' { } '@ - { Test-IdleWorkflow -WorkflowPath $wfPath } | Should -Not -Throw - - $result = Test-IdleWorkflow -WorkflowPath $wfPath - $result.IsValid | Should -BeTrue - $result.WorkflowName | Should -Be 'Joiner - OnFailure' - $result.LifecycleEvent | Should -Be 'Joiner' + { Test-IdleWorkflow -WorkflowPath $wfPath } | Should -Not -Throw - # Test-IdleWorkflow returns a small report; StepCount reflects primary Steps only. - $result.StepCount | Should -Be 1 + $result = Test-IdleWorkflow -WorkflowPath $wfPath + $result.IsValid | Should -BeTrue + $result.WorkflowName | Should -Be 'Joiner - OnFailure' + $result.LifecycleEvent | Should -Be 'Joiner' + $result.StepCount | Should -Be 1 + } } - It 'rejects unknown root keys such as CleanupSteps' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-cleanupsteps.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + Context 'Schema errors' { + It 'rejects unknown root keys such as CleanupSteps' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-cleanupsteps.psd1' -Content @' @{ Name = 'Joiner - Invalid' LifecycleEvent = 'Joiner' @@ -67,19 +66,18 @@ Describe 'Test-IdleWorkflow' { } '@ - try { - Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'Unknown root key' - $_.Exception.Message | Should -Match 'CleanupSteps' + try { + Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'Unknown root key' + $_.Exception.Message | Should -Match 'CleanupSteps' + } } - } - It 'fails when workflow LifecycleEvent does not match request LifecycleEvent' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-mismatch.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + It 'fails when workflow LifecycleEvent does not match request LifecycleEvent' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-mismatch.psd1' -Content @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -89,14 +87,15 @@ Describe 'Test-IdleWorkflow' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' - try { - Test-IdleWorkflow -WorkflowPath $wfPath -Request $req | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'does not match request LifecycleEvent' + try { + Test-IdleWorkflow -WorkflowPath $wfPath -Request $req | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'does not match request LifecycleEvent' + } } } } diff --git a/tests/Examples/WorkflowSamples.Tests.ps1 b/tests/Examples/WorkflowSamples.Tests.ps1 index 3f4c35f9..c4973a9c 100644 --- a/tests/Examples/WorkflowSamples.Tests.ps1 +++ b/tests/Examples/WorkflowSamples.Tests.ps1 @@ -7,75 +7,71 @@ BeforeAll { $workflowsPath = Join-Path -Path (Get-RepoRootPath) -ChildPath 'examples/workflows' } -Describe 'Mock example workflows' { - BeforeAll { - $mockWorkflowsPath = Join-Path -Path $workflowsPath -ChildPath 'mock' - $mockWorkflows = Get-ChildItem -Path $mockWorkflowsPath -Filter '*.psd1' -File -ErrorAction SilentlyContinue - } - - It 'Mock workflow directory exists' { - $mockWorkflowsPath | Should -Exist - } - - It 'Mock workflows exist' { - $mockWorkflows | Should -Not -BeNullOrEmpty - } +Describe 'Example workflows' { + Context 'Mock workflows' { + BeforeAll { + $mockWorkflowsPath = Join-Path -Path $workflowsPath -ChildPath 'mock' + $mockWorkflows = Get-ChildItem -Path $mockWorkflowsPath -Filter '*.psd1' -File -ErrorAction SilentlyContinue + } - It 'All mock workflows validate with Test-IdleWorkflow' { - foreach ($file in $mockWorkflows) { - { Test-IdleWorkflow -WorkflowPath $file.FullName } | Should -Not -Throw + It 'discovers mock workflow files' { + $mockWorkflowsPath | Should -Exist + $mockWorkflows | Should -Not -BeNullOrEmpty } - } - It 'All mock workflows can create a plan with Mock provider' { - $providers = @{ - Identity = New-IdleMockIdentityProvider + It 'validates mock workflows with Test-IdleWorkflow' { + foreach ($file in $mockWorkflows) { + { Test-IdleWorkflow -WorkflowPath $file.FullName } | Should -Not -Throw + } } - foreach ($file in $mockWorkflows) { - $workflow = Import-PowerShellDataFile -Path $file.FullName - $lifecycleEvent = if ($workflow.ContainsKey('LifecycleEvent')) { $workflow.LifecycleEvent } else { 'Joiner' } - $request = New-IdleLifecycleRequest -LifecycleEvent $lifecycleEvent -Actor 'test-user' + It 'creates a plan for every mock workflow' { + $providers = @{ Identity = New-IdleMockIdentityProvider } + + foreach ($file in $mockWorkflows) { + $workflow = Import-PowerShellDataFile -Path $file.FullName + $lifecycleEvent = if ($workflow.ContainsKey('LifecycleEvent')) { $workflow.LifecycleEvent } else { 'Joiner' } + $request = New-IdleTestRequest -LifecycleEvent $lifecycleEvent -Actor 'test-user' - { New-IdlePlan -WorkflowPath $file.FullName -Request $request -Providers $providers } | Should -Not -Throw + { New-IdlePlan -WorkflowPath $file.FullName -Request $request -Providers $providers } | Should -Not -Throw + } } - } - It 'All mock workflows execute successfully with Mock provider' { - $providers = @{ - Identity = New-IdleMockIdentityProvider - } + It 'executes every mock workflow successfully' { + $providers = @{ Identity = New-IdleMockIdentityProvider } - foreach ($file in $mockWorkflows) { - $workflow = Import-PowerShellDataFile -Path $file.FullName - $lifecycleEvent = if ($workflow.ContainsKey('LifecycleEvent')) { $workflow.LifecycleEvent } else { 'Joiner' } - $request = New-IdleLifecycleRequest -LifecycleEvent $lifecycleEvent -Actor 'test-user' + foreach ($file in $mockWorkflows) { + $workflow = Import-PowerShellDataFile -Path $file.FullName + $lifecycleEvent = if ($workflow.ContainsKey('LifecycleEvent')) { $workflow.LifecycleEvent } else { 'Joiner' } + $request = New-IdleTestRequest -LifecycleEvent $lifecycleEvent -Actor 'test-user' - $plan = New-IdlePlan -WorkflowPath $file.FullName -Request $request -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $plan = New-IdlePlan -WorkflowPath $file.FullName -Request $request -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' -Because "Mock workflow '$($file.Name)' should complete successfully" + $result.Status | Should -Be 'Completed' -Because "Mock workflow '$($file.Name)' should complete successfully" + } } } -} -Describe 'Template example workflows' { - BeforeAll { - $templatesWorkflowsPath = Join-Path -Path $workflowsPath -ChildPath 'templates' - $templateWorkflows = Get-ChildItem -Path $templatesWorkflowsPath -Filter '*.psd1' -File -ErrorAction SilentlyContinue - } + Context 'Template workflows' { + BeforeAll { + $templatesWorkflowsPath = Join-Path -Path $workflowsPath -ChildPath 'templates' + $templateWorkflows = Get-ChildItem -Path $templatesWorkflowsPath -Filter '*.psd1' -File -ErrorAction SilentlyContinue + } - It 'Templates workflow directory exists' { - $templatesWorkflowsPath | Should -Exist - } + It 'discovers template workflow files' { + $templatesWorkflowsPath | Should -Exist + } - It 'All template workflows validate with Test-IdleWorkflow (if any exist)' { - if ($templateWorkflows) { - foreach ($file in $templateWorkflows) { - { Test-IdleWorkflow -WorkflowPath $file.FullName } | Should -Not -Throw + It 'validates template workflows (if any exist)' { + if ($templateWorkflows) { + foreach ($file in $templateWorkflows) { + { Test-IdleWorkflow -WorkflowPath $file.FullName } | Should -Not -Throw + } + } else { + Set-ItResult -Skipped -Because 'No template workflows exist yet' } - } else { - Set-ItResult -Skipped -Because 'No template workflows exist yet' } } } + diff --git a/tests/Packaging/ModuleManifests.Tests.ps1 b/tests/Packaging/ModuleManifests.Tests.ps1 index b41905a7..204d188d 100644 --- a/tests/Packaging/ModuleManifests.Tests.ps1 +++ b/tests/Packaging/ModuleManifests.Tests.ps1 @@ -6,12 +6,14 @@ BeforeAll { } Describe 'Module manifests' { - It 'All module manifests under src/ are valid' { - $paths = Get-ModuleManifestPaths - $paths | Should -Not -BeNullOrEmpty + Context 'Validation' { + It 'All module manifests under src/ are valid' { + $paths = Get-ModuleManifestPaths + $paths | Should -Not -BeNullOrEmpty - foreach ($path in $paths) { - { Test-ModuleManifest -Path $path -ErrorAction Stop } | Should -Not -Throw + foreach ($path in $paths) { + { Test-ModuleManifest -Path $path -ErrorAction Stop } | Should -Not -Throw + } } } } diff --git a/tests/Packaging/ModuleSurface.Tests.ps1 b/tests/Packaging/ModuleSurface.Tests.ps1 index cf60039c..2a156012 100644 --- a/tests/Packaging/ModuleSurface.Tests.ps1 +++ b/tests/Packaging/ModuleSurface.Tests.ps1 @@ -1,9 +1,11 @@ +Set-StrictMode -Version Latest + BeforeAll { - $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..') - $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' + $script:RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..') + $script:IdlePsd1 = Join-Path $script:RepoRoot 'src\IdLE\IdLE.psd1' + $script:CorePsd1 = Join-Path $script:RepoRoot 'src\IdLE.Core\IdLE.Core.psd1' + $script:StepsPsd1 = Join-Path $script:RepoRoot 'src\IdLE.Steps.Common\IdLE.Steps.Common.psd1' + $script:ProviderMockPsd1 = Join-Path $script:RepoRoot 'src\IdLE.Provider.Mock\IdLE.Provider.Mock.psd1' . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule @@ -38,38 +40,42 @@ AfterAll { } Describe 'Module manifests and public surface' { + Context 'Manifest validation' { + It 'IdLE manifest is valid' { + { Test-ModuleManifest -Path $script:IdlePsd1 -ErrorAction Stop } | Should -Not -Throw + } - It 'IdLE manifest is valid' { - { Test-ModuleManifest -Path $idlePsd1 -ErrorAction Stop } | Should -Not -Throw - } - - It 'IdLE.Core manifest is valid' { - { Test-ModuleManifest -Path $corePsd1 -ErrorAction Stop } | Should -Not -Throw + It 'IdLE.Core manifest is valid' { + { Test-ModuleManifest -Path $script:CorePsd1 -ErrorAction Stop } | Should -Not -Throw + } } - It 'IdLE exports only the intended public commands' { - Remove-Module IdLE -Force -ErrorAction SilentlyContinue - Import-Module $idlePsd1 -Force -ErrorAction Stop - - $expected = @( - 'Invoke-IdlePlan' - 'New-IdleAuthSession' - 'New-IdleLifecycleRequest' - 'New-IdlePlan' - 'Test-IdleWorkflow' - 'Export-IdlePlan' - ) | Sort-Object - - $actual = (Get-Command -Module IdLE).Name | Sort-Object - $actual | Should -Be $expected + Context 'Public commands' { + It 'IdLE exports only the intended public commands' { + Remove-Module IdLE -Force -ErrorAction SilentlyContinue + Import-Module $script:IdlePsd1 -Force -ErrorAction Stop + + $expected = @( + 'Invoke-IdlePlan' + 'New-IdleAuthSession' + 'New-IdleRequest' + 'New-IdlePlan' + 'Test-IdleWorkflow' + 'Export-IdlePlan' + ) | Sort-Object + + $actual = (Get-Command -Module IdLE).Name | Sort-Object + $actual | Should -Be $expected + } } - It 'Invoke-IdlePlan returns a public execution result that includes an OnFailure section' { - Remove-Module IdLE -Force -ErrorAction SilentlyContinue - Import-Module $idlePsd1 -Force -ErrorAction Stop + Context 'Execution result contract' { + It 'Invoke-IdlePlan returns a public execution result that includes an OnFailure section' { + Remove-Module IdLE -Force -ErrorAction SilentlyContinue + Import-Module $script:IdlePsd1 -Force -ErrorAction Stop - $wfPath = Join-Path -Path $TestDrive -ChildPath 'surface-onfailure.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'surface-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Surface - OnFailure Contract' LifecycleEvent = 'Joiner' @@ -81,127 +87,130 @@ Describe 'Module manifests and public surface' { ) } '@ + $req = New-IdleRequest -LifecycleEvent 'Joiner' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Primary' = 'Invoke-IdleSurfaceTestNoopStep' - 'IdLE.Step.Containment' = 'Invoke-IdleSurfaceTestNoopStep' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Primary' = 'Invoke-IdleSurfaceTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleSurfaceTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Primary', 'IdLE.Step.Containment') } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Primary', 'IdLE.Step.Containment') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result | Should -Not -BeNullOrEmpty - $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' - $result.Status | Should -Be 'Completed' + $result | Should -Not -BeNullOrEmpty + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' - # Public result contract: the OnFailure section is always present. - $result.PSObject.Properties.Name | Should -Contain 'OnFailure' - $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' - $result.OnFailure.Status | Should -Be 'NotRun' - @($result.OnFailure.Steps).Count | Should -Be 0 + $result.PSObject.Properties.Name | Should -Contain 'OnFailure' + $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 - # Successful runs must not emit OnFailure events. - @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + } } - It 'Importing IdLE makes built-in steps available to the engine' { - # Remove ALL IdLE modules to ensure clean state (other tests may have imported them) - Get-Module -All IdLE* | Remove-Module -Force -ErrorAction SilentlyContinue - - # Also explicitly remove commands that may have been exported in previous tests - Remove-Item Function:\Invoke-IdleStepEmitEvent -Force -ErrorAction SilentlyContinue - Remove-Item Function:\Invoke-IdleStepEnsureAttribute -Force -ErrorAction SilentlyContinue - Remove-Item Function:\Invoke-IdleStepEnsureEntitlement -Force -ErrorAction SilentlyContinue - Remove-Item Function:\New-IdlePlanObject -Force -ErrorAction SilentlyContinue - Remove-Item Function:\Invoke-IdlePlanObject -Force -ErrorAction SilentlyContinue - - Import-Module $idlePsd1 -Force -ErrorAction Stop - - # NOTE: In repo/zip layouts, PSModulePath bootstrap causes NestedModules to resolve - # via PSModulePath, which exports them globally. This is a known limitation/tradeoff - # to enable name-based imports of providers and optional steps after IdLE import. - # In published PSGallery packages, RequiredModules are used instead, maintaining proper scope. - # - # The step registry supports both: - # - Global commands: 'Invoke-IdleStepEmitEvent' (repo/zip with PSModulePath bootstrap) - # - Module-qualified: 'IdLE.Steps.Common\Invoke-IdleStepEmitEvent' (nested modules without global export) - # - # Both formats work correctly - the engine can invoke either unqualified or module-qualified handlers. - - # Verify engine discovery works and step handlers are registered - InModuleScope IdLE.Core { - $registry = Get-IdleStepRegistry -Providers $null - - $registry.ContainsKey('IdLE.Step.EmitEvent') | Should -BeTrue - # Accept both unqualified (global export) and module-qualified (nested) formats - $registry['IdLE.Step.EmitEvent'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEmitEvent$' - - $registry.ContainsKey('IdLE.Step.EnsureAttributes') | Should -BeTrue - $registry['IdLE.Step.EnsureAttributes'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEnsureAttributes$' - - $registry.ContainsKey('IdLE.Step.EnsureEntitlement') | Should -BeTrue - $registry['IdLE.Step.EnsureEntitlement'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEnsureEntitlement$' + Context 'Step registry bootstrap' { + It 'Importing IdLE makes built-in steps available to the engine' { + # Remove ALL IdLE modules to ensure clean state (other tests may have imported them) + Get-Module -All IdLE* | Remove-Module -Force -ErrorAction SilentlyContinue + + # Also explicitly remove commands that may have been exported in previous tests + Remove-Item Function:\Invoke-IdleStepEmitEvent -Force -ErrorAction SilentlyContinue + Remove-Item Function:\Invoke-IdleStepEnsureAttribute -Force -ErrorAction SilentlyContinue + Remove-Item Function:\Invoke-IdleStepEnsureEntitlement -Force -ErrorAction SilentlyContinue + Remove-Item Function:\New-IdlePlanObject -Force -ErrorAction SilentlyContinue + Remove-Item Function:\Invoke-IdlePlanObject -Force -ErrorAction SilentlyContinue + + Import-Module $script:IdlePsd1 -Force -ErrorAction Stop + + # NOTE: In repo/zip layouts, PSModulePath bootstrap causes NestedModules to resolve + # via PSModulePath, which exports them globally. This is a known limitation/tradeoff + # to enable name-based imports of providers and optional steps after IdLE import. + # In published PSGallery packages, RequiredModules are used instead, maintaining proper scope. + # + # The step registry supports both: + # - Global commands: 'Invoke-IdleStepEmitEvent' (repo/zip with PSModulePath bootstrap) + # - Module-qualified: 'IdLE.Steps.Common\Invoke-IdleStepEmitEvent' (nested modules without global export) + # + # Both formats work correctly - the engine can invoke either unqualified or module-qualified handlers. + + # Verify engine discovery works and step handlers are registered + InModuleScope IdLE.Core { + $registry = Get-IdleStepRegistry -Providers $null + + $registry.ContainsKey('IdLE.Step.EmitEvent') | Should -BeTrue + # Accept both unqualified (global export) and module-qualified (nested) formats + $registry['IdLE.Step.EmitEvent'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEmitEvent$' + + $registry.ContainsKey('IdLE.Step.EnsureAttributes') | Should -BeTrue + $registry['IdLE.Step.EnsureAttributes'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEnsureAttributes$' + + $registry.ContainsKey('IdLE.Step.EnsureEntitlement') | Should -BeTrue + $registry['IdLE.Step.EnsureEntitlement'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEnsureEntitlement$' + } } } - It 'IdLE imports IdLE.Core successfully' { - # Remove ALL IdLE modules to ensure clean state (other tests may have imported them) - Get-Module -All IdLE* | Remove-Module -Force -ErrorAction SilentlyContinue - - # Also explicitly remove commands that may have been exported in previous tests - Remove-Item Function:\New-IdlePlanObject -Force -ErrorAction SilentlyContinue - Remove-Item Function:\Invoke-IdlePlanObject -Force -ErrorAction SilentlyContinue - Remove-Item Function:\Invoke-IdleStepEmitEvent -Force -ErrorAction SilentlyContinue - Remove-Item Function:\Invoke-IdleStepEnsureAttribute -Force -ErrorAction SilentlyContinue - - Import-Module $idlePsd1 -Force -ErrorAction Stop - - # NOTE: In repo/zip layouts, PSModulePath bootstrap causes NestedModules to resolve - # via PSModulePath, which exports them globally. This is a known limitation/tradeoff. - # In published PSGallery packages, RequiredModules maintain proper scope. - # - # Verify IdLE.Core is loaded and accessible (use -All to catch nested modules) - $coreModule = Get-Module -All IdLE.Core - $coreModule | Should -Not -BeNullOrEmpty - } + Context 'Module imports' { + It 'IdLE imports IdLE.Core successfully' { + # Remove ALL IdLE modules to ensure clean state (other tests may have imported them) + Get-Module -All IdLE* | Remove-Module -Force -ErrorAction SilentlyContinue + + # Also explicitly remove commands that may have been exported in previous tests + Remove-Item Function:\New-IdlePlanObject -Force -ErrorAction SilentlyContinue + Remove-Item Function:\Invoke-IdlePlanObject -Force -ErrorAction SilentlyContinue + Remove-Item Function:\Invoke-IdleStepEmitEvent -Force -ErrorAction SilentlyContinue + Remove-Item Function:\Invoke-IdleStepEnsureAttribute -Force -ErrorAction SilentlyContinue + + Import-Module $script:IdlePsd1 -Force -ErrorAction Stop + + # NOTE: In repo/zip layouts, PSModulePath bootstrap causes NestedModules to resolve + # via PSModulePath, which exports them globally. This is a known limitation/tradeoff. + # In published PSGallery packages, RequiredModules maintain proper scope. + # + # Verify IdLE.Core is loaded and accessible (use -All to catch nested modules) + $coreModule = Get-Module -All IdLE.Core + $coreModule | Should -Not -BeNullOrEmpty + } - It 'IdLE module imports IdLE.Core and IdLE.Steps.Common via bootstrap' { - Remove-Module IdLE -Force -ErrorAction SilentlyContinue - Import-Module $idlePsd1 -Force -ErrorAction Stop + It 'IdLE module imports IdLE.Core and IdLE.Steps.Common via bootstrap' { + Remove-Module IdLE -Force -ErrorAction SilentlyContinue + Import-Module $script:IdlePsd1 -Force -ErrorAction Stop - $idle = Get-Module IdLE - $idle | Should -Not -BeNullOrEmpty + $idle = Get-Module IdLE + $idle | Should -Not -BeNullOrEmpty - # With the new name-based import approach, IdLE.Core and IdLE.Steps.Common - # are imported by IdLE.psm1 bootstrap logic, not via NestedModules in manifest. - # Verify they are loaded and accessible to the engine. - # Note: They may appear in Get-Module -All depending on import scope. - - # The key validation is that IdLE public commands work (which depend on Core) - $publicCommands = (Get-Command -Module IdLE).Name - $publicCommands | Should -Contain 'New-IdlePlan' - $publicCommands | Should -Contain 'Invoke-IdlePlan' - - # And that the engine can discover built-in steps - InModuleScope IdLE.Core { - $registry = Get-IdleStepRegistry -Providers $null - $registry.ContainsKey('IdLE.Step.EmitEvent') | Should -BeTrue + # With the new name-based import approach, IdLE.Core and IdLE.Steps.Common + # are imported by IdLE.psm1 bootstrap logic, not via NestedModules in manifest. + # Verify they are loaded and accessible to the engine. + # Note: They may appear in Get-Module -All depending on import scope. + + # The key validation is that IdLE public commands work (which depend on Core) + $publicCommands = (Get-Command -Module IdLE).Name + $publicCommands | Should -Contain 'New-IdlePlan' + $publicCommands | Should -Contain 'Invoke-IdlePlan' + + # And that the engine can discover built-in steps + InModuleScope IdLE.Core { + $registry = Get-IdleStepRegistry -Providers $null + $registry.ContainsKey('IdLE.Step.EmitEvent') | Should -BeTrue + } } } - It 'IdLE auto-imports only baseline modules (Core and Steps.Common), not optional modules' { + Context 'Baseline modules' { + It 'IdLE auto-imports only baseline modules (Core and Steps.Common), not optional modules' { # Clean test state - remove ALL IdLE modules to ensure fresh import Get-Module -All IdLE* | Remove-Module -Force -ErrorAction SilentlyContinue # Import without -Force to avoid re-importing previously loaded optional modules # (PowerShell module caching can cause previously imported modules to be re-loaded with -Force) - Import-Module $idlePsd1 -ErrorAction Stop + Import-Module $script:IdlePsd1 -ErrorAction Stop $idle = Get-Module IdLE $idle | Should -Not -BeNullOrEmpty @@ -248,7 +257,7 @@ Describe 'Module manifests and public surface' { if ($moduleName -eq 'IdLE.Steps.Mailbox') { $mailboxModule = Get-Module -All -Name $moduleName if ($mailboxModule) { - Write-Warning "IdLE.Steps.Mailbox is loaded (likely from ModuleBootstrap test). In a fresh PowerShell session, it would NOT be auto-imported." + Write-Verbose "IdLE.Steps.Mailbox is loaded (likely from ModuleBootstrap test). In a fresh PowerShell session, it would NOT be auto-imported." continue } } @@ -261,120 +270,159 @@ Describe 'Module manifests and public surface' { # re-appear when their dependencies are re-imported. # This is a known test isolation limitation in PowerShell and doesn't reflect actual module behavior. # In a fresh PowerShell session, these modules are NOT auto-imported when importing IdLE. + } } - It 'Steps module exports the intended step functions' { - Remove-Module IdLE.Steps.Common -Force -ErrorAction SilentlyContinue - Import-Module $stepsPsd1 -Force -ErrorAction Stop + Context 'Step module exports' { + It 'Steps module exports the intended step functions' { + Remove-Module IdLE.Steps.Common -Force -ErrorAction SilentlyContinue + Import-Module $script:StepsPsd1 -Force -ErrorAction Stop - $exported = (Get-Command -Module IdLE.Steps.Common).Name - $exported | Should -Contain 'Invoke-IdleStepEmitEvent' - $exported | Should -Contain 'Invoke-IdleStepEnsureAttributes' - $exported | Should -Contain 'Invoke-IdleStepEnsureEntitlement' + $exported = (Get-Command -Module IdLE.Steps.Common).Name + $exported | Should -Contain 'Invoke-IdleStepEmitEvent' + $exported | Should -Contain 'Invoke-IdleStepEnsureAttributes' + $exported | Should -Contain 'Invoke-IdleStepEnsureEntitlement' + } } - It 'IdLE.Provider.Mock manifest is valid' { - { Test-ModuleManifest -Path $providerMockPsd1 -ErrorAction Stop } | Should -Not -Throw - } + Context 'Mock provider exports' { + It 'IdLE.Provider.Mock manifest is valid' { + { Test-ModuleManifest -Path $script: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 + It 'Mock provider module exports the intended provider function' { + Remove-Module IdLE.Provider.Mock -Force -ErrorAction SilentlyContinue + Import-Module $script:ProviderMockPsd1 -Force -ErrorAction Stop - (Get-Command -Module IdLE.Provider.Mock).Name | Should -Contain 'New-IdleMockIdentityProvider' + (Get-Command -Module IdLE.Provider.Mock).Name | Should -Contain 'New-IdleMockIdentityProvider' + } } Context 'Internal module import warnings' { It 'IdLE.Core emits warning when imported directly' { - $existingModule = Get-Module -Name IdLE.Core - if ($existingModule) { - Set-ItResult -Skipped -Because "IdLE.Core is already loaded; cannot test direct import warning" - return - } - - $originalValue = $env:IDLE_ALLOW_INTERNAL_IMPORT - $originalPSModulePath = $env:PSModulePath - try { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $null - # Remove src/ from PSModulePath to ensure warning is triggered - # corePsd1 is like /repo/src/IdLE.Core/IdLE.Core.psd1, so go up two levels to get /repo/src - $srcPath = Split-Path (Split-Path $corePsd1 -Parent) -Parent - $env:PSModulePath = ($env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object { $_ -ne $srcPath }) -join [System.IO.Path]::PathSeparator - - # Import and capture warning output - $output = Import-Module $corePsd1 -Force 3>&1 | Out-String - - $output | Should -Not -BeNullOrEmpty -Because "Internal module should emit warning on direct import" - $output | Should -Match "internal.*unsupported.*IdLE.*instead" -Because "Warning should indicate module is internal and suggest importing IdLE" - $output | Should -Match '\$env:IDLE_ALLOW_INTERNAL_IMPORT' -Because "Warning should show correct PowerShell syntax for bypass" - } - finally { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $originalValue - $env:PSModulePath = $originalPSModulePath - Remove-Module IdLE.Core -Force -ErrorAction SilentlyContinue + $srcPath = Split-Path (Split-Path $script:CorePsd1 -Parent) -Parent + $pathSeparator = [System.IO.Path]::PathSeparator + $paths = $env:PSModulePath -split [regex]::Escape($pathSeparator) + + $filteredPaths = foreach ($path in $paths) { + if (-not $path) { continue } + $resolvedPath = Resolve-Path -Path $path -ErrorAction SilentlyContinue + $resolvedSrc = Resolve-Path -Path $srcPath -ErrorAction SilentlyContinue + if ($resolvedPath -and $resolvedSrc -and $resolvedPath.Path -eq $resolvedSrc.Path) { continue } + $path } + + $corePsd1Escaped = $script:CorePsd1 -replace "'", "''" + $script = @" + +`$warnings = & { Import-Module '$corePsd1Escaped' -Force -WarningAction Continue } 3>&1 | + Where-Object { `$_ -is [System.Management.Automation.WarningRecord] } + +if (`$warnings) { + (`$warnings | ForEach-Object { `$_.ToString() }) -join "`n" +} else { + '' +} +"@ + + $result = Invoke-IdleIsolatedPwsh -Script $script -Environment @{ + IDLE_ALLOW_INTERNAL_IMPORT = '' + PSModulePath = ($filteredPaths -join $pathSeparator) + } -WorkingDirectory $script:RepoRoot + + $result.ExitCode | Should -Be 0 + $output = ($result.StdOut + $result.StdErr).Trim() + $output | Should -Not -BeNullOrEmpty -Because "Internal module should emit warning on direct import" + $output | Should -Match "internal.*unsupported.*IdLE.*instead" -Because "Warning should indicate module is internal and suggest importing IdLE" + $output | Should -Match '\$env:IDLE_ALLOW_INTERNAL_IMPORT' -Because "Warning should show correct PowerShell syntax for bypass" } It 'IdLE.Core does not emit warning when IDLE_ALLOW_INTERNAL_IMPORT is set' { - $existingModule = Get-Module -Name IdLE.Core - if ($existingModule) { - Set-ItResult -Skipped -Because "IdLE.Core is already loaded; cannot test bypass" - return - } - - $originalValue = $env:IDLE_ALLOW_INTERNAL_IMPORT - try { - $env:IDLE_ALLOW_INTERNAL_IMPORT = '1' - - # Import and capture warning output - $output = Import-Module $corePsd1 -Force 3>&1 | Out-String - - $output | Should -BeNullOrEmpty -Because "Internal module should not emit warning when bypass is set" - } - finally { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $originalValue - Remove-Module IdLE.Core -Force -ErrorAction SilentlyContinue - } + $corePsd1Escaped = $script:CorePsd1 -replace "'", "''" + $script = @" + +`$warnings = & { Import-Module '$corePsd1Escaped' -Force -WarningAction Continue } 3>&1 | + Where-Object { `$_ -is [System.Management.Automation.WarningRecord] } + +if (`$warnings) { + (`$warnings | ForEach-Object { `$_.ToString() }) -join "`n" +} else { + '' +} +"@ + + $result = Invoke-IdleIsolatedPwsh -Script $script -Environment @{ + IDLE_ALLOW_INTERNAL_IMPORT = '1' + } -WorkingDirectory $script:RepoRoot + + $result.ExitCode | Should -Be 0 + ($result.StdOut + $result.StdErr).Trim() | Should -BeNullOrEmpty -Because "Internal module should not emit warning when bypass is set" } It 'IdLE.Steps.Common emits warning when imported directly' { - $existingModule = Get-Module -Name IdLE.Steps.Common - if ($existingModule) { - Set-ItResult -Skipped -Because "IdLE.Steps.Common is already loaded; cannot test direct import warning" - return - } - - $originalValue = $env:IDLE_ALLOW_INTERNAL_IMPORT - try { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $null - - # Import and capture warning output - $output = Import-Module $stepsPsd1 -Force 3>&1 | Out-String - - $output | Should -Not -BeNullOrEmpty -Because "Internal module should emit warning on direct import" - $output | Should -Match "internal.*unsupported.*IdLE.*instead" -Because "Warning should indicate module is internal and suggest importing IdLE" - $output | Should -Match '\$env:IDLE_ALLOW_INTERNAL_IMPORT' -Because "Warning should show correct PowerShell syntax for bypass" - } - finally { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $originalValue - Remove-Module IdLE.Steps.Common -Force -ErrorAction SilentlyContinue + $srcPath = Split-Path (Split-Path $script:StepsPsd1 -Parent) -Parent + $pathSeparator = [System.IO.Path]::PathSeparator + $paths = $env:PSModulePath -split [regex]::Escape($pathSeparator) + + $filteredPaths = foreach ($path in $paths) { + if (-not $path) { continue } + $resolvedPath = Resolve-Path -Path $path -ErrorAction SilentlyContinue + $resolvedSrc = Resolve-Path -Path $srcPath -ErrorAction SilentlyContinue + if ($resolvedPath -and $resolvedSrc -and $resolvedPath.Path -eq $resolvedSrc.Path) { continue } + $path } + + $corePsd1Escaped = $script:CorePsd1 -replace "'", "''" + $stepsPsd1Escaped = $script:StepsPsd1 -replace "'", "''" + $script = @" + +`$env:IDLE_ALLOW_INTERNAL_IMPORT = '1' +Import-Module '$corePsd1Escaped' -Force -WarningAction SilentlyContinue | Out-Null +`$env:IDLE_ALLOW_INTERNAL_IMPORT = '' + +`$warnings = & { Import-Module '$stepsPsd1Escaped' -Force -WarningAction Continue } 3>&1 | + Where-Object { `$_ -is [System.Management.Automation.WarningRecord] } + +if (`$warnings) { + (`$warnings | ForEach-Object { `$_.ToString() }) -join "`n" +} else { + '' +} +"@ + + $result = Invoke-IdleIsolatedPwsh -Script $script -Environment @{ + IDLE_ALLOW_INTERNAL_IMPORT = '' + PSModulePath = ($filteredPaths -join $pathSeparator) + } -WorkingDirectory $script:RepoRoot + + $result.ExitCode | Should -Be 0 + $output = ($result.StdOut + $result.StdErr).Trim() + $output | Should -Not -BeNullOrEmpty -Because "Internal module should emit warning on direct import" + $output | Should -Match "internal.*unsupported.*IdLE.*instead" -Because "Warning should indicate module is internal and suggest importing IdLE" + $output | Should -Match '\$env:IDLE_ALLOW_INTERNAL_IMPORT' -Because "Warning should show correct PowerShell syntax for bypass" } It 'IdLE meta-module does not emit internal module warnings' { - $originalValue = $env:IDLE_ALLOW_INTERNAL_IMPORT - try { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $null - - # Import and capture warning output - $output = Import-Module $idlePsd1 -Force 3>&1 | Out-String - - $output | Should -BeNullOrEmpty -Because "IdLE meta-module should suppress internal module warnings via ScriptsToProcess" - } - finally { - $env:IDLE_ALLOW_INTERNAL_IMPORT = $originalValue - Remove-Module IdLE -Force -ErrorAction SilentlyContinue - } + $idlePsd1Escaped = $script:IdlePsd1 -replace "'", "''" + $script = @" + +`$warnings = & { Import-Module '$idlePsd1Escaped' -Force -WarningAction Continue } 3>&1 | + Where-Object { `$_ -is [System.Management.Automation.WarningRecord] } + +if (`$warnings) { + (`$warnings | ForEach-Object { `$_.ToString() }) -join "`n" +} else { + '' +} +"@ + + $result = Invoke-IdleIsolatedPwsh -Script $script -Environment @{ + IDLE_ALLOW_INTERNAL_IMPORT = '' + } -WorkingDirectory $script:RepoRoot + + $result.ExitCode | Should -Be 0 + ($result.StdOut + $result.StdErr).Trim() | Should -BeNullOrEmpty -Because "IdLE meta-module should suppress internal module warnings via ScriptsToProcess" } } } + diff --git a/tests/Packaging/PublicApi.Tests.ps1 b/tests/Packaging/PublicApi.Tests.ps1 index d4c94c28..3ec0acf0 100644 --- a/tests/Packaging/PublicApi.Tests.ps1 +++ b/tests/Packaging/PublicApi.Tests.ps1 @@ -6,46 +6,48 @@ BeforeAll { } Describe 'IdLE public API surface' { - It 'Expected commands exist' { - $expected = @( - 'Invoke-IdlePlan', - 'New-IdleLifecycleRequest', - 'New-IdlePlan', - 'Test-IdleWorkflow' - ) - - foreach ($name in $expected) { - Get-Command -Name $name -ErrorAction Stop | Should -Not -BeNullOrEmpty + Context 'Commands' { + It 'Expected commands exist' { + $expected = @( + 'Invoke-IdlePlan', + 'New-IdleRequest', + 'New-IdlePlan', + 'Test-IdleWorkflow' + ) + + foreach ($name in $expected) { + Get-Command -Name $name -ErrorAction Stop | Should -Not -BeNullOrEmpty + } } } - It 'Exported IdLE functions have comment-based help (Synopsis + Description + Examples)' { - $commands = Get-Command -Module IdLE -CommandType Function - $commands | Should -Not -BeNullOrEmpty + Context 'Help metadata' { + It 'Exported IdLE functions have comment-based help (Synopsis + Description + Examples)' { + $commands = Get-Command -Module IdLE -CommandType Function + $commands | Should -Not -BeNullOrEmpty - foreach ($cmd in $commands) { - $help = Get-Help -Name $cmd.Name -ErrorAction Stop + foreach ($cmd in $commands) { + $help = Get-Help -Name $cmd.Name -ErrorAction Stop - # Synopsis - $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Synopsis | Should -Not -BeNullOrEmpty - # Description (can be structured) - $descText = - if ($help.Description -and $help.Description.Text) { ($help.Description.Text -join "`n").Trim() } - else { '' } + $descText = + if ($help.Description -and $help.Description.Text) { ($help.Description.Text -join "`n").Trim() } + else { '' } - $descText | Should -Not -BeNullOrEmpty + $descText | Should -Not -BeNullOrEmpty - # Examples (can also be structured) - $exampleCount = - if ($help.Examples -and $help.Examples.Example) { - @($help.Examples.Example).Count - } - else { - 0 - } + $exampleCount = + if ($help.Examples -and $help.Examples.Example) { + @($help.Examples.Example).Count + } + else { + 0 + } - $exampleCount | Should -BeGreaterThan 0 + $exampleCount | Should -BeGreaterThan 0 + } } } } + diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 127eacdd..46e852e8 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -40,6 +40,15 @@ Describe 'AD identity provider' { Import-Module $adProviderPath -Force } + Mock -ModuleName 'IdLE.Provider.AD' -CommandName Test-IdleADPrerequisites -MockWith { + [pscustomobject]@{ + PSTypeName = 'IdLE.PrerequisitesResult' + IsHealthy = $true + MissingRequired = @() + Notes = @() + } + } + function New-FakeADAdapter { $store = @{} diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index a63c9fd6..276443cf 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -205,76 +205,82 @@ Describe 'EntraID identity provider - Contract tests' { $script:FakeAdapter = $fakeAdapter } - Invoke-IdleIdentityProviderContractTests -NewProvider { - New-IdleEntraIDIdentityProvider -Adapter $script:FakeAdapter - } + Context 'Contracts' { + Invoke-IdleIdentityProviderContractTests -NewProvider { + New-IdleEntraIDIdentityProvider -Adapter $script:FakeAdapter + } - Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { - New-IdleEntraIDIdentityProvider -Adapter $script:FakeAdapter - } + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { + New-IdleEntraIDIdentityProvider -Adapter $script:FakeAdapter + } - # Note: Generic entitlement contract tests are skipped for EntraID provider because: - # - EntraID only supports Kind='Group' (not arbitrary entitlement kinds like 'Contract') - # - Generic contract tests use Kind='Contract' which doesn't match EntraID's behavior - # - EntraID-specific entitlement tests with Kind='Group' are in the 'EntraID identity provider - Entitlements' context below + # Note: Generic entitlement contract tests are skipped for EntraID provider because: + # - EntraID only supports Kind='Group' (not arbitrary entitlement kinds like 'Contract') + # - Generic contract tests use Kind='Contract' which doesn't match EntraID's behavior + # - EntraID-specific entitlement tests with Kind='Group' are in the 'EntraID identity provider - Entitlements' context below + } } Describe 'EntraID identity provider - Capabilities' { - It 'Advertises expected capabilities by default' { - $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) - $caps = $provider.GetCapabilities() - - $caps | Should -Contain 'IdLE.Identity.Read' - $caps | Should -Contain 'IdLE.Identity.List' - $caps | Should -Contain 'IdLE.Identity.Create' - $caps | Should -Contain 'IdLE.Identity.Attribute.Ensure' - $caps | Should -Contain 'IdLE.Identity.Disable' - $caps | Should -Contain 'IdLE.Identity.Enable' - $caps | Should -Contain 'IdLE.Identity.RevokeSessions' - $caps | Should -Contain 'IdLE.Entitlement.List' - $caps | Should -Contain 'IdLE.Entitlement.Grant' - $caps | Should -Contain 'IdLE.Entitlement.Revoke' - $caps | Should -Not -Contain 'IdLE.Identity.Delete' - } + Context 'Capabilities' { + It 'Advertises expected capabilities by default' { + $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) + $caps = $provider.GetCapabilities() + + $caps | Should -Contain 'IdLE.Identity.Read' + $caps | Should -Contain 'IdLE.Identity.List' + $caps | Should -Contain 'IdLE.Identity.Create' + $caps | Should -Contain 'IdLE.Identity.Attribute.Ensure' + $caps | Should -Contain 'IdLE.Identity.Disable' + $caps | Should -Contain 'IdLE.Identity.Enable' + $caps | Should -Contain 'IdLE.Identity.RevokeSessions' + $caps | Should -Contain 'IdLE.Entitlement.List' + $caps | Should -Contain 'IdLE.Entitlement.Grant' + $caps | Should -Contain 'IdLE.Entitlement.Revoke' + $caps | Should -Not -Contain 'IdLE.Identity.Delete' + } - It 'Advertises Delete capability when AllowDelete is true' { - $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter ([pscustomobject]@{}) - $caps = $provider.GetCapabilities() + It 'Advertises Delete capability when AllowDelete is true' { + $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter ([pscustomobject]@{}) + $caps = $provider.GetCapabilities() - $caps | Should -Contain 'IdLE.Identity.Delete' - } + $caps | Should -Contain 'IdLE.Identity.Delete' + } - It 'Does not advertise Delete capability when AllowDelete is false' { - $provider = New-IdleEntraIDIdentityProvider -AllowDelete:$false -Adapter ([pscustomobject]@{}) - $caps = $provider.GetCapabilities() + It 'Does not advertise Delete capability when AllowDelete is false' { + $provider = New-IdleEntraIDIdentityProvider -AllowDelete:$false -Adapter ([pscustomobject]@{}) + $caps = $provider.GetCapabilities() - $caps | Should -Not -Contain 'IdLE.Identity.Delete' + $caps | Should -Not -Contain 'IdLE.Identity.Delete' + } } } Describe 'EntraID identity provider - AllowDelete gate' { - It 'Throws when Delete is called without AllowDelete' { - $fakeAdapter = [pscustomobject]@{ PSTypeName = 'Fake' } - $provider = New-IdleEntraIDIdentityProvider -Adapter $fakeAdapter - - { $provider.DeleteIdentity('test-id', 'fake-token') } | Should -Throw '*Delete capability is not enabled*' - } + Context 'Guard' { + It 'Throws when Delete is called without AllowDelete' { + $fakeAdapter = [pscustomobject]@{ PSTypeName = 'Fake' } + $provider = New-IdleEntraIDIdentityProvider -Adapter $fakeAdapter - It 'Allows Delete when AllowDelete is true' { - $fakeAdapter = [pscustomobject]@{ - PSTypeName = 'Fake' - } - $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { - param($ObjectId, $AccessToken) - return $null + { $provider.DeleteIdentity('test-id', 'fake-token') } | Should -Throw '*Delete capability is not enabled*' } - $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter $fakeAdapter + It 'Allows Delete when AllowDelete is true' { + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'Fake' + } + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + return $null + } + + $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter $fakeAdapter - # Use GUID format, should not throw capability error - $userId = [guid]::NewGuid().ToString() - $result = $provider.DeleteIdentity($userId, 'fake-token') - $result.Changed | Should -BeFalse + # Use GUID format, should not throw capability error + $userId = [guid]::NewGuid().ToString() + $result = $provider.DeleteIdentity($userId, 'fake-token') + $result.Changed | Should -BeFalse + } } } @@ -346,78 +352,80 @@ Describe 'EntraID identity provider - Idempotency' { $script:TestAdapter = $fakeAdapter } - It 'CreateIdentity is idempotent - returns Changed=false when user exists' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - - $attrs = @{ - UserPrincipalName = 'test@test.local' - DisplayName = 'Test User' - } - - $result1 = $provider.CreateIdentity('test@test.local', $attrs, 'fake-token') - $result1.Changed | Should -BeTrue + Context 'Idempotency' { + It 'CreateIdentity is idempotent - returns Changed=false when user exists' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $userId = $result1.IdentityKey + $attrs = @{ + UserPrincipalName = 'test@test.local' + DisplayName = 'Test User' + } - # Second create should be idempotent - $result2 = $provider.CreateIdentity($userId, $attrs, 'fake-token') - $result2.Changed | Should -BeFalse - } + $result1 = $provider.CreateIdentity('test@test.local', $attrs, 'fake-token') + $result1.Changed | Should -BeTrue - It 'DeleteIdentity is idempotent - returns Changed=false when user does not exist' { - $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter $script:TestAdapter + $userId = $result1.IdentityKey - $userId = [guid]::NewGuid().ToString() - $result = $provider.DeleteIdentity($userId, 'fake-token') - $result.Changed | Should -BeFalse - } + # Second create should be idempotent + $result2 = $provider.CreateIdentity($userId, $attrs, 'fake-token') + $result2.Changed | Should -BeFalse + } - It 'DisableIdentity is idempotent - returns Changed=false when already disabled' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + It 'DeleteIdentity is idempotent - returns Changed=false when user does not exist' { + $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter $script:TestAdapter - $userId = [guid]::NewGuid().ToString() - $script:TestAdapter.Store[$userId] = @{ - id = $userId - accountEnabled = $true + $userId = [guid]::NewGuid().ToString() + $result = $provider.DeleteIdentity($userId, 'fake-token') + $result.Changed | Should -BeFalse } - $result1 = $provider.DisableIdentity($userId, 'fake-token') - $result1.Changed | Should -BeTrue + It 'DisableIdentity is idempotent - returns Changed=false when already disabled' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $result2 = $provider.DisableIdentity($userId, 'fake-token') - $result2.Changed | Should -BeFalse - } + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store[$userId] = @{ + id = $userId + accountEnabled = $true + } - It 'EnableIdentity is idempotent - returns Changed=false when already enabled' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $result1 = $provider.DisableIdentity($userId, 'fake-token') + $result1.Changed | Should -BeTrue - $userId = [guid]::NewGuid().ToString() - $script:TestAdapter.Store[$userId] = @{ - id = $userId - accountEnabled = $false + $result2 = $provider.DisableIdentity($userId, 'fake-token') + $result2.Changed | Should -BeFalse } - $result1 = $provider.EnableIdentity($userId, 'fake-token') - $result1.Changed | Should -BeTrue + It 'EnableIdentity is idempotent - returns Changed=false when already enabled' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $result2 = $provider.EnableIdentity($userId, 'fake-token') - $result2.Changed | Should -BeFalse - } + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store[$userId] = @{ + id = $userId + accountEnabled = $false + } - It 'EnsureAttribute is idempotent - returns Changed=false when value matches' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $result1 = $provider.EnableIdentity($userId, 'fake-token') + $result1.Changed | Should -BeTrue - $userId = [guid]::NewGuid().ToString() - $script:TestAdapter.Store[$userId] = @{ - id = $userId - displayName = 'Old Name' + $result2 = $provider.EnableIdentity($userId, 'fake-token') + $result2.Changed | Should -BeFalse } - $result1 = $provider.EnsureAttribute($userId, 'DisplayName', 'New Name', 'fake-token') - $result1.Changed | Should -BeTrue + It 'EnsureAttribute is idempotent - returns Changed=false when value matches' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $result2 = $provider.EnsureAttribute($userId, 'DisplayName', 'New Name', 'fake-token') - $result2.Changed | Should -BeFalse + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store[$userId] = @{ + id = $userId + displayName = 'Old Name' + } + + $result1 = $provider.EnsureAttribute($userId, 'DisplayName', 'New Name', 'fake-token') + $result1.Changed | Should -BeTrue + + $result2 = $provider.EnsureAttribute($userId, 'DisplayName', 'New Name', 'fake-token') + $result2.Changed | Should -BeFalse + } } } @@ -441,55 +449,57 @@ Describe 'EntraID identity provider - AuthSession handling' { $script:TestAdapter = $fakeAdapter } - It 'Accepts string access token' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + Context 'AuthSession formats' { + It 'Accepts string access token' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $userId = [guid]::NewGuid().ToString() - $result = $provider.GetIdentity($userId, 'string-token') - $script:TestAdapter.LastTokenUsed | Should -Be 'string-token' - } + $userId = [guid]::NewGuid().ToString() + $result = $provider.GetIdentity($userId, 'string-token') + $script:TestAdapter.LastTokenUsed | Should -Be 'string-token' + } + + It 'Accepts object with AccessToken property' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - It 'Accepts object with AccessToken property' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $authSession = [pscustomobject]@{ + AccessToken = 'property-token' + } - $authSession = [pscustomobject]@{ - AccessToken = 'property-token' + $userId = [guid]::NewGuid().ToString() + $result = $provider.GetIdentity($userId, $authSession) + $script:TestAdapter.LastTokenUsed | Should -Be 'property-token' } - $userId = [guid]::NewGuid().ToString() - $result = $provider.GetIdentity($userId, $authSession) - $script:TestAdapter.LastTokenUsed | Should -Be 'property-token' - } + It 'Accepts object with GetAccessToken() method' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - It 'Accepts object with GetAccessToken() method' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $authSession = [pscustomobject]@{} + $authSession | Add-Member -MemberType ScriptMethod -Name GetAccessToken -Value { + return 'method-token' + } - $authSession = [pscustomobject]@{} - $authSession | Add-Member -MemberType ScriptMethod -Name GetAccessToken -Value { - return 'method-token' + $userId = [guid]::NewGuid().ToString() + $result = $provider.GetIdentity($userId, $authSession) + $script:TestAdapter.LastTokenUsed | Should -Be 'method-token' } - $userId = [guid]::NewGuid().ToString() - $result = $provider.GetIdentity($userId, $authSession) - $script:TestAdapter.LastTokenUsed | Should -Be 'method-token' - } + It 'Allows null AuthSession (for testing)' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - It 'Allows null AuthSession (for testing)' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $userId = [guid]::NewGuid().ToString() + # Should not throw - will use test token + $provider.GetIdentity($userId, $null) | Should -Not -BeNullOrEmpty + } - $userId = [guid]::NewGuid().ToString() - # Should not throw - will use test token - $provider.GetIdentity($userId, $null) | Should -Not -BeNullOrEmpty - } + It 'Throws when AuthSession format is unrecognized' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - It 'Throws when AuthSession format is unrecognized' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $badSession = [pscustomobject]@{ + SomeProperty = 'value' + } - $badSession = [pscustomobject]@{ - SomeProperty = 'value' + { $provider.GetIdentity('test-id', $badSession) } | Should -Throw '*AuthSession format not recognized*' } - - { $provider.GetIdentity('test-id', $badSession) } | Should -Throw '*AuthSession format not recognized*' } } @@ -528,56 +538,58 @@ Describe 'EntraID identity provider - Identity resolution' { $script:TestAdapter = $fakeAdapter } - It 'Resolves identity by objectId (GUID)' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + Context 'Lookups' { + It 'Resolves identity by objectId (GUID)' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $guid = [guid]::NewGuid().ToString() + $script:TestAdapter.Store["id:$guid"] = @{ + id = $guid + accountEnabled = $true + displayName = "User $guid" + } - $guid = [guid]::NewGuid().ToString() - $script:TestAdapter.Store["id:$guid"] = @{ - id = $guid - accountEnabled = $true - displayName = "User $guid" + $result = $provider.GetIdentity($guid, 'fake-token') + $result.IdentityKey | Should -Be $guid } - $result = $provider.GetIdentity($guid, 'fake-token') - $result.IdentityKey | Should -Be $guid - } + It 'Resolves identity by UPN' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - It 'Resolves identity by UPN' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $upn = 'test@test.local' + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store["upn:$upn"] = @{ + id = $userId + userPrincipalName = $upn + accountEnabled = $true + displayName = "Test User" + } - $upn = 'test@test.local' - $userId = [guid]::NewGuid().ToString() - $script:TestAdapter.Store["upn:$upn"] = @{ - id = $userId - userPrincipalName = $upn - accountEnabled = $true - displayName = "Test User" + $result = $provider.GetIdentity($upn, 'fake-token') + $result.IdentityKey | Should -Be $upn # Returns original key format } - $result = $provider.GetIdentity($upn, 'fake-token') - $result.IdentityKey | Should -Be $upn # Returns original key format - } + It 'Falls back to mail when UPN lookup fails' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - It 'Falls back to mail when UPN lookup fails' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + $mail = 'test@test.local' + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store["mail:$mail"] = @{ + id = $userId + mail = $mail + accountEnabled = $true + displayName = "Test User" + } - $mail = 'test@test.local' - $userId = [guid]::NewGuid().ToString() - $script:TestAdapter.Store["mail:$mail"] = @{ - id = $userId - mail = $mail - accountEnabled = $true - displayName = "Test User" + $result = $provider.GetIdentity($mail, 'fake-token') + $result.IdentityKey | Should -Be $mail # Returns original key format } - $result = $provider.GetIdentity($mail, 'fake-token') - $result.IdentityKey | Should -Be $mail # Returns original key format - } - - It 'Throws when identity is not found' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + It 'Throws when identity is not found' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - { $provider.GetIdentity('nonexistent@test.local', 'fake-token') } | Should -Throw '*not found*' + { $provider.GetIdentity('nonexistent@test.local', 'fake-token') } | Should -Throw '*not found*' + } } } @@ -613,26 +625,28 @@ Describe 'EntraID identity provider - Group resolution' { $script:TestAdapter = $fakeAdapter } - It 'Resolves group by objectId' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + Context 'Lookups' { + It 'Resolves group by objectId' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $groupGuid = [guid]::NewGuid().ToString() - $resolvedId = $provider.NormalizeGroupId($groupGuid, 'fake-token') + $groupGuid = [guid]::NewGuid().ToString() + $resolvedId = $provider.NormalizeGroupId($groupGuid, 'fake-token') - $resolvedId | Should -Be $groupGuid - } + $resolvedId | Should -Be $groupGuid + } - It 'Resolves group by displayName' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + It 'Resolves group by displayName' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $resolvedId = $provider.NormalizeGroupId('UniqueGroup', 'fake-token') - $resolvedId | Should -Be 'resolved-UniqueGroup' - } + $resolvedId = $provider.NormalizeGroupId('UniqueGroup', 'fake-token') + $resolvedId | Should -Be 'resolved-UniqueGroup' + } - It 'Throws when multiple groups match displayName' { - $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + It 'Throws when multiple groups match displayName' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - { $provider.NormalizeGroupId('AmbiguousGroup', 'fake-token') } | Should -Throw '*Multiple groups found*' + { $provider.NormalizeGroupId('AmbiguousGroup', 'fake-token') } | Should -Throw '*Multiple groups found*' + } } } @@ -718,83 +732,85 @@ Describe 'EntraID identity provider - Entitlement operations' { $script:EntProvider = New-IdleEntraIDIdentityProvider -Adapter $script:EntAdapter } - It 'Exposes required entitlement methods' { - $script:EntProvider.PSObject.Methods.Name | Should -Contain 'ListEntitlements' - $script:EntProvider.PSObject.Methods.Name | Should -Contain 'GrantEntitlement' - $script:EntProvider.PSObject.Methods.Name | Should -Contain 'RevokeEntitlement' - } - - It 'GrantEntitlement returns stable result shape with Kind=Group' { - $userId = [guid]::NewGuid().ToString() - [void]$script:EntProvider.GetIdentity($userId) - - $entitlement = [pscustomobject]@{ - Kind = 'Group' - Id = [guid]::NewGuid().ToString() + Context 'Operations' { + It 'Exposes required entitlement methods' { + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'ListEntitlements' + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'GrantEntitlement' + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'RevokeEntitlement' } - $result = $script:EntProvider.GrantEntitlement($userId, $entitlement) + It 'GrantEntitlement returns stable result shape with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) - $result | Should -Not -BeNullOrEmpty - $result.PSObject.Properties.Name | Should -Contain 'Changed' - $result.PSObject.Properties.Name | Should -Contain 'IdentityKey' - $result.PSObject.Properties.Name | Should -Contain 'Entitlement' - $result.Entitlement.Kind | Should -Be 'Group' - } + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } - It 'GrantEntitlement is idempotent with Kind=Group' { - $userId = [guid]::NewGuid().ToString() - [void]$script:EntProvider.GetIdentity($userId) + $result = $script:EntProvider.GrantEntitlement($userId, $entitlement) - $entitlement = [pscustomobject]@{ - Kind = 'Group' - Id = [guid]::NewGuid().ToString() + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'IdentityKey' + $result.PSObject.Properties.Name | Should -Contain 'Entitlement' + $result.Entitlement.Kind | Should -Be 'Group' } - $result1 = $script:EntProvider.GrantEntitlement($userId, $entitlement) - $result1.Changed | Should -Be $true + It 'GrantEntitlement is idempotent with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) - $result2 = $script:EntProvider.GrantEntitlement($userId, $entitlement) - $result2.Changed | Should -Be $false - } + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } - It 'RevokeEntitlement is idempotent (after a grant) with Kind=Group' { - $userId = [guid]::NewGuid().ToString() - [void]$script:EntProvider.GetIdentity($userId) + $result1 = $script:EntProvider.GrantEntitlement($userId, $entitlement) + $result1.Changed | Should -Be $true - $entitlement = [pscustomobject]@{ - Kind = 'Group' - Id = [guid]::NewGuid().ToString() + $result2 = $script:EntProvider.GrantEntitlement($userId, $entitlement) + $result2.Changed | Should -Be $false } - [void]$script:EntProvider.GrantEntitlement($userId, $entitlement) + It 'RevokeEntitlement is idempotent (after a grant) with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) - $result1 = $script:EntProvider.RevokeEntitlement($userId, $entitlement) - $result1.Changed | Should -Be $true + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } - $result2 = $script:EntProvider.RevokeEntitlement($userId, $entitlement) - $result2.Changed | Should -Be $false - } + [void]$script:EntProvider.GrantEntitlement($userId, $entitlement) - It 'ListEntitlements reflects grant and revoke operations with Kind=Group' { - $userId = [guid]::NewGuid().ToString() - [void]$script:EntProvider.GetIdentity($userId) + $result1 = $script:EntProvider.RevokeEntitlement($userId, $entitlement) + $result1.Changed | Should -Be $true - $entitlement = [pscustomobject]@{ - Kind = 'Group' - Id = [guid]::NewGuid().ToString() + $result2 = $script:EntProvider.RevokeEntitlement($userId, $entitlement) + $result2.Changed | Should -Be $false } - $before = @($script:EntProvider.ListEntitlements($userId)) + It 'ListEntitlements reflects grant and revoke operations with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) - [void]$script:EntProvider.GrantEntitlement($userId, $entitlement) - $afterGrant = @($script:EntProvider.ListEntitlements($userId)) + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } + + $before = @($script:EntProvider.ListEntitlements($userId)) - [void]$script:EntProvider.RevokeEntitlement($userId, $entitlement) - $afterRevoke = @($script:EntProvider.ListEntitlements($userId)) + [void]$script:EntProvider.GrantEntitlement($userId, $entitlement) + $afterGrant = @($script:EntProvider.ListEntitlements($userId)) - @($afterGrant | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 - @($afterRevoke | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 + [void]$script:EntProvider.RevokeEntitlement($userId, $entitlement) + $afterRevoke = @($script:EntProvider.ListEntitlements($userId)) + + @($afterGrant | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 + @($afterRevoke | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 + } } } @@ -856,89 +872,91 @@ Describe 'EntraID identity provider - RevokeSessions' { $script:RevokeProvider = New-IdleEntraIDIdentityProvider -Adapter $script:RevokeAdapter } - It 'Advertises IdLE.Identity.RevokeSessions capability' { - $caps = $script:RevokeProvider.GetCapabilities() - $caps | Should -Contain 'IdLE.Identity.RevokeSessions' - } + Context 'Operations' { + It 'Advertises IdLE.Identity.RevokeSessions capability' { + $caps = $script:RevokeProvider.GetCapabilities() + $caps | Should -Contain 'IdLE.Identity.RevokeSessions' + } - It 'Exposes RevokeSessions method' { - $script:RevokeProvider.PSObject.Methods.Name | Should -Contain 'RevokeSessions' - } + It 'Exposes RevokeSessions method' { + $script:RevokeProvider.PSObject.Methods.Name | Should -Contain 'RevokeSessions' + } - It 'RevokeSessions calls adapter with correct user ID' { - $userId = [guid]::NewGuid().ToString() - $script:RevokeAdapter.RevocationCallLog = @() - - $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') - - $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 - $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be $userId - } + It 'RevokeSessions calls adapter with correct user ID' { + $userId = [guid]::NewGuid().ToString() + $script:RevokeAdapter.RevocationCallLog = @() + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 + $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be $userId + } - It 'RevokeSessions returns ProviderResult with correct shape' { - $userId = [guid]::NewGuid().ToString() - - $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') - - $result | Should -Not -BeNullOrEmpty - $result.PSObject.TypeNames[0] | Should -Be 'IdLE.ProviderResult' - $result.Operation | Should -Be 'RevokeSessions' - $result.IdentityKey | Should -Be $userId - $result.PSObject.Properties.Name | Should -Contain 'Changed' - } + It 'RevokeSessions returns ProviderResult with correct shape' { + $userId = [guid]::NewGuid().ToString() + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.TypeNames[0] | Should -Be 'IdLE.ProviderResult' + $result.Operation | Should -Be 'RevokeSessions' + $result.IdentityKey | Should -Be $userId + $result.PSObject.Properties.Name | Should -Contain 'Changed' + } - It 'RevokeSessions reports Changed=true when Graph returns value=true' { - $userId = [guid]::NewGuid().ToString() - $script:RevokeAdapter.RevocationResponses[$userId] = [pscustomobject]@{ - value = $true + It 'RevokeSessions reports Changed=true when Graph returns value=true' { + $userId = [guid]::NewGuid().ToString() + $script:RevokeAdapter.RevocationResponses[$userId] = [pscustomobject]@{ + value = $true + } + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $result.Changed | Should -Be $true } - - $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') - - $result.Changed | Should -Be $true - } - It 'RevokeSessions reports Changed=false when Graph returns value=false' { - $userId = [guid]::NewGuid().ToString() - $script:RevokeAdapter.RevocationResponses[$userId] = [pscustomobject]@{ - value = $false + It 'RevokeSessions reports Changed=false when Graph returns value=false' { + $userId = [guid]::NewGuid().ToString() + $script:RevokeAdapter.RevocationResponses[$userId] = [pscustomobject]@{ + value = $false + } + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $result.Changed | Should -Be $false } - - $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') - - $result.Changed | Should -Be $false - } - It 'RevokeSessions resolves identity by UPN' { - $upn = 'test.user@contoso.com' - $script:RevokeAdapter.RevocationCallLog = @() - - $result = $script:RevokeProvider.RevokeSessions($upn, 'fake-token') - - $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 - $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be 'test-user-id' - } + It 'RevokeSessions resolves identity by UPN' { + $upn = 'test.user@contoso.com' + $script:RevokeAdapter.RevocationCallLog = @() + + $result = $script:RevokeProvider.RevokeSessions($upn, 'fake-token') + + $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 + $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be 'test-user-id' + } - It 'RevokeSessions resolves identity by mail' { - $mail = 'test.user@contoso.com' - $script:RevokeAdapter.RevocationCallLog = @() - - $result = $script:RevokeProvider.RevokeSessions($mail, 'fake-token') - - $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 - $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be 'test-user-id' - } + It 'RevokeSessions resolves identity by mail' { + $mail = 'test.user@contoso.com' + $script:RevokeAdapter.RevocationCallLog = @() + + $result = $script:RevokeProvider.RevokeSessions($mail, 'fake-token') + + $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 + $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be 'test-user-id' + } - It 'RevokeSessions accepts AuthSession object' { - $userId = [guid]::NewGuid().ToString() - $authSession = [pscustomobject]@{ - AccessToken = 'session-token' + It 'RevokeSessions accepts AuthSession object' { + $userId = [guid]::NewGuid().ToString() + $authSession = [pscustomobject]@{ + AccessToken = 'session-token' + } + + $result = $script:RevokeProvider.RevokeSessions($userId, $authSession) + + $result | Should -Not -BeNullOrEmpty + $result.Operation | Should -Be 'RevokeSessions' } - - $result = $script:RevokeProvider.RevokeSessions($userId, $authSession) - - $result | Should -Not -BeNullOrEmpty - $result.Operation | Should -Be 'RevokeSessions' } } @@ -986,118 +1004,120 @@ Describe 'EntraID identity provider - Password generation' { $script:PasswordTestAdapter = $fakeAdapter } - It 'Generates password when no PasswordProfile is provided' { - $attrs = @{ - UserPrincipalName = 'newuser@contoso.com' - DisplayName = 'New User' + Context 'Password generation' { + It 'Generates password when no PasswordProfile is provided' { + $attrs = @{ + UserPrincipalName = 'newuser@contoso.com' + DisplayName = 'New User' + } + + $result = $script:PasswordTestProvider.CreateIdentity('newuser@contoso.com', $attrs, 'fake-token') + + # Verify password was generated + $result.PasswordGenerated | Should -BeTrue + $result.GeneratedAccountPasswordProtected | Should -Not -BeNullOrEmpty + $result.PasswordGenerationMethod | Should -Be 'GUID' } - $result = $script:PasswordTestProvider.CreateIdentity('newuser@contoso.com', $attrs, 'fake-token') - - # Verify password was generated - $result.PasswordGenerated | Should -BeTrue - $result.GeneratedAccountPasswordProtected | Should -Not -BeNullOrEmpty - $result.PasswordGenerationMethod | Should -Be 'GUID' - } + It 'Does not include plaintext password by default' { + $attrs = @{ + UserPrincipalName = 'user@contoso.com' + DisplayName = 'User' + } - It 'Does not include plaintext password by default' { - $attrs = @{ - UserPrincipalName = 'user@contoso.com' - DisplayName = 'User' + $result = $script:PasswordTestProvider.CreateIdentity('user@contoso.com', $attrs, 'fake-token') + + # Verify plaintext password is not included + $result.PSObject.Properties.Name | Should -Not -Contain 'GeneratedAccountPasswordPlainText' } - $result = $script:PasswordTestProvider.CreateIdentity('user@contoso.com', $attrs, 'fake-token') - - # Verify plaintext password is not included - $result.PSObject.Properties.Name | Should -Not -Contain 'GeneratedAccountPasswordPlainText' - } + It 'Includes plaintext password when AllowPlainTextPasswordOutput is true' { + $attrs = @{ + UserPrincipalName = 'user2@contoso.com' + DisplayName = 'User 2' + AllowPlainTextPasswordOutput = $true + } - It 'Includes plaintext password when AllowPlainTextPasswordOutput is true' { - $attrs = @{ - UserPrincipalName = 'user2@contoso.com' - DisplayName = 'User 2' - AllowPlainTextPasswordOutput = $true - } - - $result = $script:PasswordTestProvider.CreateIdentity('user2@contoso.com', $attrs, 'fake-token') - - # Verify plaintext password is included - $result.GeneratedAccountPasswordPlainText | Should -Not -BeNullOrEmpty - $result.GeneratedAccountPasswordPlainText | Should -BeOfType [string] - - # Verify it's a GUID format - { [guid]::Parse($result.GeneratedAccountPasswordPlainText) } | Should -Not -Throw - } + $result = $script:PasswordTestProvider.CreateIdentity('user2@contoso.com', $attrs, 'fake-token') + + # Verify plaintext password is included + $result.GeneratedAccountPasswordPlainText | Should -Not -BeNullOrEmpty + $result.GeneratedAccountPasswordPlainText | Should -BeOfType [string] + + # Verify it's a GUID format + { [guid]::Parse($result.GeneratedAccountPasswordPlainText) } | Should -Not -Throw + } - It 'Does not generate password when PasswordProfile is provided' { - $attrs = @{ - UserPrincipalName = 'user3@contoso.com' - DisplayName = 'User 3' - PasswordProfile = @{ - password = 'Explicit@Pass123!' - forceChangePasswordNextSignIn = $true + It 'Does not generate password when PasswordProfile is provided' { + $attrs = @{ + UserPrincipalName = 'user3@contoso.com' + DisplayName = 'User 3' + PasswordProfile = @{ + password = 'Explicit@Pass123!' + forceChangePasswordNextSignIn = $true + } } + + $result = $script:PasswordTestProvider.CreateIdentity('user3@contoso.com', $attrs, 'fake-token') + + # Verify password was not generated (explicit password provided) + $result.PSObject.Properties.Name | Should -Not -Contain 'PasswordGenerated' } - $result = $script:PasswordTestProvider.CreateIdentity('user3@contoso.com', $attrs, 'fake-token') - - # Verify password was not generated (explicit password provided) - $result.PSObject.Properties.Name | Should -Not -Contain 'PasswordGenerated' - } + It 'Sets forceChangePasswordNextSignIn to true by default' { + $attrs = @{ + UserPrincipalName = 'user4@contoso.com' + DisplayName = 'User 4' + } - It 'Sets forceChangePasswordNextSignIn to true by default' { - $attrs = @{ - UserPrincipalName = 'user4@contoso.com' - DisplayName = 'User 4' + $result = $script:PasswordTestProvider.CreateIdentity('user4@contoso.com', $attrs, 'fake-token') + + # Verify the payload sent to adapter + $script:PasswordTestAdapter.LastCreatePayload.passwordProfile.forceChangePasswordNextSignIn | Should -BeTrue } - $result = $script:PasswordTestProvider.CreateIdentity('user4@contoso.com', $attrs, 'fake-token') - - # Verify the payload sent to adapter - $script:PasswordTestAdapter.LastCreatePayload.passwordProfile.forceChangePasswordNextSignIn | Should -BeTrue - } + It 'Allows ForceChangePasswordNextSignIn to be set to false' { + $attrs = @{ + UserPrincipalName = 'serviceaccount@contoso.com' + DisplayName = 'Service Account' + ForceChangePasswordNextSignIn = $false + } - It 'Allows ForceChangePasswordNextSignIn to be set to false' { - $attrs = @{ - UserPrincipalName = 'serviceaccount@contoso.com' - DisplayName = 'Service Account' - ForceChangePasswordNextSignIn = $false + $result = $script:PasswordTestProvider.CreateIdentity('serviceaccount@contoso.com', $attrs, 'fake-token') + + # Verify the payload sent to adapter + $script:PasswordTestAdapter.LastCreatePayload.passwordProfile.forceChangePasswordNextSignIn | Should -BeFalse } - $result = $script:PasswordTestProvider.CreateIdentity('serviceaccount@contoso.com', $attrs, 'fake-token') - - # Verify the payload sent to adapter - $script:PasswordTestAdapter.LastCreatePayload.passwordProfile.forceChangePasswordNextSignIn | Should -BeFalse - } - - It 'Generated password can be revealed using ProtectedString' { - $attrs = @{ - UserPrincipalName = 'user5@contoso.com' - DisplayName = 'User 5' - } - - $result = $script:PasswordTestProvider.CreateIdentity('user5@contoso.com', $attrs, 'fake-token') - - # Verify ProtectedString can be converted back to SecureString - $protectedString = $result.GeneratedAccountPasswordProtected - { ConvertTo-SecureString -String $protectedString } | Should -Not -Throw - - # Verify conversion works - $secure = ConvertTo-SecureString -String $protectedString - $secure | Should -BeOfType [securestring] - } + It 'Generated password can be revealed using ProtectedString' { + $attrs = @{ + UserPrincipalName = 'user5@contoso.com' + DisplayName = 'User 5' + } - It 'Generated password is a valid GUID' { - $attrs = @{ - UserPrincipalName = 'user6@contoso.com' - DisplayName = 'User 6' - AllowPlainTextPasswordOutput = $true + $result = $script:PasswordTestProvider.CreateIdentity('user5@contoso.com', $attrs, 'fake-token') + + # Verify ProtectedString can be converted back to SecureString + $protectedString = $result.GeneratedAccountPasswordProtected + { ConvertTo-SecureString -String $protectedString } | Should -Not -Throw + + # Verify conversion works + $secure = ConvertTo-SecureString -String $protectedString + $secure | Should -BeOfType [securestring] } - $result = $script:PasswordTestProvider.CreateIdentity('user6@contoso.com', $attrs, 'fake-token') - - # Verify the generated password is a valid GUID - $plainPwd = $result.GeneratedAccountPasswordPlainText - { [guid]::Parse($plainPwd) } | Should -Not -Throw + It 'Generated password is a valid GUID' { + $attrs = @{ + UserPrincipalName = 'user6@contoso.com' + DisplayName = 'User 6' + AllowPlainTextPasswordOutput = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('user6@contoso.com', $attrs, 'fake-token') + + # Verify the generated password is a valid GUID + $plainPwd = $result.GeneratedAccountPasswordPlainText + { [guid]::Parse($plainPwd) } | Should -Not -Throw + } } } diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 3d2c828f..a033492c 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -3,20 +3,27 @@ Set-StrictMode -Version Latest BeforeDiscovery { . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') Import-IdleTestModule - - $testsRoot = Split-Path -Path $PSScriptRoot -Parent - $repoRoot = Split-Path -Path $testsRoot -Parent - - # Import ExchangeOnline provider - $exoModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\IdLE.Provider.ExchangeOnline.psm1' - if (-not (Test-Path -LiteralPath $exoModulePath -PathType Leaf)) { - throw "ExchangeOnline provider module not found at: $exoModulePath" - } - Import-Module $exoModulePath -Force } Describe 'ExchangeOnline provider - Unit tests' { BeforeAll { + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + $modulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\IdLE.Provider.ExchangeOnline.psm1' + if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + throw "ExchangeOnline provider module not found at: $modulePath" + } + Import-Module $modulePath -Force + + Mock -ModuleName 'IdLE.Provider.ExchangeOnline' -CommandName Test-IdleExchangeOnlinePrerequisites -MockWith { + [pscustomobject]@{ + PSTypeName = 'IdLE.PrerequisitesResult' + IsHealthy = $true + MissingRequired = @() + Notes = @() + } + } + # Create a fake adapter for tests $fakeAdapter = [pscustomobject]@{ PSTypeName = 'IdLE.ExchangeOnlineAdapter.Fake' diff --git a/tests/Providers/MockIdentityProvider.Tests.ps1 b/tests/Providers/MockIdentityProvider.Tests.ps1 index 5ca20caf..8595262a 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -28,8 +28,10 @@ BeforeDiscovery { . $entitlementContractPath } -Describe 'Mock identity provider contracts' { - Invoke-IdleIdentityProviderContractTests -NewProvider { New-IdleMockIdentityProvider } - Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { New-IdleMockIdentityProvider } - Invoke-IdleEntitlementProviderContractTests -NewProvider { New-IdleMockIdentityProvider } +Describe 'Mock identity provider' { + Context 'Contracts' { + Invoke-IdleIdentityProviderContractTests -NewProvider { New-IdleMockIdentityProvider } + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { New-IdleMockIdentityProvider } + Invoke-IdleEntitlementProviderContractTests -NewProvider { New-IdleMockIdentityProvider } + } } diff --git a/tests/Providers/New-IdleADPassword.Tests.ps1 b/tests/Providers/New-IdleADPassword.Tests.ps1 index 8eed99d0..927e4910 100644 --- a/tests/Providers/New-IdleADPassword.Tests.ps1 +++ b/tests/Providers/New-IdleADPassword.Tests.ps1 @@ -1,16 +1,23 @@ +Set-StrictMode -Version Latest + +# PSScriptAnalyzer can flag test-only mocks and protected string conversions. +#pragma warning disable PSReviewUnusedParameter +#pragma warning disable PSAvoidUsingPlainTextForPassword +#pragma warning disable PSAvoidUsingConvertToSecureStringWithPlainText + BeforeDiscovery { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule - $testsRoot = Split-Path -Path $PSScriptRoot -Parent - $repoRoot = Split-Path -Path $testsRoot -Parent + $script:TestsRoot = Split-Path -Path $PSScriptRoot -Parent + $script:RepoRoot = Split-Path -Path $script:TestsRoot -Parent # Import AD provider module to access private functions - $adModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.AD\IdLE.Provider.AD.psm1' - if (-not (Test-Path -LiteralPath $adModulePath -PathType Leaf)) { - throw "AD provider module not found at: $adModulePath" + $script:AdModulePath = Join-Path -Path $script:RepoRoot -ChildPath 'src\IdLE.Provider.AD\IdLE.Provider.AD.psm1' + if (-not (Test-Path -LiteralPath $script:AdModulePath -PathType Leaf)) { + throw "AD provider module not found at: $script:AdModulePath" } - Import-Module $adModulePath -Force + Import-Module $script:AdModulePath -Force } Describe 'New-IdleADPassword - Policy-aware password generation' { @@ -27,8 +34,10 @@ Describe 'New-IdleADPassword - Policy-aware password generation' { [Parameter()] [pscredential]$Credential, [Parameter()] - [string]$ErrorAction + [System.Management.Automation.ActionPreference]$ErrorAction ) + if ($PSBoundParameters.ContainsKey('Credential')) { $Credential | Out-Null } + if ($PSBoundParameters.ContainsKey('ErrorAction')) { $ErrorAction | Out-Null } throw "Not implemented - should be mocked in tests" } } @@ -232,7 +241,9 @@ Describe 'New-IdleADPassword - Policy-aware password generation' { $result.ProtectedString | Should -Not -BeNullOrEmpty # Verify it can be converted to SecureString + #pragma warning disable PSAvoidUsingConvertToSecureStringWithPlainText { ConvertTo-SecureString -String $result.ProtectedString } | Should -Not -Throw + #pragma warning restore PSAvoidUsingConvertToSecureStringWithPlainText } It 'ProtectedString round-trips correctly' { @@ -243,7 +254,9 @@ Describe 'New-IdleADPassword - Policy-aware password generation' { $result = New-IdleADPassword # Convert ProtectedString -> SecureString -> PlainText + #pragma warning disable PSAvoidUsingConvertToSecureStringWithPlainText $secure = ConvertTo-SecureString -String $result.ProtectedString + #pragma warning restore PSAvoidUsingConvertToSecureStringWithPlainText $plain = [pscredential]::new('x', $secure).GetNetworkCredential().Password $plain | Should -Be $result.PlainText @@ -278,17 +291,28 @@ Describe 'New-IdleADPassword - Policy-aware password generation' { It 'Accepts credential parameter without error' { Mock Get-ADDefaultDomainPasswordPolicy { - param($Credential) + param([pscredential]$Credential) + if ($PSBoundParameters.ContainsKey('Credential')) { $Credential | Out-Null } return [pscustomobject]@{ MinPasswordLength = 12 ComplexityEnabled = $true } } - $fakeCred = [pscredential]::new('user', (ConvertTo-SecureString -String 'pass' -AsPlainText -Force)) + $secure = [System.Security.SecureString]::new() + foreach ($ch in 'pass'.ToCharArray()) { + $secure.AppendChar($ch) + } + $secure.MakeReadOnly() + + $fakeCred = [pscredential]::new('user', $secure) { New-IdleADPassword -Credential $fakeCred } | Should -Not -Throw } } } } + +#pragma warning restore PSAvoidUsingConvertToSecureStringWithPlainText +#pragma warning restore PSAvoidUsingPlainTextForPassword +#pragma warning restore PSReviewUnusedParameter diff --git a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 index ba81cf35..adbd5cf2 100644 --- a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 @@ -1,394 +1,160 @@ -#requires -Version 7.0 +Set-StrictMode -Version Latest -Describe 'IdLE.Steps - Auth Session Routing' { - - BeforeAll { - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../src/IdLE/IdLE.psd1') -Force - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../src/IdLE.Core/IdLE.Core.psd1') -Force - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../src/IdLE.Steps.Common/IdLE.Steps.Common.psd1') -Force - } - - Context 'EnsureAttribute - Auth Session Acquisition' { - - It 'acquires auth session when With.AuthSessionName is present' { - # Arrange - $testState = [pscustomobject]@{ - SessionAcquired = $false - AcquiredName = $null - AcquiredOptions = $null - } +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} - $broker = [pscustomobject]@{ - PSTypeName = 'Tests.AuthSessionBroker' - State = $testState - } - $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - $this.State.SessionAcquired = $true - $this.State.AcquiredName = $Name - $this.State.AcquiredOptions = $Options - return [PSCredential]::new('testuser', (ConvertTo-SecureString 'testpass' -AsPlainText -Force)) - } -Force +Describe 'Invoke-IdleStepEnsureAttributes (auth session routing)' { + BeforeEach { + $script:State = [pscustomobject]@{ + SessionAcquired = $false + AcquiredName = $null + AcquiredOptions = $null + ReceivedAuthSession = $null + LegacyCallMade = $false + } - $mockProvider = [pscustomobject]@{ - PSTypeName = 'Tests.MockProvider' + $script:Broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + State = $script:State + } + $script:Broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + $this.State.SessionAcquired = $true + $this.State.AcquiredName = $Name + $this.State.AcquiredOptions = $Options + return [PSCredential]::new('tier0admin', (ConvertTo-SecureString 'pass123' -AsPlainText -Force)) + } -Force + + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + State = $script:State + } + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value, $AuthSession) + $this.State.ReceivedAuthSession = $AuthSession + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true } - $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param($IdentityKey, $Name, $Value, $AuthSession) - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Changed = $true - } - } -Force + } -Force - $context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Providers = @{ - Identity = $mockProvider - AuthSessionBroker = $broker - } + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $script:Provider + AuthSessionBroker = $script:Broker } - $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) - } -Force - - $step = [pscustomobject]@{ - PSTypeName = 'IdLE.Step' - Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - IdentityKey = 'testuser' - Attributes = @{ Department = 'IT' } - AuthSessionName = 'ActiveDirectory' - AuthSessionOptions = @{ Role = 'Tier0' } - } + } + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) + } -Force + + $script:Handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + + $script:StepTemplate = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttributes' + With = @{ + IdentityKey = 'testuser' + Attributes = @{ Department = 'IT' } + AuthSessionName = 'ActiveDirectory' + AuthSessionOptions = @{ Role = 'Tier0' } } + } + } - # Act - $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step + Context 'Auth session acquisition' { + It 'acquires auth session when AuthSessionName is present' { + $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate - # Assert $result | Should -Not -BeNullOrEmpty $result.PSTypeNames | Should -Contain 'IdLE.StepResult' $result.Status | Should -Be 'Completed' - $testState.SessionAcquired | Should -Be $true - $testState.AcquiredName | Should -Be 'ActiveDirectory' - $testState.AcquiredOptions.Role | Should -Be 'Tier0' + $script:State.SessionAcquired | Should -Be $true + $script:State.AcquiredName | Should -Be 'ActiveDirectory' + $script:State.AcquiredOptions.Role | Should -Be 'Tier0' } - It 'does not acquire auth session when With.AuthSessionName is absent' { - # Arrange - $sessionAcquired = $false - - $broker = [pscustomobject]@{ - PSTypeName = 'Tests.AuthSessionBroker' - } - $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - $script:sessionAcquired = $true - throw "Should not be called" - } -Force - - $mockProvider = [pscustomobject]@{ - PSTypeName = 'Tests.MockProvider' - } - $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + It 'does not acquire auth session when AuthSessionName is absent and provider lacks AuthSession parameter' { + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { param($IdentityKey, $Name, $Value) + $this.State.LegacyCallMade = $true return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' - Changed = $true + Changed = $true } } -Force - $context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Providers = @{ - Identity = $mockProvider - AuthSessionBroker = $broker - } - } - $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) - } -Force - - $step = [pscustomobject]@{ - PSTypeName = 'IdLE.Step' - Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - IdentityKey = 'testuser' - Attributes = @{ Department = 'IT' } - } - } + $step = $script:StepTemplate + $step.With.Remove('AuthSessionName') + $step.With.Remove('AuthSessionOptions') - # Act - $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step + $result = & $script:Handler -Context $script:Context -Step $step - # Assert - $result | Should -Not -BeNullOrEmpty $result.Status | Should -Be 'Completed' - $sessionAcquired | Should -Be $false + $script:State.SessionAcquired | Should -Be $false } It 'passes auth session to provider when provider supports AuthSession parameter' { - # Arrange - $testState = [pscustomobject]@{ - ReceivedAuthSession = $null - } - - $broker = [pscustomobject]@{ - PSTypeName = 'Tests.AuthSessionBroker' - } - $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return [PSCredential]::new('tier0admin', (ConvertTo-SecureString 'pass123' -AsPlainText -Force)) - } -Force - - $mockProvider = [pscustomobject]@{ - PSTypeName = 'Tests.MockProvider' - State = $testState - } - $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param($IdentityKey, $Name, $Value, $AuthSession) - $this.State.ReceivedAuthSession = $AuthSession - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Changed = $true - } - } -Force - - $context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Providers = @{ - Identity = $mockProvider - AuthSessionBroker = $broker - } - } - $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) - } -Force - - $step = [pscustomobject]@{ - PSTypeName = 'IdLE.Step' - Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - IdentityKey = 'testuser' - Attributes = @{ Department = 'IT' } - AuthSessionName = 'ActiveDirectory' - } - } - - # Act - $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step + $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate - # Assert $result | Should -Not -BeNullOrEmpty $result.Status | Should -Be 'Completed' - $testState.ReceivedAuthSession | Should -Not -BeNullOrEmpty - $testState.ReceivedAuthSession | Should -BeOfType [PSCredential] - $testState.ReceivedAuthSession.UserName | Should -Be 'tier0admin' + $script:State.ReceivedAuthSession | Should -Not -BeNullOrEmpty + $script:State.ReceivedAuthSession | Should -BeOfType [PSCredential] + $script:State.ReceivedAuthSession.UserName | Should -Be 'tier0admin' } It 'falls back to legacy signature when provider lacks AuthSession parameter' { - # Arrange - $testState = [pscustomobject]@{ - LegacyCallMade = $false - } - - $broker = [pscustomobject]@{ - PSTypeName = 'Tests.AuthSessionBroker' - } - $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return [PSCredential]::new('tier0admin', (ConvertTo-SecureString 'pass123' -AsPlainText -Force)) - } -Force - - # Provider without AuthSession parameter (legacy) - $mockProvider = [pscustomobject]@{ - PSTypeName = 'Tests.MockProvider' - State = $testState - } - $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { param($IdentityKey, $Name, $Value) $this.State.LegacyCallMade = $true return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' - Changed = $true + Changed = $true } } -Force - $context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Providers = @{ - Identity = $mockProvider - AuthSessionBroker = $broker - } - } - $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) - } -Force - - $step = [pscustomobject]@{ - PSTypeName = 'IdLE.Step' - Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - IdentityKey = 'testuser' - Attributes = @{ Department = 'IT' } - AuthSessionName = 'ActiveDirectory' - } - } - - # Act - $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step + $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate - # Assert $result | Should -Not -BeNullOrEmpty $result.Status | Should -Be 'Completed' - $testState.LegacyCallMade | Should -Be $true + $script:State.LegacyCallMade | Should -Be $true } - It 'throws when With.AuthSessionOptions is not a hashtable' { - # Arrange - $mockProvider = [pscustomobject]@{ - PSTypeName = 'Tests.MockProvider' - } - $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param($IdentityKey, $Name, $Value) - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Changed = $true - } - } -Force - - $context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Providers = @{ - Identity = $mockProvider - } - } - - $step = [pscustomobject]@{ - PSTypeName = 'IdLE.Step' - Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - IdentityKey = 'testuser' - Attributes = @{ Department = 'IT' } - AuthSessionName = 'ActiveDirectory' - AuthSessionOptions = 'invalid-string' - } - } + It 'throws when AuthSessionOptions is not a hashtable' { + $step = $script:StepTemplate + $step.With.AuthSessionOptions = 'invalid-string' - # Act & Assert - { Invoke-IdleStepEnsureAttributes -Context $context -Step $step } | + { & $script:Handler -Context $script:Context -Step $step } | Should -Throw '*AuthSessionOptions*hashtable*' } It 'acquires default auth session when AuthSessionName is absent but broker exists' { - # Arrange - $testState = [pscustomobject]@{ - SessionAcquired = $false - AcquiredName = $null - AcquiredOptions = $null - } - - $broker = [pscustomobject]@{ - PSTypeName = 'Tests.AuthSessionBroker' - State = $testState - } - $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - $this.State.SessionAcquired = $true - $this.State.AcquiredName = $Name - $this.State.AcquiredOptions = $Options - return [PSCredential]::new('defaultuser', (ConvertTo-SecureString 'defaultpass' -AsPlainText -Force)) - } -Force + $step = $script:StepTemplate + $step.With.Remove('AuthSessionName') + $step.With.Remove('AuthSessionOptions') - $mockProvider = [pscustomobject]@{ - PSTypeName = 'Tests.MockProvider' - } - $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param($IdentityKey, $Name, $Value, $AuthSession) - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Changed = $true - } - } -Force - - $context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Providers = @{ - Identity = $mockProvider - AuthSessionBroker = $broker - } - } - $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) - } -Force + $result = & $script:Handler -Context $script:Context -Step $step - $step = [pscustomobject]@{ - PSTypeName = 'IdLE.Step' - Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - IdentityKey = 'testuser' - Attributes = @{ Department = 'IT' } - # No AuthSessionName - should still try to acquire default session - } - } - - # Act - $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step - - # Assert $result | Should -Not -BeNullOrEmpty $result.Status | Should -Be 'Completed' - $testState.SessionAcquired | Should -Be $true - $testState.AcquiredName | Should -Be '' - $testState.AcquiredOptions | Should -BeNullOrEmpty + $script:State.SessionAcquired | Should -Be $true + $script:State.AcquiredName | Should -Be '' + $script:State.AcquiredOptions | Should -BeNullOrEmpty } It 'throws when AuthSessionName is set but no broker is available' { - # Arrange - $mockProvider = [pscustomobject]@{ - PSTypeName = 'Tests.MockProvider' - } - $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param($IdentityKey, $Name, $Value, $AuthSession) - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Changed = $true - } - } -Force - $context = [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionContext' - Providers = @{ - Identity = $mockProvider - # No AuthSessionBroker - } - } - - $step = [pscustomobject]@{ - PSTypeName = 'IdLE.Step' - Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - IdentityKey = 'testuser' - Attributes = @{ Department = 'IT' } - AuthSessionName = 'ActiveDirectory' # Explicitly set but no broker - } + Providers = @{ Identity = $script:Provider } } - # Act & Assert - { Invoke-IdleStepEnsureAttributes -Context $context -Step $step } | + { & $script:Handler -Context $context -Step $script:StepTemplate } | Should -Throw '*AuthSessionName*AcquireAuthSession*' } } diff --git a/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 b/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 index c0baee72..be1bf574 100644 --- a/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 @@ -27,57 +27,61 @@ Describe 'Invoke-IdleStepEnsureEntitlement (built-in step)' { } } - It 'grants entitlement when missing' { - $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + Context 'Behavior' { + It 'grants entitlement when missing' { + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') - $step = $script:StepTemplate - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + $step = $script:StepTemplate + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - $result = & $handler -Context $script:Context -Step $step + $result = & $handler -Context $script:Context -Step $step - $result.Status | Should -Be 'Completed' - $result.Changed | Should -BeTrue + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue - $assignments = $script:Provider.ListEntitlements('user1') - $assignments | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq 'demo-group' } | Should -Not -BeNullOrEmpty - } + $assignments = $script:Provider.ListEntitlements('user1') + $assignments | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq 'demo-group' } | Should -Not -BeNullOrEmpty + } - It 'skips grant when entitlement already present (case-insensitive id match)' { - $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') - $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'DEMO-GROUP' }) + It 'skips grant when entitlement already present (case-insensitive id match)' { + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'DEMO-GROUP' }) - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + $result = & $handler -Context $script:Context -Step $script:StepTemplate - $result.Status | Should -Be 'Completed' - $result.Changed | Should -BeFalse - } + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeFalse + } - It 'revokes entitlement when state is Absent' { - $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') - $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'demo-group' }) + It 'revokes entitlement when state is Absent' { + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'demo-group' }) - $step = $script:StepTemplate - $step.With.State = 'Absent' + $step = $script:StepTemplate + $step.With.State = 'Absent' - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - $result = & $handler -Context $script:Context -Step $step + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + $result = & $handler -Context $script:Context -Step $step - $result.Status | Should -Be 'Completed' - $result.Changed | Should -BeTrue + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue - $script:Provider.ListEntitlements('user1') | Should -BeNullOrEmpty + $script:Provider.ListEntitlements('user1') | Should -BeNullOrEmpty + } } - It 'throws when the provider is missing' { - $script:Context.Providers.Clear() + Context 'Validation' { + It 'throws when the provider is missing' { + $script:Context.Providers.Clear() - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * - } + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } - It 'bubbles up provider errors when the identity is unknown' { - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + It 'bubbles up provider errors when the identity is unknown' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 index 7e3568f3..436929ca 100644 --- a/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 @@ -68,44 +68,45 @@ Describe 'Invoke-IdleStepMailboxGetInfo' { } } - It 'retrieves mailbox and returns data in State' { - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $false - $result.State | Should -Not -BeNullOrEmpty - $result.State.Mailbox | Should -Not -BeNullOrEmpty - $result.State.Mailbox.IdentityKey | Should -Be 'user@contoso.com' - $result.State.Mailbox.Type | Should -Be 'User' - } - - It 'applies AuthSessionName convention (defaults to Provider)' { - # Remove AuthSessionName to test default behavior - $step = $script:StepTemplate - $step.With.Remove('AuthSessionName') - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - $result = & $handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - # Step should complete successfully using default AuthSessionName - # (Plan object should remain unmodified - AuthSessionName still absent) - $step.With.ContainsKey('AuthSessionName') | Should -Be $false - } - - It 'throws when provider is missing' { - $script:Context.Providers.Clear() - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + Context 'Behavior' { + It 'retrieves mailbox and returns data in State' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + $result.State | Should -Not -BeNullOrEmpty + $result.State.Mailbox | Should -Not -BeNullOrEmpty + $result.State.Mailbox.IdentityKey | Should -Be 'user@contoso.com' + $result.State.Mailbox.Type | Should -Be 'User' + } + + It 'applies AuthSessionName convention (defaults to Provider)' { + $step = $script:StepTemplate + $step.With.Remove('AuthSessionName') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $step.With.ContainsKey('AuthSessionName') | Should -Be $false + } } - - It 'throws when IdentityKey is missing' { - $step = $script:StepTemplate - $step.With.Remove('IdentityKey') - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + + Context 'Validation' { + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 index 09e21710..56f5c921 100644 --- a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 @@ -84,163 +84,166 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { } } - It 'enables Out of Office and reports Changed = true' { - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $true - - # Verify OOF was updated - $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Enabled' - $script:Provider.Store['user@contoso.com']['OOFInternalMessage'] | Should -Be 'I am out of office.' - } - - It 'is idempotent when OOF already matches desired state' { - # Set OOF to Enabled first - $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $false - } - - It 'disables Out of Office' { - # First enable it - $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' - - $step = $script:StepTemplate - $step.With.Config = @{ Mode = 'Disabled' } - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $true - $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Disabled' - } - - It 'configures scheduled Out of Office' { - $start = [DateTime]::Parse('2025-02-01T00:00:00Z') - $end = [DateTime]::Parse('2025-02-15T00:00:00Z') - - $step = $script:StepTemplate - $step.With.Config = @{ - Mode = 'Scheduled' - Start = $start - End = $end - InternalMessage = 'On vacation' + Context 'Behavior' { + It 'enables Out of Office and reports Changed = true' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Enabled' + $script:Provider.Store['user@contoso.com']['OOFInternalMessage'] | Should -Be 'I am out of office.' } - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $true - $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Scheduled' - } - - It 'throws when Config is missing' { - $step = $script:StepTemplate - $step.With.Remove('Config') - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config*" - } - - It 'throws when Config.Mode is missing' { - $step = $script:StepTemplate - $step.With.Config = @{ InternalMessage = 'Test' } - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config.Mode*" - } - - It 'throws when Config.Mode is invalid' { - $step = $script:StepTemplate - $step.With.Config.Mode = 'InvalidMode' - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*Mode to be one of: Disabled, Enabled, Scheduled*" - } - - It 'throws when Scheduled mode is missing Start' { - $step = $script:StepTemplate - $step.With.Config = @{ - Mode = 'Scheduled' - End = [DateTime]::Now + It 'is idempotent when OOF already matches desired state' { + $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false } - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*Mode 'Scheduled' requires With.Config.Start*" - } - - It 'throws when Scheduled mode is missing End' { - $step = $script:StepTemplate - $step.With.Config = @{ - Mode = 'Scheduled' - Start = [DateTime]::Now + It 'disables Out of Office' { + $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' + + $step = $script:StepTemplate + $step.With.Config = @{ Mode = 'Disabled' } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Disabled' } - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*Mode 'Scheduled' requires With.Config.End*" - } - - It 'rejects ScriptBlocks in Config (security boundary)' { - $step = $script:StepTemplate - $step.With.Config.InternalMessage = { Write-Host "malicious" } - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*ScriptBlocks are not allowed*" - } - - It 'throws when provider is missing' { - $script:Context.Providers.Clear() - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + It 'configures scheduled Out of Office' { + $start = [DateTime]::Parse('2025-02-01T00:00:00Z') + $end = [DateTime]::Parse('2025-02-15T00:00:00Z') + + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + Start = $start + End = $end + InternalMessage = 'On vacation' + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Scheduled' + } } - - It 'throws when IdentityKey is missing' { - $step = $script:StepTemplate - $step.With.Remove('IdentityKey') + + Context 'Validation' { + It 'throws when Config is missing' { + $step = $script:StepTemplate + $step.With.Remove('Config') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config*" + } - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" - } - - It 'accepts MessageFormat = Text' { - $step = $script:StepTemplate - $step.With.Config.MessageFormat = 'Text' + It 'throws when Config.Mode is missing' { + $step = $script:StepTemplate + $step.With.Config = @{ InternalMessage = 'Test' } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config.Mode*" + } - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step + It 'throws when Config.Mode is invalid' { + $step = $script:StepTemplate + $step.With.Config.Mode = 'InvalidMode' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode to be one of: Disabled, Enabled, Scheduled*" + } - $result.Status | Should -Be 'Completed' - } - - It 'accepts MessageFormat = Html' { - $step = $script:StepTemplate - $step.With.Config.MessageFormat = 'Html' + It 'throws when Scheduled mode is missing Start' { + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + End = [DateTime]::Now + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode 'Scheduled' requires With.Config.Start*" + } - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step + It 'throws when Scheduled mode is missing End' { + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + Start = [DateTime]::Now + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode 'Scheduled' requires With.Config.End*" + } - $result.Status | Should -Be 'Completed' + It 'rejects ScriptBlocks in Config (security boundary)' { + $step = $script:StepTemplate + $step.With.Config.InternalMessage = { Write-Host "malicious" } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*ScriptBlocks are not allowed*" + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } } - - It 'throws when MessageFormat is invalid' { - $step = $script:StepTemplate - $step.With.Config.MessageFormat = 'InvalidFormat' - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*MessageFormat to be one of: Text, Html*" + + Context 'Message format' { + It 'accepts MessageFormat = Text' { + $step = $script:StepTemplate + $step.With.Config.MessageFormat = 'Text' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'accepts MessageFormat = Html' { + $step = $script:StepTemplate + $step.With.Config.MessageFormat = 'Html' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'throws when MessageFormat is invalid' { + $step = $script:StepTemplate + $step.With.Config.MessageFormat = 'InvalidFormat' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*MessageFormat to be one of: Text, Html*" + } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 index 535321cb..98dc93f7 100644 --- a/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 @@ -76,74 +76,75 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { } } - It 'converts mailbox type and reports Changed = true' { - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $true - - # Verify mailbox was updated - $script:Provider.Store['user@contoso.com']['Type'] | Should -Be 'Shared' - } - - It 'is idempotent when mailbox already has desired type' { - # Set mailbox to Shared first - $script:Provider.Store['user@contoso.com']['Type'] = 'Shared' - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $false - } - - It 'throws when MailboxType is invalid' { - $step = $script:StepTemplate - $step.With.MailboxType = 'InvalidType' - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*MailboxType to be one of: User, Shared, Room, Equipment*" - } - - It 'throws when provider is missing' { - $script:Context.Providers.Clear() - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * - } - - It 'throws when IdentityKey is missing' { - $step = $script:StepTemplate - $step.With.Remove('IdentityKey') - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" - } - - It 'throws when MailboxType is missing' { - $step = $script:StepTemplate - $step.With.Remove('MailboxType') - - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.MailboxType*" + Context 'Behavior' { + It 'converts mailbox type and reports Changed = true' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + + $script:Provider.Store['user@contoso.com']['Type'] | Should -Be 'Shared' + } + + It 'is idempotent when mailbox already has desired type' { + $script:Provider.Store['user@contoso.com']['Type'] = 'Shared' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + } + + It 'supports all valid mailbox types' { + foreach ($type in @('Shared', 'Room', 'Equipment', 'User')) { + $startType = if ($type -eq 'User') { 'Shared' } else { 'User' } + $script:Provider.Store['user@contoso.com']['Type'] = $startType + + $step = $script:StepTemplate + $step.With.MailboxType = $type + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['Type'] | Should -Be $type + } + } } - - It 'supports all valid mailbox types' { - foreach ($type in @('Shared', 'Room', 'Equipment', 'User')) { - # Always set to a different type first - $startType = if ($type -eq 'User') { 'Shared' } else { 'User' } - $script:Provider.Store['user@contoso.com']['Type'] = $startType - + + Context 'Validation' { + It 'throws when MailboxType is invalid' { $step = $script:StepTemplate - $step.With.MailboxType = $type - + $step.With.MailboxType = 'InvalidType' + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - $result = & $handler -Context $script:Context -Step $step - - $result.Changed | Should -Be $true - $script:Provider.Store['user@contoso.com']['Type'] | Should -Be $type + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*MailboxType to be one of: User, Shared, Room, Equipment*" + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } + + It 'throws when MailboxType is missing' { + $step = $script:StepTemplate + $step.With.Remove('MailboxType') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.MailboxType*" } } } diff --git a/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 b/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 index 76dce941..10b43f35 100644 --- a/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 @@ -66,114 +66,120 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { } } - It 'calls provider RevokeSessions method with correct identity key' { - $step = $script:StepTemplate - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + Context 'Behavior' { + It 'calls provider RevokeSessions method with correct identity key' { + $step = $script:StepTemplate + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + $result = & $handler -Context $script:Context -Step $step - $result.Status | Should -Be 'Completed' - $result.Changed | Should -Be $true - $script:FakeProvider.CallLog.Count | Should -Be 1 - $script:FakeProvider.CallLog[0].IdentityKey | Should -Be 'user@contoso.com' - } - - It 'returns StepResult with correct shape' { - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result | Should -Not -BeNullOrEmpty - $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' - $result.Name | Should -Be 'Revoke sessions' - $result.Type | Should -Be 'IdLE.Step.RevokeIdentitySessions' - $result.Status | Should -Be 'Completed' - $result.PSObject.Properties.Name | Should -Contain 'Changed' - $result.PSObject.Properties.Name | Should -Contain 'Error' - $result.Error | Should -BeNullOrEmpty - } - - It 'acquires auth session when AuthSessionName is provided' { - $step = $script:StepTemplate - $step.With.AuthSessionName = 'MicrosoftGraph' - $step.With.AuthSessionOptions = @{ Role = 'Admin' } + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:FakeProvider.CallLog.Count | Should -Be 1 + $script:FakeProvider.CallLog[0].IdentityKey | Should -Be 'user@contoso.com' + } - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + It 'returns StepResult with correct shape' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' + $result.Name | Should -Be 'Revoke sessions' + $result.Type | Should -Be 'IdLE.Step.RevokeIdentitySessions' + $result.Status | Should -Be 'Completed' + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'Error' + $result.Error | Should -BeNullOrEmpty + } - $result.Status | Should -Be 'Completed' - $script:FakeProvider.CallLog.Count | Should -Be 1 - $script:FakeProvider.CallLog[0].AuthSession | Should -Not -BeNullOrEmpty - $script:FakeProvider.CallLog[0].AuthSession.SessionName | Should -Be 'MicrosoftGraph' + It 'respects Changed flag from provider result' { + $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name RevokeSessions -Value { + param($IdentityKey, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeSessions' + IdentityKey = $IdentityKey + Changed = $false + } + } -Force + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Changed | Should -Be $false + } } - It 'throws when With.IdentityKey is missing' { - $step = $script:StepTemplate - $step.With.Remove('IdentityKey') - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' - } + Context 'Auth session acquisition' { + It 'acquires auth session when AuthSessionName is provided' { + $step = $script:StepTemplate + $step.With.AuthSessionName = 'MicrosoftGraph' + $step.With.AuthSessionOptions = @{ Role = 'Admin' } - It 'throws when provider is missing' { - $script:Context.Providers.Clear() + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $step - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + $script:FakeProvider.CallLog[0].AuthSession | Should -Not -BeNullOrEmpty + $script:FakeProvider.CallLog[0].AuthSession.SessionName | Should -Be 'MicrosoftGraph' + } } - It 'throws when provider does not support RevokeSessions method' { - # Create a provider without RevokeSessions support - $unsupportedProvider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.FakeWithoutRevoke' - } - $unsupportedProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Read') + Context 'Validation' { + It 'throws when With.IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' } - $script:Context.Providers['Identity'] = $unsupportedProvider + It 'throws when provider is missing' { + $script:Context.Providers.Clear() - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * - } + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' + } - It 'respects Changed flag from provider result' { - # Modify provider to return Changed=false - $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name RevokeSessions -Value { - param($IdentityKey, $AuthSession) - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'RevokeSessions' - IdentityKey = $IdentityKey - Changed = $false + It 'throws when provider does not support RevokeSessions method' { + $unsupportedProvider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.FakeWithoutRevoke' + } + $unsupportedProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Read') } - } -Force - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $script:Context.Providers['Identity'] = $unsupportedProvider - $result.Changed | Should -Be $false + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } } - It 'uses default provider alias "Identity" when not specified' { - $step = $script:StepTemplate - $step.With.Remove('Provider') + Context 'Provider selection' { + It 'uses default provider alias "Identity" when not specified' { + $step = $script:StepTemplate + $step.With.Remove('Provider') - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $step - $result.Status | Should -Be 'Completed' - $script:FakeProvider.CallLog.Count | Should -Be 1 - } + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + } - It 'supports custom provider alias' { - $script:Context.Providers['CustomEntra'] = $script:FakeProvider - $step = $script:StepTemplate - $step.With.Provider = 'CustomEntra' + It 'supports custom provider alias' { + $script:Context.Providers['CustomEntra'] = $script:FakeProvider + $step = $script:StepTemplate + $step.With.Provider = 'CustomEntra' - $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $step - $result.Status | Should -Be 'Completed' - $script:FakeProvider.CallLog.Count | Should -Be 1 + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + } } } diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index 6759ebd5..2e5e8ded 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -70,3 +70,149 @@ function Get-ModuleManifestPaths { Select-Object -ExpandProperty FullName } +function New-IdleTestRequest { + [CmdletBinding()] + param( + [Parameter()] + [string] $LifecycleEvent = 'Joiner', + + [Parameter()] + [hashtable] $IdentityKeys, + + [Parameter()] + [hashtable] $DesiredState, + + [Parameter()] + [hashtable] $Changes, + + [Parameter()] + [string] $CorrelationId, + + [Parameter()] + [string] $Actor + ) + + $params = @{ LifecycleEvent = $LifecycleEvent } + + if ($PSBoundParameters.ContainsKey('IdentityKeys')) { $params.IdentityKeys = $IdentityKeys } + if ($PSBoundParameters.ContainsKey('DesiredState')) { $params.DesiredState = $DesiredState } + if ($PSBoundParameters.ContainsKey('Changes')) { $params.Changes = $Changes } + if ($PSBoundParameters.ContainsKey('CorrelationId')) { $params.CorrelationId = $CorrelationId } + if ($PSBoundParameters.ContainsKey('Actor')) { $params.Actor = $Actor } + + return New-IdleRequest @params +} + +function New-IdleTestWorkflowFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Content, + + [Parameter()] + [string] $FileName = 'test.psd1', + + [Parameter()] + [string] $BasePath + ) + + if (-not $BasePath) { + $testDriveVar = $null + foreach ($scope in @('Local', 'Script', 'Global', 1, 2)) { + try { + $testDriveVar = Get-Variable -Name TestDrive -Scope $scope -ErrorAction SilentlyContinue + if ($testDriveVar) { break } + } catch { + continue + } + } + + if ($testDriveVar) { + $BasePath = $testDriveVar.Value + } else { + throw 'BasePath is required when TestDrive is not available.' + } + } + + $path = Join-Path -Path $BasePath -ChildPath $FileName + Set-Content -Path $path -Encoding UTF8 -Value $Content + return $path +} + +function New-IdleTestModuleLayout { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $DestinationRoot, + + [Parameter()] + [string[]] $Modules = @('IdLE', 'IdLE.Core', 'IdLE.Steps.Common') + ) + + $repoRoot = Get-RepoRootPath + + if (-not (Test-Path -Path $DestinationRoot)) { + $null = New-Item -Path $DestinationRoot -ItemType Directory -Force + } + + foreach ($moduleName in $Modules) { + $sourcePath = Join-Path -Path $repoRoot -ChildPath (Join-Path 'src' $moduleName) + $destPath = Join-Path -Path $DestinationRoot -ChildPath $moduleName + Copy-Item -Path $sourcePath -Destination $destPath -Recurse -Force + } + + return [pscustomobject]@{ + Root = $DestinationRoot + Modules = $Modules + } +} + +function Invoke-IdleIsolatedPwsh { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Script, + + [Parameter()] + [hashtable] $Environment, + + [Parameter()] + [string] $WorkingDirectory + ) + + $pwshPath = (Get-Command -Name 'pwsh' -ErrorAction Stop).Source + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($Script)) + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $pwshPath + $psi.Arguments = "-NoProfile -NonInteractive -EncodedCommand $encoded" + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + + if ($WorkingDirectory) { + $psi.WorkingDirectory = $WorkingDirectory + } + + if ($Environment) { + foreach ($key in $Environment.Keys) { + $value = $Environment[$key] + $psi.Environment[$key] = if ($null -eq $value) { '' } else { [string]$value } + } + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $psi + $null = $process.Start() + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout + StdErr = $stderr + } +} + diff --git a/tools/Invoke-IdlePesterTests.ps1 b/tools/Invoke-IdlePesterTests.ps1 index 4daae10b..a723a73a 100644 --- a/tools/Invoke-IdlePesterTests.ps1 +++ b/tools/Invoke-IdlePesterTests.ps1 @@ -241,4 +241,11 @@ if ($coverageEnabled -and $resolvedCoverageOutputPath) { } } -Invoke-Pester -Configuration $config +$previousProgressPreference = $global:ProgressPreference +try { + $global:ProgressPreference = 'SilentlyContinue' + Invoke-Pester -Configuration $config +} +finally { + $global:ProgressPreference = $previousProgressPreference +} diff --git a/tools/import-idle.ps1 b/tools/import-idle.ps1 index c8e1c8e8..b478d4ec 100644 --- a/tools/import-idle.ps1 +++ b/tools/import-idle.ps1 @@ -20,7 +20,7 @@ Get-Module -All IdLE* | # Verify public API surface $expectedCommands = @( 'Invoke-IdlePlan', - 'New-IdleLifecycleRequest', + 'New-IdleRequest', 'New-IdlePlan', 'Test-IdleWorkflow' ) @@ -46,3 +46,4 @@ foreach ($wf in $workflowPaths) { } Write-Host "All example workflows validated successfully." -ForegroundColor Green + diff --git a/website/sidebars.js b/website/sidebars.js index 3c3bcae7..0bb0ed3e 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -69,7 +69,7 @@ const sidebars = { 'reference/cmdlets/Export-IdlePlan', 'reference/cmdlets/Invoke-IdlePlan', 'reference/cmdlets/New-IdleAuthSession', - 'reference/cmdlets/New-IdleLifecycleRequest', + 'reference/cmdlets/New-IdleRequest', 'reference/cmdlets/New-IdlePlan', 'reference/cmdlets/Test-IdleWorkflow', ],