From 7ae79171572fc6b1f7ecd7146e3c02ea6ea06e0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:27:33 +0000 Subject: [PATCH 1/7] Initial plan From 690fdbecce61202ed6f8afa1dc0f014481d5d123 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:50:54 +0000 Subject: [PATCH 2/7] Fix Mailbox.EnsurePermissions: transient error retry, diagnostics events, EventSink injection Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 13 ++ .../Public/New-IdleExchangeOnlineProvider.ps1 | 88 ++++++++++ ...nvoke-IdleStepMailboxPermissionsEnsure.ps1 | 7 + .../ExchangeOnlineProvider.Tests.ps1 | 152 ++++++++++++++++++ tests/Providers/_testHelpers.Providers.ps1 | 45 ++++++ 5 files changed, 305 insertions(+) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 963e88e0..c4526239 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -37,6 +37,9 @@ function New-IdleExchangeOnlineAdapter { $bearerTokenPattern = 'Bearer\s+[^\s]+' $tokenAssignmentPattern = 'token[^\s]*\s*=\s*[^\s,;]+' + # Transient EXO error patterns: server-side 5xx errors and throttling (429) + $transientErrorPattern = 'server\s+side\s+error|throttl|too\s+many\s+request|service\s+unavailable|temporarily\s+unavailable|bad\s+gateway' + try { $result = & $CommandName @Parameters return $result @@ -44,14 +47,24 @@ function New-IdleExchangeOnlineAdapter { catch { # Build error message without exposing sensitive data $errorMessage = "Exchange Online command '$CommandName' failed" + $isTransient = $false if ($_.Exception.Message) { # Sanitize error message to avoid leaking tokens/secrets $sanitized = $_.Exception.Message -replace $bearerTokenPattern, 'Bearer ' $sanitized = $sanitized -replace $tokenAssignmentPattern, 'token=' $errorMessage += " | $sanitized" + + # Mark retryable server-side and throttling errors as transient so the + # plan executor's Invoke-IdleWithRetry can retry the enclosing step. + if ($_.Exception.Message -match $transientErrorPattern) { + $isTransient = $true + } } $ex = [System.Exception]::new($errorMessage, $_.Exception) + if ($isTransient) { + $ex.Data['Idle.IsTransient'] = $true + } throw $ex } } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index ba3bf011..13708cbb 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -167,6 +167,7 @@ function New-IdleExchangeOnlineProvider { PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' Name = 'ExchangeOnlineProvider' Adapter = $Adapter + EventSink = $null } $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -Force @@ -438,6 +439,9 @@ function New-IdleExchangeOnlineProvider { $changed = $false + # Helper: emit diagnostic event if EventSink is available + $hasEventSink = ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) + # --- FullAccess --- $desiredFullAccess = @($Permissions | Where-Object { $_.Right -eq 'FullAccess' }) if ($desiredFullAccess.Count -gt 0) { @@ -448,15 +452,40 @@ function New-IdleExchangeOnlineProvider { Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | ForEach-Object { $_.User.ToLowerInvariant() }) + if ($hasEventSink) { + $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 if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $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 } elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $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 } @@ -472,15 +501,40 @@ function New-IdleExchangeOnlineProvider { Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | ForEach-Object { $_.Trustee.ToLowerInvariant() }) + if ($hasEventSink) { + $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Evaluated', + "SendAs current state evaluated for '$mailboxSmtp'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; CurrentUsers = $currentSendAsTrustees } + ) + } + foreach ($entry in $desiredSendAs) { $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() $isPresent = $currentSendAsTrustees -contains $trusteeLower if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $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) { + $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 } @@ -493,6 +547,15 @@ function New-IdleExchangeOnlineProvider { $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) + if ($hasEventSink) { + $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) } @@ -503,10 +566,26 @@ function New-IdleExchangeOnlineProvider { $isPresent = $currentDelegatesLower -contains $userLower if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $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) { + $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 } @@ -520,6 +599,15 @@ function New-IdleExchangeOnlineProvider { } } + if ($hasEventSink) { + $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' diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 index 44f04c38..da119702 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 @@ -159,6 +159,13 @@ function Invoke-IdleStepMailboxPermissionsEnsure { $effectiveWith['AuthSessionName'] = $providerAlias } + # Inject EventSink into the provider so it can emit diagnostics events + $provider = $Context.Providers[$providerAlias] + if ($provider.PSObject.Properties.Name -contains 'EventSink' -and + $Context.PSObject.Properties.Name -contains 'EventSink') { + $provider.EventSink = $Context.EventSink + } + $result = Invoke-IdleProviderMethod ` -Context $Context ` -With $effectiveWith ` diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 45063a83..06837c1e 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -816,5 +816,157 @@ Describe 'ExchangeOnline provider - Unit tests' { { $provider.EnsureMailboxPermissions('nonexistent@contoso.com', $permissions, $null) } | Should -Throw "*Mailbox 'nonexistent@contoso.com' not found*" } + + It 'emits Evaluated and Result events when EventSink is set' { + Add-TestMailbox -PrimarySmtpAddress 'evt1@contoso.com' + + $capturedEvents = [System.Collections.Generic.List[hashtable]]::new() + $fakeEventSink = [pscustomobject]@{} + $fakeEventSink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { + param($Type, $Message, $StepName, $Data) + $script:capturedEvents.Add(@{ Type = $Type; Message = $Message; Data = $Data }) + } -Force + + $provider.EventSink = $fakeEventSink + $script:capturedEvents = $capturedEvents + + $permissions = @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('evt1@contoso.com', $permissions, $null) + + $provider.EventSink = $null + + $evalEvents = @($capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Evaluated' }) + $applyEvents = @($capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Applying' }) + $resultEvents = @($capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Result' }) + + $evalEvents.Count | Should -BeGreaterOrEqual 1 + $applyEvents.Count | Should -Be 1 + $resultEvents.Count | Should -Be 1 + $result.Changed | Should -Be $true + } + + It 'does not emit events when EventSink is null' { + Add-TestMailbox -PrimarySmtpAddress 'evt2@contoso.com' + + $provider.EventSink = $null + + $permissions = @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + + # Should not throw even with no EventSink + { $provider.EnsureMailboxPermissions('evt2@contoso.com', $permissions, $null) } | Should -Not -Throw + } + } + + Context 'InvokeSafely transient error marking' { + BeforeAll { + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + $adapterPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\New-IdleExchangeOnlineAdapter.ps1' + . $adapterPath + + # Dot-source provider helpers so EXO simulation functions are in scope for ScriptMethods + . (Join-Path -Path $PSScriptRoot -ChildPath '_testHelpers.Providers.ps1') + + # Load Test-IdleTransientError for recursive exception chain checking + $retryHelpersPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Core\Private\Invoke-IdleWithRetry.ps1' + . $retryHelpersPath + } + + It 'marks server-side EXO error as transient (detectable by retry engine)' { + $testAdapter = New-IdleExchangeOnlineAdapter + + $caught = $null + try { + $testAdapter.InvokeSafely('Invoke-IdleEXOSimulateServerSideError', @{}) + } + catch { + $caught = $_.Exception + } + + $caught | Should -Not -BeNullOrEmpty + # Use Test-IdleTransientError (same check as the plan executor's Invoke-IdleWithRetry) + Test-IdleTransientError -Exception $caught | Should -Be $true + } + + It 'marks throttling EXO error as transient (detectable by retry engine)' { + $testAdapter = New-IdleExchangeOnlineAdapter + + $caught = $null + try { + $testAdapter.InvokeSafely('Invoke-IdleEXOSimulateThrottleError', @{}) + } + catch { + $caught = $_.Exception + } + + $caught | Should -Not -BeNullOrEmpty + Test-IdleTransientError -Exception $caught | Should -Be $true + } + + It 'does not mark non-transient EXO error as transient' { + $testAdapter = New-IdleExchangeOnlineAdapter + + $caught = $null + try { + $testAdapter.InvokeSafely('Invoke-IdleEXOSimulatePermError', @{}) + } + catch { + $caught = $_.Exception + } + + $caught | Should -Not -BeNullOrEmpty + Test-IdleTransientError -Exception $caught | Should -Be $false + } + } + + Context 'Transient error propagation from EnsureMailboxPermissions' { + BeforeAll { + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + $retryHelpersPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Core\Private\Invoke-IdleWithRetry.ps1' + . $retryHelpersPath + } + + It 'propagates transient exception from Remove operation so plan executor can retry the step' { + Add-TestMailbox -PrimarySmtpAddress 'transient1@contoso.com' + $fakeAdapter.Store.FullAccess['transient1@contoso.com'] = @{ 'delegate1@contoso.com' = $true } + + # Simulate what the real InvokeSafely does when EXO returns a server-side error + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveMailboxPermission -Value { + param($MailboxIdentity, $User, $AccessToken) + $inner = [System.Exception]::new('A server side error has occurred.') + $wrapped = [System.Exception]::new("Exchange Online command 'Remove-MailboxPermission' failed | A server side error has occurred.", $inner) + $wrapped.Data['Idle.IsTransient'] = $true + throw $wrapped + } -Force + + $caught = $null + try { + $provider.EnsureMailboxPermissions('transient1@contoso.com', @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } + ), $null) + } + catch { + $caught = $_.Exception + } + + # Restore original RemoveMailboxPermission + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveMailboxPermission -Value { + param($MailboxIdentity, $User, $AccessToken) + $key = $MailboxIdentity.ToLowerInvariant() + if ($this.Store.FullAccess.ContainsKey($key)) { + $this.Store.FullAccess[$key].Remove($User.ToLowerInvariant()) + } + } -Force + + $caught | Should -Not -BeNullOrEmpty + # Use Test-IdleTransientError (same check as the plan executor's Invoke-IdleWithRetry) + Test-IdleTransientError -Exception $caught | Should -Be $true + } } } diff --git a/tests/Providers/_testHelpers.Providers.ps1 b/tests/Providers/_testHelpers.Providers.ps1 index c99077a9..b70dea29 100644 --- a/tests/Providers/_testHelpers.Providers.ps1 +++ b/tests/Providers/_testHelpers.Providers.ps1 @@ -28,3 +28,48 @@ function Invoke-IdleTestBearerTokenError { throw 'Authentication failed: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig' } + +function Invoke-IdleEXOSimulateServerSideError { + <# + .SYNOPSIS + Test helper: throws a server-side EXO error (transient pattern). + + .DESCRIPTION + Used by InvokeSafely unit tests to verify that server-side Exchange Online + errors are marked as transient so that the plan executor can retry the step. + #> + [CmdletBinding()] + param() + + throw [System.Exception]::new('A server side error has occurred because of which the operation could not be completed.') +} + +function Invoke-IdleEXOSimulateThrottleError { + <# + .SYNOPSIS + Test helper: throws a throttling EXO error (transient pattern). + + .DESCRIPTION + Used by InvokeSafely unit tests to verify that throttling Exchange Online + errors are marked as transient so that the plan executor can retry the step. + #> + [CmdletBinding()] + param() + + throw [System.Exception]::new('The request has been throttled due to too many requests.') +} + +function Invoke-IdleEXOSimulatePermError { + <# + .SYNOPSIS + Test helper: throws a non-transient permission EXO error. + + .DESCRIPTION + Used by InvokeSafely unit tests to verify that non-transient Exchange Online + errors are NOT marked as transient. + #> + [CmdletBinding()] + param() + + throw [System.Exception]::new("Access denied. The user does not have the required permission.") +} From b950ae5946ee40f620f77d5ee9a58d67387fb713 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:53:02 +0000 Subject: [PATCH 3/7] Address code review: fix regex typo, simplify event capture, use try/finally for cleanup Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 2 +- .../ExchangeOnlineProvider.Tests.ps1 | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index c4526239..52d04bba 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -38,7 +38,7 @@ function New-IdleExchangeOnlineAdapter { $tokenAssignmentPattern = 'token[^\s]*\s*=\s*[^\s,;]+' # Transient EXO error patterns: server-side 5xx errors and throttling (429) - $transientErrorPattern = 'server\s+side\s+error|throttl|too\s+many\s+request|service\s+unavailable|temporarily\s+unavailable|bad\s+gateway' + $transientErrorPattern = 'server\s+side\s+error|throttl|too\s+many\s+requests|service\s+unavailable|temporarily\s+unavailable|bad\s+gateway' try { $result = & $CommandName @Parameters diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 06837c1e..d352bca7 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -820,7 +820,7 @@ Describe 'ExchangeOnline provider - Unit tests' { It 'emits Evaluated and Result events when EventSink is set' { Add-TestMailbox -PrimarySmtpAddress 'evt1@contoso.com' - $capturedEvents = [System.Collections.Generic.List[hashtable]]::new() + $script:capturedEvents = [System.Collections.Generic.List[hashtable]]::new() $fakeEventSink = [pscustomobject]@{} $fakeEventSink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { param($Type, $Message, $StepName, $Data) @@ -828,7 +828,6 @@ Describe 'ExchangeOnline provider - Unit tests' { } -Force $provider.EventSink = $fakeEventSink - $script:capturedEvents = $capturedEvents $permissions = @( @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } @@ -838,9 +837,9 @@ Describe 'ExchangeOnline provider - Unit tests' { $provider.EventSink = $null - $evalEvents = @($capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Evaluated' }) - $applyEvents = @($capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Applying' }) - $resultEvents = @($capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Result' }) + $evalEvents = @($script:capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Evaluated' }) + $applyEvents = @($script:capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Applying' }) + $resultEvents = @($script:capturedEvents | Where-Object { $_.Type -eq 'Provider.ExchangeOnline.Permissions.Result' }) $evalEvents.Count | Should -BeGreaterOrEqual 1 $applyEvents.Count | Should -Be 1 @@ -954,15 +953,16 @@ Describe 'ExchangeOnline provider - Unit tests' { catch { $caught = $_.Exception } - - # Restore original RemoveMailboxPermission - $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveMailboxPermission -Value { - param($MailboxIdentity, $User, $AccessToken) - $key = $MailboxIdentity.ToLowerInvariant() - if ($this.Store.FullAccess.ContainsKey($key)) { - $this.Store.FullAccess[$key].Remove($User.ToLowerInvariant()) - } - } -Force + finally { + # Restore original RemoveMailboxPermission so other tests are not affected + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveMailboxPermission -Value { + param($MailboxIdentity, $User, $AccessToken) + $key = $MailboxIdentity.ToLowerInvariant() + if ($this.Store.FullAccess.ContainsKey($key)) { + $this.Store.FullAccess[$key].Remove($User.ToLowerInvariant()) + } + } -Force + } $caught | Should -Not -BeNullOrEmpty # Use Test-IdleTransientError (same check as the plan executor's Invoke-IdleWithRetry) From be535b8ee93220bea56c140bc03ac06c21bb0a41 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:02:40 +0100 Subject: [PATCH 4/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 2 +- .../ExchangeOnlineProvider.Tests.ps1 | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 52d04bba..8048a04e 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -38,7 +38,7 @@ function New-IdleExchangeOnlineAdapter { $tokenAssignmentPattern = 'token[^\s]*\s*=\s*[^\s,;]+' # Transient EXO error patterns: server-side 5xx errors and throttling (429) - $transientErrorPattern = 'server\s+side\s+error|throttl|too\s+many\s+requests|service\s+unavailable|temporarily\s+unavailable|bad\s+gateway' + $transientErrorPattern = 'server[\s-]+side[\s-]+error|throttl|too[\s-]+many[\s-]+requests|service[\s-]+unavailable|temporarily[\s-]+unavailable|bad[\s-]+gateway' try { $result = & $CommandName @Parameters diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index d352bca7..6d786a0d 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -968,5 +968,83 @@ Describe 'ExchangeOnline provider - Unit tests' { # Use Test-IdleTransientError (same check as the plan executor's Invoke-IdleWithRetry) Test-IdleTransientError -Exception $caught | Should -Be $true } + + It 'propagates transient exception from RemoveRecipientPermission (SendAs) so plan executor can retry the step' { + Add-TestMailbox -PrimarySmtpAddress 'transient2@contoso.com' + $fakeAdapter.Store.SendAs['transient2@contoso.com'] = @{ 'delegate1@contoso.com' = $true } + + # Simulate what the real InvokeSafely does when EXO returns a server-side error for Remove-RecipientPermission + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveRecipientPermission -Value { + param($MailboxIdentity, $Trustee, $AccessToken) + $inner = [System.Exception]::new('A server side error has occurred.') + $wrapped = [System.Exception]::new("Exchange Online command 'Remove-RecipientPermission' failed | A server side error has occurred.", $inner) + $wrapped.Data['Idle.IsTransient'] = $true + throw $wrapped + } -Force + + $caught = $null + try { + $provider.EnsureMailboxPermissions('transient2@contoso.com', @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'SendAs'; Ensure = 'Absent' } + ), $null) + } + catch { + $caught = $_.Exception + } + finally { + # Restore original RemoveRecipientPermission so other tests are not affected + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveRecipientPermission -Value { + param($MailboxIdentity, $Trustee, $AccessToken) + $key = $MailboxIdentity.ToLowerInvariant() + if ($this.Store.SendAs.ContainsKey($key)) { + $this.Store.SendAs[$key].Remove($Trustee.ToLowerInvariant()) + } + } -Force + } + + $caught | Should -Not -BeNullOrEmpty + Test-IdleTransientError -Exception $caught | Should -Be $true + } + + It 'propagates transient exception from SetMailboxSendOnBehalf (SendOnBehalf Absent) so plan executor can retry the step' { + Add-TestMailbox -PrimarySmtpAddress 'transient3@contoso.com' + # Seed existing SendOnBehalf delegates so EnsureMailboxPermissions will attempt to remove one + $fakeAdapter.Store.SendOnBehalf['transient3@contoso.com'] = @('delegate1@contoso.com', 'someoneelse@contoso.com') + + # Simulate what the real InvokeSafely does when EXO returns a server-side error for Set-Mailbox (SendOnBehalf) + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name SetMailboxSendOnBehalf -Value { + param($MailboxIdentity, $GrantSendOnBehalfTo, $AccessToken) + $inner = [System.Exception]::new('A server side error has occurred.') + $wrapped = [System.Exception]::new("Exchange Online command 'Set-Mailbox -GrantSendOnBehalfTo' failed | A server side error has occurred.", $inner) + $wrapped.Data['Idle.IsTransient'] = $true + throw $wrapped + } -Force + + $caught = $null + try { + $provider.EnsureMailboxPermissions('transient3@contoso.com', @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Absent' } + ), $null) + } + catch { + $caught = $_.Exception + } + finally { + # Restore original SetMailboxSendOnBehalf so other tests are not affected + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name SetMailboxSendOnBehalf -Value { + param($MailboxIdentity, $GrantSendOnBehalfTo, $AccessToken) + $key = $MailboxIdentity.ToLowerInvariant() + if ($null -eq $GrantSendOnBehalfTo -or $GrantSendOnBehalfTo.Count -eq 0) { + $this.Store.SendOnBehalf.Remove($key) | Out-Null + } + else { + $this.Store.SendOnBehalf[$key] = $GrantSendOnBehalfTo + } + } -Force + } + + $caught | Should -Not -BeNullOrEmpty + Test-IdleTransientError -Exception $caught | Should -Be $true + } } } From be7ee6a3b691f5b106cefd67601a4c3cc5b2322d Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:02:58 +0100 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 8048a04e..fb598514 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -56,7 +56,7 @@ function New-IdleExchangeOnlineAdapter { # Mark retryable server-side and throttling errors as transient so the # plan executor's Invoke-IdleWithRetry can retry the enclosing step. - if ($_.Exception.Message -match $transientErrorPattern) { + if ($_.Exception.Message -imatch $transientErrorPattern) { $isTransient = $true } } From 85547aae7cc348319ef0f4fb8db83b681fee3f22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:32:45 +0000 Subject: [PATCH 6/7] Fix Changed=False for Ensure=Present: suppress leaked InvokeSafely output in write adapter methods Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 16 ++++---- .../ExchangeOnlineProvider.Tests.ps1 | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index fb598514..71992eb0 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -159,10 +159,8 @@ function New-IdleExchangeOnlineAdapter { } } - $this.InvokeSafely('Set-Mailbox', $params) + $null = $this.InvokeSafely('Set-Mailbox', $params) } -Force - - # GetMailboxAutoReplyConfiguration: Get Out of Office settings $adapter | Add-Member -MemberType ScriptMethod -Name GetMailboxAutoReplyConfiguration -Value { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] param( @@ -268,7 +266,7 @@ function New-IdleExchangeOnlineAdapter { $params['ExternalAudience'] = $Config['ExternalAudience'] } - $this.InvokeSafely('Set-MailboxAutoReplyConfiguration', $params) + $null = $this.InvokeSafely('Set-MailboxAutoReplyConfiguration', $params) } -Force # GetMailboxPermissions: Get FullAccess permissions for a mailbox @@ -355,7 +353,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $this.InvokeSafely('Add-MailboxPermission', $params) + $null = $this.InvokeSafely('Add-MailboxPermission', $params) } -Force # RemoveMailboxPermission: Revoke FullAccess from a mailbox @@ -386,7 +384,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $this.InvokeSafely('Remove-MailboxPermission', $params) + $null = $this.InvokeSafely('Remove-MailboxPermission', $params) } -Force # GetRecipientPermissions: Get SendAs permissions for a mailbox @@ -472,7 +470,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $this.InvokeSafely('Add-RecipientPermission', $params) + $null = $this.InvokeSafely('Add-RecipientPermission', $params) } -Force # RemoveRecipientPermission: Revoke SendAs from a mailbox @@ -503,7 +501,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $this.InvokeSafely('Remove-RecipientPermission', $params) + $null = $this.InvokeSafely('Remove-RecipientPermission', $params) } -Force # GetMailboxSendOnBehalf: Get the GrantSendOnBehalfTo list for a mailbox @@ -575,7 +573,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $this.InvokeSafely('Set-Mailbox', $params) + $null = $this.InvokeSafely('Set-Mailbox', $params) } -Force return $adapter diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 6d786a0d..e6f1a79e 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -591,6 +591,46 @@ Describe 'ExchangeOnline provider - Unit tests' { { $adapter.TestErrorSanitization() } | Should -Throw -ExpectedMessage "*Bearer *" } + + It 'AddMailboxPermission does not leak return value to callers when InvokeSafely returns a non-null result' { + # Regression test: Add-MailboxPermission returns the created permission object in EXO. + # The adapter method must suppress this output with $null = $this.InvokeSafely(...) + # so it does not pollute EnsureMailboxPermissions's output pipeline and cause Changed=False. + $adapter = New-IdleExchangeOnlineAdapter + + $adapter | Add-Member -MemberType ScriptMethod -Name InvokeSafely -Value { + param($CommandName, $Parameters) + # Simulate Add-MailboxPermission returning a permission object (real EXO behavior) + return [pscustomobject]@{ Identity = 'mailbox@contoso.com'; User = 'user@contoso.com'; AccessRights = 'FullAccess' } + } -Force + + $output = $adapter.AddMailboxPermission('mailbox@contoso.com', 'user@contoso.com', $null) + $output | Should -BeNullOrEmpty + } + + It 'AddRecipientPermission does not leak return value to callers when InvokeSafely returns a non-null result' { + $adapter = New-IdleExchangeOnlineAdapter + + $adapter | Add-Member -MemberType ScriptMethod -Name InvokeSafely -Value { + param($CommandName, $Parameters) + return [pscustomobject]@{ Identity = 'mailbox@contoso.com'; Trustee = 'user@contoso.com'; AccessRights = 'SendAs' } + } -Force + + $output = $adapter.AddRecipientPermission('mailbox@contoso.com', 'user@contoso.com', $null) + $output | Should -BeNullOrEmpty + } + + It 'SetMailboxSendOnBehalf does not leak return value to callers when InvokeSafely returns a non-null result' { + $adapter = New-IdleExchangeOnlineAdapter + + $adapter | Add-Member -MemberType ScriptMethod -Name InvokeSafely -Value { + param($CommandName, $Parameters) + return [pscustomobject]@{ Identity = 'mailbox@contoso.com'; GrantSendOnBehalfTo = @('user@contoso.com') } + } -Force + + $output = $adapter.SetMailboxSendOnBehalf('mailbox@contoso.com', @('user@contoso.com'), $null) + $output | Should -BeNullOrEmpty + } } Context 'Normalize-IdleExchangeOnlineAutoReplyMessage' { From 784b2b74274c5c4063f796dd1f1c6bfe91049bf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:56:34 +0000 Subject: [PATCH 7/7] Suppress WriteEvent output in EnsureMailboxPermissions to prevent pipeline pollution Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Public/New-IdleExchangeOnlineProvider.ps1 | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index db980ab3..be0b0aa2 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -461,7 +461,7 @@ function New-IdleExchangeOnlineProvider { ForEach-Object { $_.User.ToLowerInvariant() }) if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Evaluated', "FullAccess current state evaluated for '$mailboxSmtp'", 'EnsureMailboxPermissions', @@ -475,7 +475,7 @@ function New-IdleExchangeOnlineProvider { if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Applying', "Granting FullAccess on '$mailboxSmtp' to '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', @@ -487,7 +487,7 @@ function New-IdleExchangeOnlineProvider { } elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Applying', "Revoking FullAccess on '$mailboxSmtp' from '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', @@ -510,7 +510,7 @@ function New-IdleExchangeOnlineProvider { ForEach-Object { $_.Trustee.ToLowerInvariant() }) if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Evaluated', "SendAs current state evaluated for '$mailboxSmtp'", 'EnsureMailboxPermissions', @@ -524,7 +524,7 @@ function New-IdleExchangeOnlineProvider { if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Applying', "Granting SendAs on '$mailboxSmtp' to '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', @@ -536,7 +536,7 @@ function New-IdleExchangeOnlineProvider { } elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Applying', "Revoking SendAs on '$mailboxSmtp' from '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', @@ -556,7 +556,7 @@ function New-IdleExchangeOnlineProvider { $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Evaluated', "SendOnBehalf current state evaluated for '$mailboxSmtp'", 'EnsureMailboxPermissions', @@ -575,7 +575,7 @@ function New-IdleExchangeOnlineProvider { if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Applying', "Granting SendOnBehalf on '$mailboxSmtp' to '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', @@ -587,7 +587,7 @@ function New-IdleExchangeOnlineProvider { } elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Applying', "Revoking SendOnBehalf on '$mailboxSmtp' from '$($entry.AssignedUser)'", 'EnsureMailboxPermissions', @@ -608,7 +608,7 @@ function New-IdleExchangeOnlineProvider { } if ($hasEventSink) { - $this.EventSink.WriteEvent( + $null = $this.EventSink.WriteEvent( 'Provider.ExchangeOnline.Permissions.Result', "EnsureMailboxPermissions completed for '$mailboxSmtp': Changed=$changed", 'EnsureMailboxPermissions',