From 40c72a5f1291ed171b5c21fbc0ae209c08f1155e Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:31:51 +0100 Subject: [PATCH 01/14] Fix precondition warning propagation after codex reintegration --- src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 | 11 +++++++++++ tests/Core/Export-IdlePlan.Tests.ps1 | 2 ++ 2 files changed, 13 insertions(+) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 64779e4c..7de8bacc 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -132,6 +132,16 @@ function ConvertTo-IdleWorkflowSteps { $precondition = $preconditionSettings.Precondition $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse $preconditionEvent = $preconditionSettings.PreconditionEvent + $preconditionWarnings = @() + + $planWarnings = Get-IdlePropertyValue -Object $PlanningContext.Plan -Name 'Warnings' + if ($null -ne $planWarnings) { + $preconditionWarnings = @($planWarnings | Where-Object { + $warningStep = Get-IdlePropertyValue -Object $_ -Name 'Step' + $warningSource = Get-IdlePropertyValue -Object $_ -Name 'Source' + $warningStep -eq $stepName -and $warningSource -eq 'Precondition' + }) + } $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' @@ -142,6 +152,7 @@ function ConvertTo-IdleWorkflowSteps { Precondition = $precondition OnPreconditionFalse = $onPreconditionFalse PreconditionEvent = $preconditionEvent + Warnings = $preconditionWarnings With = $with RequiresCapabilities = $requiresCaps Status = $status diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index 8d45f92c..cc38bebc 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -114,6 +114,8 @@ Describe 'Export-IdlePlan' { $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers @($plan.Warnings).Count | Should -BeGreaterThan 0 + @($plan.Steps[0].Warnings).Count | Should -BeGreaterThan 0 + $plan.Steps[0].Warnings[0].Code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' $json = $plan | Export-IdlePlan | ConvertFrom-Json @($json.plan.warnings).Count | Should -BeGreaterThan 0 From 5d1982f4edd442219c7ba3172493f5e6919328df Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:53:17 +0100 Subject: [PATCH 02/14] Fix unambiguous precondition warning association per step --- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 22 +++++++--- tests/Core/New-IdlePlan.Tests.ps1 | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 7de8bacc..218f7a70 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -128,19 +128,27 @@ function ConvertTo-IdleWorkflowSteps { $null } + $planWarnings = Get-IdlePropertyValue -Object $PlanningContext.Plan -Name 'Warnings' + $planWarningsCanTrackCount = $null -ne $planWarnings -and $null -ne $planWarnings.PSObject.Properties['Count'] + $warningCountBefore = if ($planWarningsCanTrackCount) { [int]$planWarnings.Count } else { 0 } + $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName -PlanningContext $PlanningContext $precondition = $preconditionSettings.Precondition $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse $preconditionEvent = $preconditionSettings.PreconditionEvent $preconditionWarnings = @() - $planWarnings = Get-IdlePropertyValue -Object $PlanningContext.Plan -Name 'Warnings' - if ($null -ne $planWarnings) { - $preconditionWarnings = @($planWarnings | Where-Object { - $warningStep = Get-IdlePropertyValue -Object $_ -Name 'Step' - $warningSource = Get-IdlePropertyValue -Object $_ -Name 'Source' - $warningStep -eq $stepName -and $warningSource -eq 'Precondition' - }) + if ($planWarningsCanTrackCount) { + $warningCountAfter = [int]$planWarnings.Count + if ($warningCountAfter -gt $warningCountBefore) { + for ($warningIndex = $warningCountBefore; $warningIndex -lt $warningCountAfter; $warningIndex++) { + $warning = $planWarnings[$warningIndex] + $warningSource = Get-IdlePropertyValue -Object $warning -Name 'Source' + if ($warningSource -eq 'Precondition') { + $preconditionWarnings += $warning + } + } + } } $normalizedSteps += [pscustomobject]@{ diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 29556a3a..05849d82 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -141,6 +141,49 @@ Describe 'New-IdlePlan' { $plan.OnFailureSteps[1].Type | Should -Be 'IdLE.Step.NeverApplicable' $plan.OnFailureSteps[1].Status | Should -Be 'NotApplicable' } + + It 'associates precondition warnings with the correct step even when Steps and OnFailureSteps share a name' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-shared-step-name-warning.psd1' -Content @' +@{ + Name = 'Joiner - Shared Step Name Warning' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'SharedName' + Type = 'IdLE.Step.ResolveIdentity' + Precondition = @{ Exists = 'Request.Context.MissingAtPlan' } + } + ) + OnFailureSteps = @( + @{ + Name = 'SharedName' + Type = 'IdLE.Step.Containment' + With = @{ Mode = 'Quarantine' } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @( + 'IdLE.Step.ResolveIdentity', + 'IdLE.Step.Containment' + ) + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + @($plan.Warnings).Count | Should -BeGreaterThan 0 + @($plan.Steps[0].Warnings).Count | Should -Be 1 + $plan.Steps[0].Warnings[0].Code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' + @($plan.OnFailureSteps[0].Warnings).Count | Should -Be 0 + } } Context 'Validation' { From b59b47bc4b00fe67a93f19ffbd68cddd4ab3b30d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:57:07 +0000 Subject: [PATCH 03/14] Initial plan From 8d1528dddc9172d9219b2c846d17bf3d2957644d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:04:21 +0000 Subject: [PATCH 04/14] Fix empty ArrayList collapse when tracking precondition warnings per step Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 218f7a70..2248e0e8 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -128,8 +128,17 @@ function ConvertTo-IdleWorkflowSteps { $null } - $planWarnings = Get-IdlePropertyValue -Object $PlanningContext.Plan -Name 'Warnings' - $planWarningsCanTrackCount = $null -ne $planWarnings -and $null -ne $planWarnings.PSObject.Properties['Count'] + $planWarnings = $null + $planObj = $PlanningContext.Plan + if ($null -ne $planObj) { + if ($planObj -is [System.Collections.IDictionary]) { + if ($planObj.Contains('Warnings')) { $planWarnings = $planObj['Warnings'] } + } else { + $wProp = $planObj.PSObject.Properties['Warnings'] + if ($null -ne $wProp) { $planWarnings = $wProp.Value } + } + } + $planWarningsCanTrackCount = $planWarnings -is [System.Collections.IList] $warningCountBefore = if ($planWarningsCanTrackCount) { [int]$planWarnings.Count } else { 0 } $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName -PlanningContext $PlanningContext From 55d731c4322574b684d2f93ec5160b268506335e Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:48:09 +0100 Subject: [PATCH 05/14] Update src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 2248e0e8..48a5e741 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -153,7 +153,8 @@ function ConvertTo-IdleWorkflowSteps { for ($warningIndex = $warningCountBefore; $warningIndex -lt $warningCountAfter; $warningIndex++) { $warning = $planWarnings[$warningIndex] $warningSource = Get-IdlePropertyValue -Object $warning -Name 'Source' - if ($warningSource -eq 'Precondition') { + $warningStep = Get-IdlePropertyValue -Object $warning -Name 'Step' + if ($warningSource -eq 'Precondition' -and $warningStep -eq $stepName) { $preconditionWarnings += $warning } } From f9f5f82a467a0fcc0b9009e7c78467f45bdbbdbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:56:48 +0000 Subject: [PATCH 06/14] Initial plan From a3fab9238a33724097ed63c65a382669206a7f98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:09:01 +0000 Subject: [PATCH 07/14] Add execution-time path validation for precondition operators Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Assert-IdleConditionPathsResolvable.ps1 | 38 +++++++++++++------ .../Public/Invoke-IdlePlanObject.ps1 | 17 +++++++-- .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 19 ++++++++++ .../missing-context-at-invoke.psd1 | 15 ++++++++ 4 files changed, 73 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 0249a54b..d8f1f801 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -24,7 +24,13 @@ function Assert-IdleConditionPathsResolvable { [Parameter()] [AllowNull()] - [object] $WarningSink + [object] $WarningSink, + + # When set, skips validation of paths used by the Exists operator. + # Exists semantics intentionally allow missing paths (returns $false if absent), + # so strict execution-time path validation should exclude those paths. + [Parameter()] + [switch] $ExcludeExistsOperatorPaths ) function Add-IdlePathIfPresent { @@ -61,13 +67,16 @@ function Assert-IdleConditionPathsResolvable { [Parameter(Mandatory)] [AllowEmptyCollection()] - [System.Collections.Generic.List[string]] $PathList + [System.Collections.Generic.List[string]] $PathList, + + [Parameter()] + [switch] $ExcludeExistsPaths ) if ($Node.Contains('All')) { foreach ($child in @($Node.All)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -76,7 +85,7 @@ function Assert-IdleConditionPathsResolvable { if ($Node.Contains('Any')) { foreach ($child in @($Node.Any)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -85,7 +94,7 @@ function Assert-IdleConditionPathsResolvable { if ($Node.Contains('None')) { foreach ($child in @($Node.None)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -102,12 +111,17 @@ function Assert-IdleConditionPathsResolvable { } if ($Node.Contains('Exists')) { - $existsVal = $Node.Exists - if ($existsVal -is [string]) { - Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal - } - elseif ($existsVal -is [System.Collections.IDictionary]) { - Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path + # Exists operator semantics: checking for the presence of a path is intentional. + # When -ExcludeExistsPaths is set (e.g. strict execution-time validation), skip these + # so that Exists can still return $false without causing a path-not-found error. + if (-not $ExcludeExistsPaths) { + $existsVal = $Node.Exists + if ($existsVal -is [string]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal + } + elseif ($existsVal -is [System.Collections.IDictionary]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path + } } return } @@ -119,7 +133,7 @@ function Assert-IdleConditionPathsResolvable { } $paths = [System.Collections.Generic.List[string]]::new() - Get-IdleConditionPaths -Node $Condition -PathList $paths + Get-IdleConditionPaths -Node $Condition -PathList $paths -ExcludeExistsPaths:$ExcludeExistsOperatorPaths $uniquePaths = @($paths | Select-Object -Unique) if ($uniquePaths.Count -eq 0) { diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index f3484670..9cf265cb 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -330,8 +330,13 @@ function Invoke-IdlePlanObject { # Fail closed: a malformed or unexpected node type is treated as a failed precondition. $preconditionPassed = $false } - elseif (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) { - $preconditionPassed = $false + else { + # Validate that all non-Exists paths exist at execution time. + # Exists operator paths are excluded because Exists semantics intentionally allow missing paths. + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext -StepName $stepName -Source 'Precondition' -ExcludeExistsOperatorPaths + if (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) { + $preconditionPassed = $false + } } if (-not $preconditionPassed) { @@ -626,8 +631,12 @@ function Invoke-IdlePlanObject { if ($ofPrecondition -isnot [System.Collections.IDictionary]) { $ofPreconditionPassed = $false } - elseif (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { - $ofPreconditionPassed = $false + else { + # Validate that all non-Exists paths exist at execution time. + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext -StepName $ofName -Source 'Precondition' -ExcludeExistsOperatorPaths + if (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { + $ofPreconditionPassed = $false + } } if (-not $ofPreconditionPassed) { diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 8b8c19cb..5bee17cc 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -374,5 +374,24 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 } } + + Context 'Unresolvable precondition path at execution time' { + It 'throws when a non-Exists precondition path is missing from the request context at invoke time' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'missing-context-at-invoke.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.MissingContextAtInvoke' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.MissingContextAtInvoke') + } + + # Planning succeeds with a soft warning for the missing Request.Context path. + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $plan | Should -Not -BeNullOrEmpty + @($plan.Warnings).Count | Should -BeGreaterThan 0 + + # Execution must throw because the path is still missing at runtime. + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*unresolved condition path*' + } + } } diff --git a/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 b/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 new file mode 100644 index 00000000..8737d0a3 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 @@ -0,0 +1,15 @@ +@{ + Name = 'Missing Context Path At Invoke' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.MissingContextAtInvoke' + Precondition = @{ + All = @( + @{ In = @{ Path = 'Request.Context.NA'; Values = @('EU', 'DE') } } + ) + } + } + ) +} From 1fc5197de6f9b4bf841216cadd61789369693710 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:03:22 +0000 Subject: [PATCH 08/14] Initial plan From 18f116abe6a503bf020d74496a124bd546a3f7e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:25:18 +0000 Subject: [PATCH 09/14] Fix precondition plan warning visibility and PSSA issues - Add Write-Warning to Assert-IdleConditionPathsResolvable so missing Request.Context.* paths in preconditions emit a visible terminal warning during New-IdlePlan in addition to being stored in plan.Warnings - Rename Normalize-IdleExchangeOnlineAutoReplyMessage to Format-IdleExchangeOnlineAutoReplyMessage (PSUseApprovedVerbs) - Apply Invoke-Formatter to fix PSUseConsistentWhitespace and PSUseConsistentIndentation across 7 source files Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Assert-IdleConditionPathsResolvable.ps1 | 34 +- .../Private/Get-IdleReadOnlyCapabilities.ps1 | 2 +- .../Public/Invoke-IdlePlanObject.ps1 | 6 +- .../Public/New-IdleAuthSessionBroker.ps1 | 8 +- .../Public/New-IdleRequestObject.ps1 | 4 +- .../Private/Get-IdleADAttributeContract.ps1 | 96 +++--- .../Private/New-IdleADAdapter.ps1 | 2 +- ...at-IdleExchangeOnlineAutoReplyMessage.ps1} | 8 +- .../Public/New-IdleExchangeOnlineProvider.ps1 | 290 +++++++++--------- .../ExchangeOnlineProvider.Tests.ps1 | 22 +- 10 files changed, 238 insertions(+), 234 deletions(-) rename src/IdLE.Provider.ExchangeOnline/Private/{Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 => Format-IdleExchangeOnlineAutoReplyMessage.ps1} (91%) diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index d8f1f801..83945bfe 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -152,22 +152,26 @@ function Assert-IdleConditionPathsResolvable { } } - if ($softMissingContextPaths.Count -gt 0 -and $null -ne $WarningSink) { - $warningItem = [ordered]@{ - Code = 'PreconditionContextPathUnresolvedAtPlan' - Type = 'Warning' - Step = $StepName - Source = $Source - Paths = @($softMissingContextPaths | Select-Object -Unique) - Message = ("Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', @($softMissingContextPaths | Select-Object -Unique)))) - } + if ($softMissingContextPaths.Count -gt 0) { + $uniqueSoftPaths = @($softMissingContextPaths | Select-Object -Unique) + $warningMessage = "Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', $uniqueSoftPaths)) + + # Emit a visible PowerShell warning for immediate host feedback during planning. + Write-Warning $warningMessage + + if ($null -ne $WarningSink) { + $warningItem = [ordered]@{ + Code = 'PreconditionContextPathUnresolvedAtPlan' + Type = 'Warning' + Step = $StepName + Source = $Source + Paths = $uniqueSoftPaths + Message = $warningMessage + } - if ($WarningSink -is [System.Collections.IList]) { - $null = $WarningSink.Add($warningItem) - } - elseif ($WarningSink -is [object[]]) { - # Fallback for fixed arrays: cannot mutate by reference safely. - # Caller should pass an IList (plan.Warnings is an ArrayList) for collection. + if ($WarningSink -is [System.Collections.IList]) { + $null = $WarningSink.Add($warningItem) + } } } diff --git a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 index 634bc049..e956032e 100644 --- a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 @@ -70,7 +70,7 @@ function Get-IdleCapabilityContextPath { switch ($Capability) { 'IdLE.Entitlement.List' { return 'Identity.Entitlements' } - 'IdLE.Identity.Read' { return 'Identity.Profile' } + 'IdLE.Identity.Read' { return 'Identity.Profile' } default { throw [System.ArgumentException]::new( "No predefined context path defined for capability '$Capability'.", diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 9cf265cb..2374cca9 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -118,7 +118,7 @@ function Invoke-IdlePlanObject { # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries if ($null -ne $planProviders) { $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or - ($planProviders.PSObject -and $planProviders.PSObject.Properties) + ($planProviders.PSObject -and $planProviders.PSObject.Properties) if ($isValidProvider) { $effectiveProviders = $planProviders } @@ -359,7 +359,7 @@ function Invoke-IdlePlanObject { $pcEvt = Get-IdlePropertyValue -Object $step -Name 'PreconditionEvent' if ($null -ne $pcEvt) { $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type') - $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') + $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data' # PreconditionEvent.Data is validated as a hashtable at planning time and # stored via Copy-IdleDataObject, so it will be a hashtable (IDictionary) here. @@ -659,7 +659,7 @@ function Invoke-IdlePlanObject { $ofPcEvt = Get-IdlePropertyValue -Object $ofStep -Name 'PreconditionEvent' if ($null -ne $ofPcEvt) { $ofPcEvtType = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Type') - $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') + $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') $ofPcEvtData = Get-IdlePropertyValue -Object $ofPcEvt -Name 'Data' $ofPcEvtDataHt = if ($ofPcEvtData -is [System.Collections.IDictionary]) { [hashtable]$ofPcEvtData } else { $null } $context.EventSink.WriteEvent($ofPcEvtType, $ofPcEvtMsg, $ofName, $ofPcEvtDataHt) diff --git a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 index 8a29b9dc..e4b709f3 100644 --- a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 +++ b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 @@ -370,10 +370,10 @@ function New-IdleAuthSessionBroker { # If multiple matches, this is ambiguous - fail with clear error if ($matchingEntries.Count -gt 1) { $matchDetails = ($matchingEntries | ForEach-Object { - $currentEntry = $_ - $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' - "{ $keyStr }" - }) -join '; ' + $currentEntry = $_ + $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' + "{ $keyStr }" + }) -join '; ' throw "Ambiguous auth session match for Name='$Name'. Multiple entries matched: $matchDetails. Provide AuthSessionOptions to disambiguate." } diff --git a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 index 77d8d096..48e6b915 100644 --- a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -100,8 +100,8 @@ function New-IdleRequestObject { # Clone hashtables to avoid external mutation after object creation # shallow clone is sufficient as we have already validated no ScriptBlocks are present $IdentityKeys = if ($null -eq $IdentityKeys) { @{} } else { $IdentityKeys.Clone() } - $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } - $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } + $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } + $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } # Construct and return the core domain object defined in Private/IdleLifecycleRequest.ps1 return [IdleLifecycleRequest]::new( diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 index 1e8cb77d..e42c0f70 100644 --- a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -46,88 +46,88 @@ function Get-IdleADAttributeContract { if ($Operation -eq 'CreateIdentity') { $contract = @{ # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Name Attributes - Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Password Attributes AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false } - AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } - AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # State Attributes - Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # Extension Container (keys must be valid LDAP attribute names) - OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } } } elseif ($Operation -eq 'EnsureAttributes') { $contract = @{ # Name Attributes - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Address Attributes - StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - City = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - State = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + City = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + State = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Web / Profile Attributes - HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Account / Profile Path Attributes - HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Extension Container (keys must be valid LDAP attribute names, e.g. 'mobile', 'telephoneNumber') OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 28b77786..461609ad 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -331,7 +331,7 @@ function New-IdleADAdapter { Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from DisplayName" } elseif ($effectiveAttributes.ContainsKey('GivenName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['GivenName']) -and - $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { + $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { $derivedName = "$($effectiveAttributes['GivenName']) $($effectiveAttributes['Surname'])" Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from GivenName+Surname" } diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 similarity index 91% rename from src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 rename to src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 index 43536e1b..3303a5de 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -1,7 +1,7 @@ -function Normalize-IdleExchangeOnlineAutoReplyMessage { +function Format-IdleExchangeOnlineAutoReplyMessage { <# .SYNOPSIS - Normalizes Exchange Online auto-reply messages for stable idempotency comparison. + Formats Exchange Online auto-reply messages for stable idempotency comparison. .DESCRIPTION Exchange Online may introduce server-side canonicalization when storing automatic reply messages, @@ -26,8 +26,8 @@ function Normalize-IdleExchangeOnlineAutoReplyMessage { System.String - The normalized message string. .EXAMPLE - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentMessage - if ($normalized -eq (Normalize-IdleExchangeOnlineAutoReplyMessage -Message $desiredMessage)) { + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentMessage + if ($normalized -eq (Format-IdleExchangeOnlineAutoReplyMessage -Message $desiredMessage)) { # Messages are functionally equivalent } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index be0b0aa2..281b29a1 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -369,8 +369,8 @@ function New-IdleExchangeOnlineProvider { # Check internal message with normalization if ($Config.ContainsKey('InternalMessage')) { # Use normalization to handle server-side HTML canonicalization - $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage - $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] + $normalizedCurrent = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage + $normalizedDesired = Format-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] if ($normalizedCurrent -ne $normalizedDesired) { $changed = $true } @@ -379,8 +379,8 @@ function New-IdleExchangeOnlineProvider { # Check external message with normalization if ($Config.ContainsKey('ExternalMessage')) { # Use normalization to handle server-side HTML canonicalization - $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage - $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] + $normalizedCurrent = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage + $normalizedDesired = Format-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] if ($normalizedCurrent -ne $normalizedDesired) { $changed = $true } @@ -457,172 +457,172 @@ function New-IdleExchangeOnlineProvider { # Normalize current delegates (case-insensitive) $currentFullAccessUsers = @($currentPerms | - Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | - ForEach-Object { $_.User.ToLowerInvariant() }) - - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "FullAccess current state evaluated for '$mailboxSmtp'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; CurrentUsers = $currentFullAccessUsers } - ) - } - - foreach ($entry in $desiredFullAccess) { - $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentFullAccessUsers -contains $userLower + Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | + ForEach-Object { $_.User.ToLowerInvariant() }) + + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Evaluated', + "FullAccess current state evaluated for '$mailboxSmtp'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; CurrentUsers = $currentFullAccessUsers } + ) + } - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting FullAccess on '$mailboxSmtp' to '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Add' } - ) + foreach ($entry in $desiredFullAccess) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentFullAccessUsers -contains $userLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting FullAccess on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Add' } + ) + } + $this.Adapter.AddMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true } - $this.Adapter.AddMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking FullAccess on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking FullAccess on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) + } + $this.Adapter.RemoveMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true } - $this.Adapter.RemoveMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true } } - } - # --- SendAs --- - $desiredSendAs = @($Permissions | Where-Object { $_.Right -eq 'SendAs' }) - if ($desiredSendAs.Count -gt 0) { - $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) - - $currentSendAsTrustees = @($currentRecipientPerms | - Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | - ForEach-Object { $_.Trustee.ToLowerInvariant() }) - - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "SendAs current state evaluated for '$mailboxSmtp'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; CurrentUsers = $currentSendAsTrustees } - ) - } + # --- SendAs --- + $desiredSendAs = @($Permissions | Where-Object { $_.Right -eq 'SendAs' }) + if ($desiredSendAs.Count -gt 0) { + $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) - foreach ($entry in $desiredSendAs) { - $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentSendAsTrustees -contains $trusteeLower + $currentSendAsTrustees = @($currentRecipientPerms | + Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | + ForEach-Object { $_.Trustee.ToLowerInvariant() }) - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting SendAs on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'Provider.ExchangeOnline.Permissions.Evaluated', + "SendAs current state evaluated for '$mailboxSmtp'", 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Add' } + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; CurrentUsers = $currentSendAsTrustees } ) } - $this.Adapter.AddRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking SendAs on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) + + foreach ($entry in $desiredSendAs) { + $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentSendAsTrustees -contains $trusteeLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting SendAs on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Add' } + ) + } + $this.Adapter.AddRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking SendAs on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) + } + $this.Adapter.RemoveRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } } - $this.Adapter.RemoveRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true } - } - } - # --- SendOnBehalf --- - $desiredSendOnBehalf = @($Permissions | Where-Object { $_.Right -eq 'SendOnBehalf' }) - if ($desiredSendOnBehalf.Count -gt 0) { - $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) - $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) - - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "SendOnBehalf current state evaluated for '$mailboxSmtp'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; CurrentUsers = $currentDelegatesLower } - ) - } - - # Compute desired final list based on Present/Absent entries - $updatedDelegates = [System.Collections.Generic.List[string]]::new() - foreach ($d in $currentDelegates) { $updatedDelegates.Add($d) } - - $sobChanged = $false - foreach ($entry in $desiredSendOnBehalf) { - $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentDelegatesLower -contains $userLower + # --- SendOnBehalf --- + $desiredSendOnBehalf = @($Permissions | Where-Object { $_.Right -eq 'SendOnBehalf' }) + if ($desiredSendOnBehalf.Count -gt 0) { + $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) + $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting SendOnBehalf on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'Provider.ExchangeOnline.Permissions.Evaluated', + "SendOnBehalf current state evaluated for '$mailboxSmtp'", 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Add' } + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; CurrentUsers = $currentDelegatesLower } ) } - $updatedDelegates.Add([string]$entry.AssignedUser) - $sobChanged = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking SendOnBehalf on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) + + # Compute desired final list based on Present/Absent entries + $updatedDelegates = [System.Collections.Generic.List[string]]::new() + foreach ($d in $currentDelegates) { $updatedDelegates.Add($d) } + + $sobChanged = $false + foreach ($entry in $desiredSendOnBehalf) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentDelegatesLower -contains $userLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting SendOnBehalf on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Add' } + ) + } + $updatedDelegates.Add([string]$entry.AssignedUser) + $sobChanged = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking SendOnBehalf on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) + } + # Remove case-insensitively + $toRemove = $updatedDelegates | Where-Object { $_.ToLowerInvariant() -eq $userLower } + foreach ($r in @($toRemove)) { $updatedDelegates.Remove($r) | Out-Null } + $sobChanged = $true + } + } + + if ($sobChanged) { + $this.Adapter.SetMailboxSendOnBehalf($mailboxSmtp, [string[]]$updatedDelegates, $accessToken) + $changed = $true } - # Remove case-insensitively - $toRemove = $updatedDelegates | Where-Object { $_.ToLowerInvariant() -eq $userLower } - foreach ($r in @($toRemove)) { $updatedDelegates.Remove($r) | Out-Null } - $sobChanged = $true } - } - if ($sobChanged) { - $this.Adapter.SetMailboxSendOnBehalf($mailboxSmtp, [string[]]$updatedDelegates, $accessToken) - $changed = $true - } - } + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Result', + "EnsureMailboxPermissions completed for '$mailboxSmtp': Changed=$changed", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Changed = $changed } + ) + } - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Result', - "EnsureMailboxPermissions completed for '$mailboxSmtp': Changed=$changed", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Changed = $changed } - ) - } + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxPermissions' + IdentityKey = $mailboxSmtp + Changed = $changed + } + } -Force - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'EnsureMailboxPermissions' - IdentityKey = $mailboxSmtp - Changed = $changed + return $provider } - } -Force - - return $provider -} diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 5c934912..8152d17f 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -633,14 +633,14 @@ Describe 'ExchangeOnline provider - Unit tests' { } } - Context 'Normalize-IdleExchangeOnlineAutoReplyMessage' { + Context 'Format-IdleExchangeOnlineAutoReplyMessage' { BeforeAll { # Import the private normalization function for direct testing $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent - $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Normalize-IdleExchangeOnlineAutoReplyMessage.ps1' + $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Format-IdleExchangeOnlineAutoReplyMessage.ps1' if (-not (Test-Path -LiteralPath $normalizeFunctionPath -PathType Leaf)) { - throw "Normalize-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" + throw "Format-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" } # Dot-source the private function @@ -649,55 +649,55 @@ Describe 'ExchangeOnline provider - Unit tests' { It 'removes HTML wrappers' { $input = '

Test message

' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be '

Test message

' } It 'normalizes CRLF to LF' { $input = "Line 1`r`nLine 2`r`nLine 3" - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be "Line 1`nLine 2`nLine 3" } It 'trims leading and trailing whitespace' { $input = "

Test message

`n`n" - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be '

Test message

' } It 'normalizes excessive spaces conservatively' { $input = '

Test message here

' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input # 3+ spaces become 2 spaces (conservative normalization) $normalized | Should -Be '

Test message here

' } It 'handles empty string input' { - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message '' + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message '' $normalized | Should -Be '' } It 'handles null input' { - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $null + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $null $normalized | Should -Be '' } It 'removes DOCTYPE declarations' { $input = '

Test

' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be '

Test

' } It 'preserves intentional HTML formatting' { $input = '

This is important and contact us.

' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be '

This is important and contact us.

' } From 5c6e585eb7d4688b880c123b43927a6f202ade80 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:01:25 +0100 Subject: [PATCH 10/14] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 2374cca9..5c7b007d 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -118,7 +118,7 @@ function Invoke-IdlePlanObject { # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries if ($null -ne $planProviders) { $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or - ($planProviders.PSObject -and $planProviders.PSObject.Properties) + ($planProviders.PSObject -and $planProviders.PSObject.Properties) if ($isValidProvider) { $effectiveProviders = $planProviders } From 41d31e6915018bb171e26d2aca0b7cb4a59c3420 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:11:30 +0000 Subject: [PATCH 11/14] Fix EnsureMailboxPermissions indentation and normalize test terminology - Re-apply Invoke-Formatter to New-IdleExchangeOnlineProvider.ps1 to produce zero PSUseConsistentIndentation violations (fixes #2865282671) - Fix Invoke-IdlePlanObject.ps1 line 121 continuation indent to match formatter expectation (no extra 4 spaces on the -or continuation) - Update ExchangeOnlineProvider.Tests.ps1 BeforeAll: rename variable $normalizeFunctionPath -> $formatHelperPath to match file name, and clarify comment with consistent normalization/format terminology Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 2 +- .../Providers/ExchangeOnlineProvider.Tests.ps1 | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 5c7b007d..2374cca9 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -118,7 +118,7 @@ function Invoke-IdlePlanObject { # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries if ($null -ne $planProviders) { $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or - ($planProviders.PSObject -and $planProviders.PSObject.Properties) + ($planProviders.PSObject -and $planProviders.PSObject.Properties) if ($isValidProvider) { $effectiveProviders = $planProviders } diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 8152d17f..aff6119a 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -635,16 +635,17 @@ Describe 'ExchangeOnline provider - Unit tests' { Context 'Format-IdleExchangeOnlineAutoReplyMessage' { BeforeAll { - # Import the private normalization function for direct testing + # Import the private auto-reply message normalization helper for direct testing. + # Named Format-* (approved PS verb) but performs normalization for idempotency comparison. $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent - $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Format-IdleExchangeOnlineAutoReplyMessage.ps1' - - if (-not (Test-Path -LiteralPath $normalizeFunctionPath -PathType Leaf)) { - throw "Format-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" + $formatHelperPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Format-IdleExchangeOnlineAutoReplyMessage.ps1' + + if (-not (Test-Path -LiteralPath $formatHelperPath -PathType Leaf)) { + throw "Format-IdleExchangeOnlineAutoReplyMessage script not found at: $formatHelperPath" } - - # Dot-source the private function - . $normalizeFunctionPath + + # Dot-source the private helper + . $formatHelperPath } It 'removes HTML wrappers' { From 991b1a6be70eea2e84283503df569374c6180a2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:29:53 +0000 Subject: [PATCH 12/14] Initial plan From cb96549442d92d5be13c0e006584d887dfe71406 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:56:29 +0000 Subject: [PATCH 13/14] Fix PSSA indentation, add per-step JSON warnings, beautify PSSA tool output Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../ConvertTo-IdlePlanExportObject.ps1 | 17 ++ .../Public/New-IdleExchangeOnlineProvider.ps1 | 282 +++++++++--------- tests/Core/Export-IdlePlan.Tests.ps1 | 3 + .../plan-export/expected/plan-export.json | 3 +- tools/Invoke-IdleScriptAnalyzer.ps1 | 119 +++++++- 5 files changed, 273 insertions(+), 151 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index e524ddbe..a17419ea 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -276,6 +276,23 @@ function ConvertTo-IdlePlanExportObject { $stepMap.inputs = $redactedInputs $stepMap.expectedState = $redactedExpectedState + # Per-step planning warnings (e.g. unresolved precondition context paths). + $rawStepWarnings = Get-FirstPropertyValue -Object $step -Names @('Warnings', 'PlanningWarnings', 'StepWarnings') + $stepWarningList = @() + foreach ($sw in @($rawStepWarnings)) { + if ($null -eq $sw) { continue } + + $stepWarningMap = New-OrderedMap + $stepWarningMap.code = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Code', 'code')) + $stepWarningMap.type = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Type', 'type')) + $stepWarningMap.source = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Source', 'source')) + $stepWarningMap.paths = Get-FirstPropertyValue -Object $sw -Names @('Paths', 'paths') + $stepWarningMap.message = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Message', 'message')) + + $stepWarningList += $stepWarningMap + } + $stepMap.warnings = $stepWarningList + $stepList += $stepMap } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index 281b29a1..2a6fb3cf 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -456,173 +456,171 @@ function New-IdleExchangeOnlineProvider { $currentPerms = $this.Adapter.GetMailboxPermissions($mailboxSmtp, $accessToken) # Normalize current delegates (case-insensitive) - $currentFullAccessUsers = @($currentPerms | - Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | - ForEach-Object { $_.User.ToLowerInvariant() }) - - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "FullAccess current state evaluated for '$mailboxSmtp'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; CurrentUsers = $currentFullAccessUsers } - ) - } + $filteredFullAccessPerms = $currentPerms | Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } + $currentFullAccessUsers = @($filteredFullAccessPerms | ForEach-Object { $_.User.ToLowerInvariant() }) + + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Evaluated', + "FullAccess current state evaluated for '$mailboxSmtp'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; CurrentUsers = $currentFullAccessUsers } + ) + } + + foreach ($entry in $desiredFullAccess) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentFullAccessUsers -contains $userLower - foreach ($entry in $desiredFullAccess) { - $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentFullAccessUsers -contains $userLower - - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting FullAccess on '$mailboxSmtp' to '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Add' } - ) - } - $this.Adapter.AddMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting FullAccess on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Add' } + ) } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking FullAccess on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) - } - $this.Adapter.RemoveMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true + $this.Adapter.AddMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking FullAccess on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) } + $this.Adapter.RemoveMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true } } + } - # --- SendAs --- - $desiredSendAs = @($Permissions | Where-Object { $_.Right -eq 'SendAs' }) - if ($desiredSendAs.Count -gt 0) { - $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) + # --- SendAs --- + $desiredSendAs = @($Permissions | Where-Object { $_.Right -eq 'SendAs' }) + if ($desiredSendAs.Count -gt 0) { + $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) + + $filteredSendAsPerms = $currentRecipientPerms | Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } + $currentSendAsTrustees = @($filteredSendAsPerms | ForEach-Object { $_.Trustee.ToLowerInvariant() }) + + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Evaluated', + "SendAs current state evaluated for '$mailboxSmtp'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; CurrentUsers = $currentSendAsTrustees } + ) + } - $currentSendAsTrustees = @($currentRecipientPerms | - Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | - ForEach-Object { $_.Trustee.ToLowerInvariant() }) + foreach ($entry in $desiredSendAs) { + $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentSendAsTrustees -contains $trusteeLower + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "SendAs current state evaluated for '$mailboxSmtp'", + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting SendAs on '$mailboxSmtp' to '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; CurrentUsers = $currentSendAsTrustees } + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Add' } ) } - - foreach ($entry in $desiredSendAs) { - $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentSendAsTrustees -contains $trusteeLower - - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting SendAs on '$mailboxSmtp' to '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Add' } - ) - } - $this.Adapter.AddRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking SendAs on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) - } - $this.Adapter.RemoveRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true - } + $this.Adapter.AddRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking SendAs on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) } + $this.Adapter.RemoveRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true } + } + } - # --- SendOnBehalf --- - $desiredSendOnBehalf = @($Permissions | Where-Object { $_.Right -eq 'SendOnBehalf' }) - if ($desiredSendOnBehalf.Count -gt 0) { - $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) - $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) + # --- SendOnBehalf --- + $desiredSendOnBehalf = @($Permissions | Where-Object { $_.Right -eq 'SendOnBehalf' }) + if ($desiredSendOnBehalf.Count -gt 0) { + $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) + $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) + + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Evaluated', + "SendOnBehalf current state evaluated for '$mailboxSmtp'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; CurrentUsers = $currentDelegatesLower } + ) + } + + # Compute desired final list based on Present/Absent entries + $updatedDelegates = [System.Collections.Generic.List[string]]::new() + foreach ($d in $currentDelegates) { $updatedDelegates.Add($d) } + + $sobChanged = $false + foreach ($entry in $desiredSendOnBehalf) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentDelegatesLower -contains $userLower + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "SendOnBehalf current state evaluated for '$mailboxSmtp'", + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting SendOnBehalf on '$mailboxSmtp' to '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; CurrentUsers = $currentDelegatesLower } + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Add' } ) } - - # Compute desired final list based on Present/Absent entries - $updatedDelegates = [System.Collections.Generic.List[string]]::new() - foreach ($d in $currentDelegates) { $updatedDelegates.Add($d) } - - $sobChanged = $false - foreach ($entry in $desiredSendOnBehalf) { - $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentDelegatesLower -contains $userLower - - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting SendOnBehalf on '$mailboxSmtp' to '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Add' } - ) - } - $updatedDelegates.Add([string]$entry.AssignedUser) - $sobChanged = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking SendOnBehalf on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) - } - # Remove case-insensitively - $toRemove = $updatedDelegates | Where-Object { $_.ToLowerInvariant() -eq $userLower } - foreach ($r in @($toRemove)) { $updatedDelegates.Remove($r) | Out-Null } - $sobChanged = $true - } - } - - if ($sobChanged) { - $this.Adapter.SetMailboxSendOnBehalf($mailboxSmtp, [string[]]$updatedDelegates, $accessToken) - $changed = $true + $updatedDelegates.Add([string]$entry.AssignedUser) + $sobChanged = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking SendOnBehalf on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) } + # Remove case-insensitively + $toRemove = $updatedDelegates | Where-Object { $_.ToLowerInvariant() -eq $userLower } + foreach ($r in @($toRemove)) { $updatedDelegates.Remove($r) | Out-Null } + $sobChanged = $true } + } - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Result', - "EnsureMailboxPermissions completed for '$mailboxSmtp': Changed=$changed", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Changed = $changed } - ) - } + if ($sobChanged) { + $this.Adapter.SetMailboxSendOnBehalf($mailboxSmtp, [string[]]$updatedDelegates, $accessToken) + $changed = $true + } + } - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'EnsureMailboxPermissions' - IdentityKey = $mailboxSmtp - Changed = $changed - } - } -Force + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Result', + "EnsureMailboxPermissions completed for '$mailboxSmtp': Changed=$changed", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Changed = $changed } + ) + } - return $provider + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxPermissions' + IdentityKey = $mailboxSmtp + Changed = $changed } + } -Force + + return $provider +} diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index cc38bebc..00ff40cd 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -121,6 +121,9 @@ Describe 'Export-IdlePlan' { @($json.plan.warnings).Count | Should -BeGreaterThan 0 $json.plan.warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' $json.plan.warnings[0].step | Should -Be 'Check Context' + + @($json.plan.steps[0].warnings).Count | Should -BeGreaterThan 0 + $json.plan.steps[0].warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' } } Context 'Contract invariants' { diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index adbf1042..8c6a5095 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -33,7 +33,8 @@ "inputs": { "mailboxType": "User" }, - "expectedState": null + "expectedState": null, + "warnings": [] } ], "warnings": [] diff --git a/tools/Invoke-IdleScriptAnalyzer.ps1 b/tools/Invoke-IdleScriptAnalyzer.ps1 index b066495f..bf6ec6b1 100644 --- a/tools/Invoke-IdleScriptAnalyzer.ps1 +++ b/tools/Invoke-IdleScriptAnalyzer.ps1 @@ -179,13 +179,112 @@ function Initialize-Module { throw "Module '$Name' ($RequiredVersion) is required, but Install-Module is not available. Install the module manually and retry." } - Write-Host "Installing module '$Name' ($RequiredVersion) in CurrentUser scope..." + Write-Host " Installing module '$Name' ($RequiredVersion) in CurrentUser scope..." -ForegroundColor DarkGray Install-Module -Name $Name -Scope CurrentUser -Force -RequiredVersion $RequiredVersion -AllowClobber | Out-Null } Import-Module -Name $Name -RequiredVersion $RequiredVersion -Force } +function Write-PssaFinding { + <# + .SYNOPSIS + Writes a single PSScriptAnalyzer finding to the host with color coding. + + .DESCRIPTION + Errors are written in red, warnings in yellow. Suppresses raw diagnostic + object noise and formats the output for human readability. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $Finding + ) + + $severity = [string]$Finding.Severity + $color = if ($severity -eq 'Error') { 'Red' } else { 'Yellow' } + $icon = if ($severity -eq 'Error') { [char]0x2717 } else { [char]0x26A0 } + $label = if ($severity -eq 'Error') { 'Error ' } else { 'Warning' } + $scriptName = [System.IO.Path]::GetFileName([string]$Finding.ScriptPath) + + Write-Host " $icon [$label] $($Finding.RuleName)" -ForegroundColor $color + Write-Host " File : $scriptName (line $($Finding.Line), col $($Finding.Column))" -ForegroundColor DarkGray + Write-Host " Msg : $($Finding.Message)" -ForegroundColor DarkGray + Write-Host '' +} + +function Write-PssaHeader { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [version] $Version, + + [Parameter(Mandatory)] + [string] $SettingsPath, + + [Parameter(Mandatory)] + [string[]] $AnalyzedPaths + ) + + $separator = '-' * 64 + Write-Host $separator -ForegroundColor DarkCyan + Write-Host " PSScriptAnalyzer $Version" -ForegroundColor Cyan + Write-Host " Settings : $(Split-Path -Leaf $SettingsPath)" -ForegroundColor DarkCyan + Write-Host ' Paths :' -ForegroundColor DarkCyan + foreach ($p in $AnalyzedPaths) { + Write-Host " > $p" -ForegroundColor DarkGray + } + Write-Host $separator -ForegroundColor DarkCyan + Write-Host '' +} + +function Write-PssaSummary { + <# + .SYNOPSIS + Writes a color-coded summary of PSScriptAnalyzer results. + + .DESCRIPTION + - Green : no findings at all + - Yellow: only warnings present (local run passes, CI may flag if FailOnSeverity=Warning) + - Red : errors present (blocking findings) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]] $Findings, + + [Parameter(Mandatory)] + [string] $FailOnSeverity + ) + + $errorCount = @($Findings | Where-Object { $_.Severity -eq 'Error' }).Count + $warnCount = @($Findings | Where-Object { $_.Severity -eq 'Warning' }).Count + $separator = '-' * 64 + + Write-Host $separator -ForegroundColor DarkCyan + + if ($errorCount -eq 0 -and $warnCount -eq 0) { + Write-Host " $([char]0x2713) No findings — all checks passed." -ForegroundColor Green + } + else { + Write-Host " Findings : $($errorCount + $warnCount) total | $errorCount error(s) | $warnCount warning(s)" -ForegroundColor DarkGray + + if ($errorCount -gt 0) { + Write-Host " $([char]0x2717) $errorCount error(s) found — this run will FAIL." -ForegroundColor Red + } + + if ($warnCount -gt 0 -and $FailOnSeverity -eq 'Error') { + Write-Host " $([char]0x26A0) $warnCount warning(s) found — these pass locally but CI will flag them if FailOnSeverity=Warning." -ForegroundColor Yellow + } + elseif ($warnCount -gt 0) { + Write-Host " $([char]0x26A0) $warnCount warning(s) found — this run will FAIL." -ForegroundColor Yellow + } + } + + Write-Host $separator -ForegroundColor DarkCyan + Write-Host '' +} + function Write-JsonFile { <# .SYNOPSIS @@ -250,15 +349,18 @@ Initialize-Module -Name 'PSScriptAnalyzer' -RequiredVersion $PSScriptAnalyzerVer # Run analysis using the repo settings file. # We rely on the settings file for rule selection and severities. -Write-Host "Running PSScriptAnalyzer ($PSScriptAnalyzerVersion) using settings: $resolvedSettingsPath" -Write-Host "Analyzing paths:" -$resolvedPaths | ForEach-Object { Write-Host " - $_" } +Write-PssaHeader -Version $PSScriptAnalyzerVersion -SettingsPath $resolvedSettingsPath -AnalyzedPaths $resolvedPaths $findings = @() foreach ($path in $resolvedPaths) { $findings += Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $resolvedSettingsPath } +# Display each finding with color coding (errors in red, warnings in yellow). +foreach ($f in ($findings | Sort-Object ScriptName, Line, Column, RuleName)) { + Write-PssaFinding -Finding $f +} + # Create a stable, small JSON payload (DiagnosticRecord contains complex members). $summary = @( foreach ($f in ($findings | Sort-Object ScriptName, Line, Column, RuleName)) { @@ -274,7 +376,7 @@ $summary = @( ) if ($CI -and $resolvedJsonOutputPath) { - Write-Host "Writing PSScriptAnalyzer JSON results: $resolvedJsonOutputPath" + Write-Host " Writing JSON results : $resolvedJsonOutputPath" -ForegroundColor DarkGray Write-JsonFile -Path $resolvedJsonOutputPath -Object $summary } @@ -288,10 +390,13 @@ if ($emitSarif -and $resolvedSarifOutputPath) { throw "ConvertToSARIF module is installed, but 'ConvertTo-SARIF' cmdlet was not found." } - Write-Host "Writing SARIF results: $resolvedSarifOutputPath" + Write-Host " Writing SARIF results : $resolvedSarifOutputPath" -ForegroundColor DarkGray $findings | & $convertCommand -FilePath $resolvedSarifOutputPath } +# Display color-coded summary (green/yellow/red) with CI hint for warnings. +Write-PssaSummary -Findings $findings -FailOnSeverity $FailOnSeverity + # Determine whether we should fail this run. $failSeverities = @($FailOnSeverity) if ($FailOnSeverity -eq 'Warning') { @@ -308,5 +413,3 @@ if ($blockingFindings) { $message = "PSScriptAnalyzer found blocking issues (FailOnSeverity: $FailOnSeverity). Errors: $errorCount, Warnings: $warningCount." throw $message } - -Write-Host "PSScriptAnalyzer completed with no blocking findings (FailOnSeverity: $FailOnSeverity)." From 5febd4b224563c165eed1a3f720b74d7e91de230 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:16:07 +0000 Subject: [PATCH 14/14] Fix unreliable @($null).Count step warnings assertion in export test Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/Core/Export-IdlePlan.Tests.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index 00ff40cd..bea1602f 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -122,7 +122,8 @@ Describe 'Export-IdlePlan' { $json.plan.warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' $json.plan.warnings[0].step | Should -Be 'Check Context' - @($json.plan.steps[0].warnings).Count | Should -BeGreaterThan 0 + $json.plan.steps[0].warnings | Should -Not -BeNullOrEmpty + ($json.plan.steps[0].warnings | Measure-Object).Count | Should -BeGreaterThan 0 $json.plan.steps[0].warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' } }