diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 963e88e0..71992eb0 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-]+requests|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 -imatch $transientErrorPattern) { + $isTransient = $true + } } $ex = [System.Exception]::new($errorMessage, $_.Exception) + if ($isTransient) { + $ex.Data['Idle.IsTransient'] = $true + } throw $ex } } @@ -146,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( @@ -255,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 @@ -342,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 @@ -373,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 @@ -459,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 @@ -490,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 @@ -562,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/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index 9ac3faed..be0b0aa2 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -175,6 +175,7 @@ function New-IdleExchangeOnlineProvider { PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' Name = 'ExchangeOnlineProvider' Adapter = $Adapter + EventSink = $null } $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -Force @@ -446,6 +447,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) { @@ -456,15 +460,40 @@ function New-IdleExchangeOnlineProvider { 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 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 } 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 } @@ -480,15 +509,40 @@ function New-IdleExchangeOnlineProvider { 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 } + ) + } + 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 } @@ -501,6 +555,15 @@ function New-IdleExchangeOnlineProvider { $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) } @@ -511,10 +574,26 @@ function New-IdleExchangeOnlineProvider { $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 } @@ -528,6 +607,15 @@ function New-IdleExchangeOnlineProvider { } } + 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' 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 e578b9d8..5c934912 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' { @@ -816,6 +856,236 @@ 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' + + $script: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 + + $permissions = @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('evt1@contoso.com', $permissions, $null) + + $provider.EventSink = $null + + $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 + $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 + } + 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) + 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 + } } } 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.") +}