From 487c8c03e880befe67f472f8da63c552e692980c 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:04 +0000 Subject: [PATCH 1/7] Initial plan From cd4c3148da6a25cd94aca227b7ab8d2ae5206456 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:40:16 +0000 Subject: [PATCH 2/7] EXO provider: prerequisites cmdlet check, Get-EXOMailbox migration, caching, and docs update Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../providers/provider-exchangeonline.md | 229 ++++++++++++++---- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 4 +- .../Test-IdleExchangeOnlinePrerequisites.ps1 | 30 ++- .../Public/New-IdleExchangeOnlineProvider.ps1 | 17 +- .../ExchangeOnlineProvider.Tests.ps1 | 120 +++++++++ 5 files changed, 338 insertions(+), 62 deletions(-) diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 0fb58b0a..5f3e76a1 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -10,37 +10,44 @@ import ExoLeaverMailboxOffboarding from '@site/../examples/workflows/templates/e ## Summary -- **Module:** `IdLE.Provider.ExchangeOnline` -- **What it’s for:** Exchange Online mailbox configuration (type conversion, Out of Office, delegate permissions, mailbox info) -- **Targets:** Exchange Online via `ExchangeOnlineManagement` v3+ cmdlets (PowerShell 7+ compatible) -- **Identity keys:** UPN (recommended), SMTP address, mailbox identifiers (provider-specific) +| Item | Value | +| --- | --- | +| **Provider name** | `ExchangeOnlineProvider` | +| **Module** | `IdLE.Provider.ExchangeOnline` | +| **Provider role** | Messaging | +| **Targets** | Exchange Online via `ExchangeOnlineManagement` v3+ (PowerShell 7+) | +| **Status** | Built-in | +| **PowerShell** | PowerShell 7+ | -## When to use +--- + +## When to use this provider -Use this provider when your workflows need to manage **mailbox settings** in Exchange Online, for example: +### Use cases -- read mailbox info (type, primary SMTP, identifiers) -- apply a safe baseline at onboarding (verify mailbox + ensure expected type) -- convert mailbox type (e.g. user → shared for leavers) -- set Out of Office messages (internal/external) and audience -- converge delegate permissions (FullAccess, SendAs, SendOnBehalf) +- Read mailbox details (type, primary SMTP address, identifiers) +- Apply a safe baseline at onboarding (verify mailbox exists, ensure expected type) +- Convert mailbox type (e.g. user → shared for leavers) +- Set Out of Office messages (internal/external) and audience +- Converge delegate permissions (FullAccess, SendAs, SendOnBehalf) -Non-goals: +### Out of scope -- establishing the Exchange Online connection (host/runtime responsibility) -- managing identity objects (use AD / Entra ID providers for accounts) +- Establishing the Exchange Online session (host/runtime responsibility — see [Authentication](#authentication)) +- Creating or deleting mailboxes (use Entra ID / AD providers for account lifecycle) +- Managing identity objects or directory attributes (use AD / Entra ID providers) + +--- ## Getting started ### Requirements -- `ExchangeOnlineManagement` v3.0+ module available on the execution host (supports PowerShell 7+) -- A host/runtime that establishes an Exchange Online session (delegated or app-only) -- Permissions for the mailbox operations you intend to run (conversion, OOO, permissions, etc.) +- **Module:** `ExchangeOnlineManagement` v3.0+ installed on the execution host +- **Session:** An Exchange Online session must be established **before** IdLE runs (call `Connect-ExchangeOnline` in your host/runtime) +- **Permissions:** The session identity must have rights for the mailbox operations you intend to run -> **PowerShell 7+ compatibility:** `ExchangeOnlineManagement` v3.0.0 and later support PowerShell 7+ on -> Windows, macOS, and Linux via REST-based cmdlets. The “Windows only” limitation in earlier versions -> applied to certificate-based app-only auth; the module itself runs cross-platform from v3.0 onwards. +> **PowerShell 7+ compatibility:** `ExchangeOnlineManagement` v3.0+ supports PowerShell 7+ on Windows, macOS, and Linux via REST-based cmdlets. ### Install (PowerShell Gallery) @@ -48,45 +55,145 @@ Non-goals: Install-Module IdLE.Provider.ExchangeOnline -Scope CurrentUser ``` -### Import +### Import & basic check ```powershell Import-Module IdLE.Provider.ExchangeOnline + +# Create provider instance +$provider = New-IdleExchangeOnlineProvider ``` -## Quickstart +The provider runs a one-time prerequisites check at construction and emits `Write-Warning` if the Exchange Online session is not established. See [Troubleshooting](#troubleshooting) if this fails. -Create provider and register it (example convention): +--- + +## Quickstart (minimal runnable) ```powershell +# 1) Establish Exchange Online session (host responsibility — outside IdLE) +Connect-ExchangeOnline -UserPrincipalName admin@contoso.com + +# 2) Provider instance +$provider = New-IdleExchangeOnlineProvider + +# 3) Provider map (alias used in workflow files) $providers = @{ - ExchangeOnline = New-IdleExchangeOnlineProvider + ExchangeOnline = $provider } + +# 4) Plan + execute +$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers $providers +$result = Invoke-IdlePlan -Plan $plan -Providers $providers ``` +--- + ## Authentication -This provider does **not** authenticate by itself. +This provider does **not** authenticate by itself. Your host/runtime must establish the Exchange Online session before IdLE runs. + +- **Auth session name:** `ExchangeOnline` +- **Auth session options:** `@{ Role = 'Admin' }` (optional routing key) + +Workflow steps reference the session via: + +```powershell +With = @{ + AuthSessionName = 'ExchangeOnline' + AuthSessionOptions = @{ Role = 'Admin' } +} +``` + +### Token requirements (delegated access) -Your host/runtime must establish the Exchange Online session and (optionally) route it via the AuthSessionBroker. -Mailbox steps typically reference that session via: +When using delegated (user) authentication, mint the token for the Exchange Online resource: -- `AuthSessionName = 'ExchangeOnline'` -- `AuthSessionOptions = @{ Role = 'Admin' }` (optional routing key) +```powershell +# Interactive (delegated) — requires user interaction at a browser prompt +$token = Get-MsalToken ` + -ClientId '' ` + -TenantId '' ` + -Scopes 'https://outlook.office365.com/.default' ` + -DeviceCode + +Connect-ExchangeOnline -AccessToken $token.AccessToken -UserPrincipalName admin@contoso.com +``` -> Keep credentials/secrets **out of workflow files**. Resolve them in the host/runtime and provide them via the broker. +> **Note:** `-DeviceCode` is interactive and requires a user to authenticate via a browser. For **automated/unattended** scenarios, use app-only authentication with a certificate: +> +> ```powershell +> Connect-ExchangeOnline -CertificateThumbprint '' -AppId '' -Organization '.onmicrosoft.com' +> ``` -## Supported Step Types +The token's `scp` claim must include at least one of: +- `https://outlook.office365.com/Exchange.Manage` — full mailbox management (delegated) +- `Exchange.ManageAsApp` — app-only/service principal access + +> **Note:** The `.default` scope requests all permissions pre-consented on the app registration. Make sure the EXO delegated permissions are granted in your Entra ID app. + +:::warning +**Security** +- Do not pass secrets or access tokens in provider options or workflow files. +- Ensure credentials/tokens are not written to logs or events. +- The provider redacts token values from error messages automatically. +::: + +--- -Common step types using this provider include: +## Supported step types | Step Type | Capability Required | Description | | --- | --- | --- | | `IdLE.Step.Mailbox.GetInfo` | `IdLE.Mailbox.Info.Read` | Read mailbox details | -| `IdLE.Step.Mailbox.EnsureType` | `IdLE.Mailbox.Type.Ensure` | Convert mailbox type | -| `IdLE.Step.Mailbox.EnsureOutOfOffice` | `IdLE.Mailbox.OutOfOffice.Ensure` | Configure Out of Office | +| `IdLE.Step.Mailbox.EnsureType` | `IdLE.Mailbox.Type.Ensure` | Convert mailbox type (User/Shared/Room/Equipment) | +| `IdLE.Step.Mailbox.EnsureOutOfOffice` | `IdLE.Mailbox.OutOfOffice.Ensure` | Configure Out of Office (enabled/disabled/scheduled) | | `IdLE.Step.Mailbox.EnsurePermissions` | `IdLE.Mailbox.Permissions.Ensure` | Converge delegate permissions | +--- + +## Configuration + +### Provider creation + +- **Factory cmdlet:** `New-IdleExchangeOnlineProvider` + +**Parameters** + +- `-Adapter` — Internal use only (dependency injection for unit tests; do not set in production) + +### Provider alias usage + +```powershell +$providers = @{ + ExchangeOnline = New-IdleExchangeOnlineProvider +} +``` + +- **Recommended alias:** `ExchangeOnline` +- **Default alias expected by mailbox steps:** `ExchangeOnline` + +### Options reference + +This provider has no admin-facing option bag. Authentication is handled by your runtime via the AuthSessionBroker. + +--- + +## Operational behavior + +- **Idempotency:** Yes — all `Ensure*` methods check current state before making changes; unchanged state = `Changed = $false` +- **Consistency model:** Depends on Exchange Online replication (eventual consistency for permission changes) +- **Throttling / rate limits:** Subject to Exchange Online service limits; no built-in retry — delegate retry to the host +- **Retry behavior:** None built-in; host/runtime is responsible for retry on transient failures + +--- + +## Examples (canonical templates) + +{ExoJoinerMailboxBaseline} + +{ExoLeaverMailboxOffboarding} + ### Delegate permissions example ```powershell @@ -97,9 +204,9 @@ Common step types using this provider include: Provider = 'ExchangeOnline' IdentityKey = 'shared@contoso.com' Permissions = @( - @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } - @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } - @{ AssignedUser = 'leaver@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + @{ AssignedUser = 'leaver@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } ) } } @@ -108,29 +215,45 @@ Common step types using this provider include: Supported rights: `FullAccess`, `SendAs`, `SendOnBehalf`. Each entry requires `AssignedUser` (UPN/SMTP), `Right`, and `Ensure` (`Present` or `Absent`). -## Configuration +### More examples -No admin-facing provider options. +- `examples/workflows/templates/entraid-exo-leaver.psd1` — cross-provider leaver (Entra ID + Exchange Online) -## Examples (canonical templates) +--- -To keep provider documentation focused and consistent, this page embeds only the **canonical** Exchange Online templates: +## Troubleshooting -{ExoJoinerMailboxBaseline} +### Common problems -{ExoLeaverMailboxOffboarding} +- **`ExchangeOnlineManagement` module not installed** + → Install it: `Install-Module ExchangeOnlineManagement -Scope CurrentUser` -## Troubleshooting +- **Provider warns "No active Exchange Online session"** + → `Connect-ExchangeOnline` was not called before creating the provider. + Run `Connect-ExchangeOnline -UserPrincipalName admin@contoso.com` in your host/runtime first. + +- **`Get-EXOMailbox` not found / module not imported** + → Module is installed but not imported in this session: `Import-Module ExchangeOnlineManagement` + +- **`Set-Mailbox` not recognized (session proxy cmdlet missing)** + → No active Exchange Online session. Call `Connect-ExchangeOnline` before using the provider. + +- **`Unauthorized` / 401 when using `-AccessToken`** + → Token is not scoped for Exchange Online. Ensure you requested scopes for `https://outlook.office365.com/.default`, not `https://graph.microsoft.com/.default`. + Verify the token's `scp` claim contains `Exchange.Manage` or `Exchange.ManageAsApp`. + +- **Access denied when changing mailbox settings** + → The session identity must have the *Mail Recipients* management role (or Exchange Administrator) for mailbox changes, and *Recipient Management* for permission changes. -- **Module not found**: install `ExchangeOnlineManagement` on the execution host. -- **Not connected**: ensure the host establishes an Exchange Online session before IdLE runs. -- **Access denied**: the session identity must have permission to change mailbox settings. -- **OOO formatting issues**: use `MessageFormat = 'Html'` and validate HTML in a test mailbox first. -- **Permission changes not applied**: ensure the session identity has the *Mail Recipients* management role (or Exchange Administrator) required for `Add/Remove-MailboxPermission` and `Add/Remove-RecipientPermission`. +- **OOO formatting issues** + → Use `MessageFormat = 'Html'` and validate HTML in a test mailbox first. The provider normalizes HTML before comparing for idempotency. -## Scenarios (link-only) +- **Permission changes not visible immediately** + → Exchange Online replication is eventually consistent; allow a few minutes for changes to propagate. -Cross-provider orchestration examples are valuable, but should not be embedded in a single provider reference page. -Keep them as **link-only** and collect them on a central Examples/Scenarios page: +### What to collect for support -- `examples/workflows/templates/entraid-exo-leaver.psd1` +- IdLE version, `IdLE.Provider.ExchangeOnline` module version +- `ExchangeOnlineManagement` module version +- Redacted error message (the provider automatically redacts tokens from error output) +- Whether using delegated or app-only auth (without sharing credentials) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 963e88e0..39c0c21c 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -80,7 +80,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $mailbox = $this.InvokeSafely('Get-Mailbox', $params) + $mailbox = $this.InvokeSafely('Get-EXOMailbox', $params) if ($null -eq $mailbox) { return $null @@ -515,7 +515,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $mailbox = $this.InvokeSafely('Get-Mailbox', $params) + $mailbox = $this.InvokeSafely('Get-EXOMailbox', $params) if ($null -eq $mailbox) { return @() diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 index 0d44fb8b..1465e964 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 @@ -6,8 +6,13 @@ function Test-IdleExchangeOnlinePrerequisites { Checks if the Exchange Online prerequisites are available. .DESCRIPTION - Validates that the ExchangeOnlineManagement PowerShell module is available. - This module is required for all Exchange Online provider operations. + Validates that the ExchangeOnlineManagement PowerShell module is available and that + a working Exchange Online session exists in the current runspace. + + Three checks are performed in order: + 1. Module availability — ExchangeOnlineManagement must be installed. + 2. Module import — Get-EXOMailbox must be discoverable (module imported in session). + 3. Session established — Set-Mailbox must be available (Connect-ExchangeOnline was called). This function does not throw and returns a structured result object that can be used by the provider to emit warnings or by provider methods @@ -43,6 +48,27 @@ function Test-IdleExchangeOnlinePrerequisites { $notes += 'The ExchangeOnlineManagement PowerShell module is required for all Exchange Online provider operations.' $notes += 'Install via: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser' } + else { + # Module is available — now verify key cmdlets are accessible. + # Get-EXOMailbox is a native module cmdlet (always present after Import-Module). + # Its absence means the module has not been imported into this session yet. + $exoMailboxCmd = Get-Command -Name 'Get-EXOMailbox' -ErrorAction SilentlyContinue + if ($null -eq $exoMailboxCmd) { + $missingRequired += 'Get-EXOMailbox' + $notes += "The ExchangeOnlineManagement module is installed but 'Get-EXOMailbox' is not available in this session." + $notes += 'Ensure the module is imported: Import-Module ExchangeOnlineManagement' + } + + # Set-Mailbox is a session proxy cmdlet — only available after Connect-ExchangeOnline. + # Its absence means no active Exchange Online session exists in this runspace. + $setMailboxCmd = Get-Command -Name 'Set-Mailbox' -ErrorAction SilentlyContinue + if ($null -eq $setMailboxCmd) { + $missingRequired += 'ExchangeOnlineSession' + $notes += "No active Exchange Online session detected ('Set-Mailbox' is not available)." + $notes += 'Establish a session before using the provider: Connect-ExchangeOnline -UserPrincipalName admin@contoso.com' + $notes += "For delegated access, acquire a token scoped to 'https://outlook.office365.com/.default' and pass it via -AccessToken." + } + } $isHealthy = ($missingRequired.Count -eq 0) diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index ba3bf011..f839db0c 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -75,8 +75,12 @@ function New-IdleExchangeOnlineProvider { [object] $Adapter ) - # Check prerequisites and emit warnings if required components are missing + # Check prerequisites once at construction and cache the result on the provider instance. + # This avoids repeated module/cmdlet probing on every operation (one-time per instance). + Write-Verbose "Provider.ExchangeOnline.Init.Start: Checking prerequisites (ProviderName=ExchangeOnlineProvider)" $prereqs = Test-IdleExchangeOnlinePrerequisites + Write-Verbose "Provider.ExchangeOnline.Prerequisites.ModuleImport: ExchangeOnlineManagement module available=$(-not ($prereqs.MissingRequired -contains 'ExchangeOnlineManagement'))" + Write-Verbose "Provider.ExchangeOnline.CommandAvailability: Get-EXOMailbox=$(-not ($prereqs.MissingRequired -contains 'Get-EXOMailbox')) ExchangeOnlineSession=$(-not ($prereqs.MissingRequired -contains 'ExchangeOnlineSession'))" if (-not $prereqs.IsHealthy) { foreach ($missing in $prereqs.MissingRequired) { Write-Warning "ExchangeOnline provider prerequisite check: Required component '$missing' is not available." @@ -85,6 +89,7 @@ function New-IdleExchangeOnlineProvider { Write-Warning "ExchangeOnline provider prerequisite check: $note" } } + Write-Verbose "Provider.ExchangeOnline.Init.End: IsHealthy=$($prereqs.IsHealthy)" if ($null -eq $Adapter) { $Adapter = New-IdleExchangeOnlineAdapter @@ -103,7 +108,8 @@ function New-IdleExchangeOnlineProvider { $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ExchangeOnlineAdapter') if ($isRealAdapter) { - $prereqCheck = Test-IdleExchangeOnlinePrerequisites + # Use the prerequisites result cached at construction time (one-time per provider instance) + $prereqCheck = $this._prereqResult if (-not $prereqCheck.IsHealthy) { $missingList = $prereqCheck.MissingRequired -join ', ' $errorMsg = "ExchangeOnline provider operation cannot proceed. Required prerequisite(s) missing: $missingList" @@ -164,9 +170,10 @@ function New-IdleExchangeOnlineProvider { } $provider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' - Name = 'ExchangeOnlineProvider' - Adapter = $Adapter + PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' + Name = 'ExchangeOnlineProvider' + Adapter = $Adapter + _prereqResult = $prereqs } $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -Force diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 45063a83..384a1b2b 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -818,3 +818,123 @@ Describe 'ExchangeOnline provider - Unit tests' { } } } + +Describe 'Test-IdleExchangeOnlinePrerequisites - Unit tests' { + BeforeAll { + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + $modulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\IdLE.Provider.ExchangeOnline.psm1' + if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + throw "ExchangeOnline provider module not found at: $modulePath" + } + Import-Module $modulePath -Force + } + + Context 'Module not installed' { + It 'returns IsHealthy = false and lists ExchangeOnlineManagement as missing' { + InModuleScope 'IdLE.Provider.ExchangeOnline' { + Mock Get-Module { $null } -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } + + $result = Test-IdleExchangeOnlinePrerequisites + + $result.IsHealthy | Should -Be $false + $result.MissingRequired | Should -Contain 'ExchangeOnlineManagement' + $result.Notes.Count | Should -BeGreaterThan 0 + } + } + + It 'includes install guidance in Notes' { + InModuleScope 'IdLE.Provider.ExchangeOnline' { + Mock Get-Module { $null } -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } + + $result = Test-IdleExchangeOnlinePrerequisites + + ($result.Notes -join ' ') | Should -Match 'Install-Module' + } + } + } + + Context 'Module installed but not imported (Get-EXOMailbox missing)' { + It 'returns IsHealthy = false and lists Get-EXOMailbox as missing' { + InModuleScope 'IdLE.Provider.ExchangeOnline' { + Mock Get-Module { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` + -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-EXOMailbox' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Set-Mailbox' } + + $result = Test-IdleExchangeOnlinePrerequisites + + $result.IsHealthy | Should -Be $false + $result.MissingRequired | Should -Contain 'Get-EXOMailbox' + ($result.Notes -join ' ') | Should -Match 'Import-Module' + } + } + } + + Context 'Module imported but no active session (Set-Mailbox missing)' { + It 'returns IsHealthy = false and lists ExchangeOnlineSession as missing' { + InModuleScope 'IdLE.Provider.ExchangeOnline' { + Mock Get-Module { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` + -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } + Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` + -ParameterFilter { $Name -eq 'Get-EXOMailbox' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Set-Mailbox' } + + $result = Test-IdleExchangeOnlinePrerequisites + + $result.IsHealthy | Should -Be $false + $result.MissingRequired | Should -Contain 'ExchangeOnlineSession' + ($result.Notes -join ' ') | Should -Match 'Connect-ExchangeOnline' + } + } + + It 'includes token scope guidance in Notes' { + InModuleScope 'IdLE.Provider.ExchangeOnline' { + Mock Get-Module { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` + -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } + Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` + -ParameterFilter { $Name -eq 'Get-EXOMailbox' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Set-Mailbox' } + + $result = Test-IdleExchangeOnlinePrerequisites + + ($result.Notes -join ' ') | Should -Match 'outlook\.office365\.com' + } + } + } + + Context 'All prerequisites met' { + It 'returns IsHealthy = true with empty MissingRequired' { + InModuleScope 'IdLE.Provider.ExchangeOnline' { + Mock Get-Module { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` + -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } + Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` + -ParameterFilter { $Name -eq 'Get-EXOMailbox' } + Mock Get-Command { [pscustomobject]@{ Name = 'Set-Mailbox' } } ` + -ParameterFilter { $Name -eq 'Set-Mailbox' } + + $result = Test-IdleExchangeOnlinePrerequisites + + $result.IsHealthy | Should -Be $true + $result.MissingRequired | Should -BeNullOrEmpty + } + } + + It 'returns a result with expected shape' { + InModuleScope 'IdLE.Provider.ExchangeOnline' { + Mock Get-Module { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` + -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } + Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` + -ParameterFilter { $Name -eq 'Get-EXOMailbox' } + Mock Get-Command { [pscustomobject]@{ Name = 'Set-Mailbox' } } ` + -ParameterFilter { $Name -eq 'Set-Mailbox' } + + $result = Test-IdleExchangeOnlinePrerequisites + + $result.PSObject.TypeNames | Should -Contain 'IdLE.PrerequisitesResult' + $result.ProviderName | Should -Be 'ExchangeOnlineProvider' + $result.CheckedAt | Should -BeOfType [datetime] + } + } + } +} From 5a0691c31058d543e96c1be513fa8b0ee756b44c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:48:09 +0000 Subject: [PATCH 3/7] Use Get-Mailbox (not Set-Mailbox) as active session indicator in prerequisites check Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../providers/provider-exchangeonline.md | 2 +- .../Test-IdleExchangeOnlinePrerequisites.ps1 | 10 +++++----- tests/Providers/ExchangeOnlineProvider.Tests.ps1 | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 5f3e76a1..5eae7b82 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -235,7 +235,7 @@ Each entry requires `AssignedUser` (UPN/SMTP), `Right`, and `Ensure` (`Present` - **`Get-EXOMailbox` not found / module not imported** → Module is installed but not imported in this session: `Import-Module ExchangeOnlineManagement` -- **`Set-Mailbox` not recognized (session proxy cmdlet missing)** +- **`Get-Mailbox` not recognized (session proxy cmdlet missing)** → No active Exchange Online session. Call `Connect-ExchangeOnline` before using the provider. - **`Unauthorized` / 401 when using `-AccessToken`** diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 index 1465e964..fd5dd7bb 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 @@ -12,7 +12,7 @@ function Test-IdleExchangeOnlinePrerequisites { Three checks are performed in order: 1. Module availability — ExchangeOnlineManagement must be installed. 2. Module import — Get-EXOMailbox must be discoverable (module imported in session). - 3. Session established — Set-Mailbox must be available (Connect-ExchangeOnline was called). + 3. Session established — Get-Mailbox must be available (Connect-ExchangeOnline was called). This function does not throw and returns a structured result object that can be used by the provider to emit warnings or by provider methods @@ -59,12 +59,12 @@ function Test-IdleExchangeOnlinePrerequisites { $notes += 'Ensure the module is imported: Import-Module ExchangeOnlineManagement' } - # Set-Mailbox is a session proxy cmdlet — only available after Connect-ExchangeOnline. + # Get-Mailbox is a session proxy cmdlet — only available after Connect-ExchangeOnline. # Its absence means no active Exchange Online session exists in this runspace. - $setMailboxCmd = Get-Command -Name 'Set-Mailbox' -ErrorAction SilentlyContinue - if ($null -eq $setMailboxCmd) { + $getMailboxCmd = Get-Command -Name 'Get-Mailbox' -ErrorAction SilentlyContinue + if ($null -eq $getMailboxCmd) { $missingRequired += 'ExchangeOnlineSession' - $notes += "No active Exchange Online session detected ('Set-Mailbox' is not available)." + $notes += "No active Exchange Online session detected ('Get-Mailbox' is not available)." $notes += 'Establish a session before using the provider: Connect-ExchangeOnline -UserPrincipalName admin@contoso.com' $notes += "For delegated access, acquire a token scoped to 'https://outlook.office365.com/.default' and pass it via -AccessToken." } diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 384a1b2b..e578b9d8 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -860,7 +860,7 @@ Describe 'Test-IdleExchangeOnlinePrerequisites - Unit tests' { Mock Get-Module { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-EXOMailbox' } - Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Set-Mailbox' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-Mailbox' } $result = Test-IdleExchangeOnlinePrerequisites @@ -871,14 +871,14 @@ Describe 'Test-IdleExchangeOnlinePrerequisites - Unit tests' { } } - Context 'Module imported but no active session (Set-Mailbox missing)' { + Context 'Module imported but no active session (Get-Mailbox missing)' { It 'returns IsHealthy = false and lists ExchangeOnlineSession as missing' { InModuleScope 'IdLE.Provider.ExchangeOnline' { Mock Get-Module { [pscustomobject]@{ Name = 'ExchangeOnlineManagement' } } ` -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` -ParameterFilter { $Name -eq 'Get-EXOMailbox' } - Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Set-Mailbox' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-Mailbox' } $result = Test-IdleExchangeOnlinePrerequisites @@ -894,7 +894,7 @@ Describe 'Test-IdleExchangeOnlinePrerequisites - Unit tests' { -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` -ParameterFilter { $Name -eq 'Get-EXOMailbox' } - Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Set-Mailbox' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-Mailbox' } $result = Test-IdleExchangeOnlinePrerequisites @@ -910,8 +910,8 @@ Describe 'Test-IdleExchangeOnlinePrerequisites - Unit tests' { -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` -ParameterFilter { $Name -eq 'Get-EXOMailbox' } - Mock Get-Command { [pscustomobject]@{ Name = 'Set-Mailbox' } } ` - -ParameterFilter { $Name -eq 'Set-Mailbox' } + Mock Get-Command { [pscustomobject]@{ Name = 'Get-Mailbox' } } ` + -ParameterFilter { $Name -eq 'Get-Mailbox' } $result = Test-IdleExchangeOnlinePrerequisites @@ -926,8 +926,8 @@ Describe 'Test-IdleExchangeOnlinePrerequisites - Unit tests' { -ParameterFilter { $Name -eq 'ExchangeOnlineManagement' } Mock Get-Command { [pscustomobject]@{ Name = 'Get-EXOMailbox' } } ` -ParameterFilter { $Name -eq 'Get-EXOMailbox' } - Mock Get-Command { [pscustomobject]@{ Name = 'Set-Mailbox' } } ` - -ParameterFilter { $Name -eq 'Set-Mailbox' } + Mock Get-Command { [pscustomobject]@{ Name = 'Get-Mailbox' } } ` + -ParameterFilter { $Name -eq 'Get-Mailbox' } $result = Test-IdleExchangeOnlinePrerequisites From b7a2531f034c9ffa4ac7ed0d38ed8e7ebbeb49c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:55:29 +0000 Subject: [PATCH 4/7] Revert adapter GetMailbox/GetMailboxSendOnBehalf back to Get-Mailbox (session presence guarantee) Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 39c0c21c..963e88e0 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -80,7 +80,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $mailbox = $this.InvokeSafely('Get-EXOMailbox', $params) + $mailbox = $this.InvokeSafely('Get-Mailbox', $params) if ($null -eq $mailbox) { return $null @@ -515,7 +515,7 @@ function New-IdleExchangeOnlineAdapter { ErrorAction = 'Stop' } - $mailbox = $this.InvokeSafely('Get-EXOMailbox', $params) + $mailbox = $this.InvokeSafely('Get-Mailbox', $params) if ($null -eq $mailbox) { return @() From f1fcd19c841457ef0a713848a15bc57fdf222da8 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:55:40 +0100 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/reference/providers/provider-exchangeonline.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 5eae7b82..56793b34 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -126,11 +126,13 @@ Connect-ExchangeOnline -AccessToken $token.AccessToken -UserPrincipalName admin@ > Connect-ExchangeOnline -CertificateThumbprint '' -AppId '' -Organization '.onmicrosoft.com' > ``` -The token's `scp` claim must include at least one of: +For **delegated** flows, the token's `scp` claim must include: - `https://outlook.office365.com/Exchange.Manage` — full mailbox management (delegated) + +For **app-only** flows, the token's `roles` claim must include: - `Exchange.ManageAsApp` — app-only/service principal access -> **Note:** The `.default` scope requests all permissions pre-consented on the app registration. Make sure the EXO delegated permissions are granted in your Entra ID app. +> **Note:** The `.default` scope requests all delegated permissions pre-consented on the app registration. For app-only flows, ensure the `Exchange.ManageAsApp` app role is granted to your Entra ID application. :::warning **Security** From c75e2bcf41b319be90d728de09241199a7bf944b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:59:22 +0000 Subject: [PATCH 6/7] Make prerequisites check lazy (re-check on each operation, not cached at construction) Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Public/New-IdleExchangeOnlineProvider.ps1 | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index f839db0c..9ac3faed 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -75,8 +75,9 @@ function New-IdleExchangeOnlineProvider { [object] $Adapter ) - # Check prerequisites once at construction and cache the result on the provider instance. - # This avoids repeated module/cmdlet probing on every operation (one-time per instance). + # Run prerequisites check at construction for early diagnostic output only. + # The actual gate check is deferred to the first real operation so the provider + # can recover if Connect-ExchangeOnline is called after the provider is created. Write-Verbose "Provider.ExchangeOnline.Init.Start: Checking prerequisites (ProviderName=ExchangeOnlineProvider)" $prereqs = Test-IdleExchangeOnlinePrerequisites Write-Verbose "Provider.ExchangeOnline.Prerequisites.ModuleImport: ExchangeOnlineManagement module available=$(-not ($prereqs.MissingRequired -contains 'ExchangeOnlineManagement'))" @@ -108,8 +109,9 @@ function New-IdleExchangeOnlineProvider { $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ExchangeOnlineAdapter') if ($isRealAdapter) { - # Use the prerequisites result cached at construction time (one-time per provider instance) - $prereqCheck = $this._prereqResult + # Re-check prerequisites on each operation so the provider can recover + # if Connect-ExchangeOnline is called after the provider was created. + $prereqCheck = Test-IdleExchangeOnlinePrerequisites if (-not $prereqCheck.IsHealthy) { $missingList = $prereqCheck.MissingRequired -join ', ' $errorMsg = "ExchangeOnline provider operation cannot proceed. Required prerequisite(s) missing: $missingList" @@ -170,10 +172,9 @@ function New-IdleExchangeOnlineProvider { } $provider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' - Name = 'ExchangeOnlineProvider' - Adapter = $Adapter - _prereqResult = $prereqs + PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' + Name = 'ExchangeOnlineProvider' + Adapter = $Adapter } $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -Force From 4f74dd66762680fa82a6f853ab1083d34a8d56b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:27:59 +0000 Subject: [PATCH 7/7] Fix MDX JSX parse error: move fenced code block out of blockquote in provider docs Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-exchangeonline.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 56793b34..89c09ede 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -120,11 +120,13 @@ $token = Get-MsalToken ` Connect-ExchangeOnline -AccessToken $token.AccessToken -UserPrincipalName admin@contoso.com ``` -> **Note:** `-DeviceCode` is interactive and requires a user to authenticate via a browser. For **automated/unattended** scenarios, use app-only authentication with a certificate: -> -> ```powershell -> Connect-ExchangeOnline -CertificateThumbprint '' -AppId '' -Organization '.onmicrosoft.com' -> ``` +> **Note:** `-DeviceCode` is interactive and requires a user to authenticate via a browser. For **automated/unattended** scenarios, use app-only authentication with a certificate. + +App-only authentication example: + +```powershell +Connect-ExchangeOnline -CertificateThumbprint '' -AppId '' -Organization '.onmicrosoft.com' +``` For **delegated** flows, the token's `scp` claim must include: - `https://outlook.office365.com/Exchange.Manage` — full mailbox management (delegated)