diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index df02f4a6..6cbd6627 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -114,6 +114,12 @@ Prefer explicit reference fields over implicit parsing: This makes configurations safe and statically validatable. +## Advanced Workflow Patterns + +(Content for advanced patterns will be added in future updates) + +This approach keeps workflows data-only while allowing rich message formatting in the host code. + ## Related - [Steps](steps.md) diff --git a/examples/workflows/live/complete-leaver-entraid-exo.psd1 b/examples/workflows/live/complete-leaver-entraid-exo.psd1 new file mode 100644 index 00000000..0e0fa2d4 --- /dev/null +++ b/examples/workflows/live/complete-leaver-entraid-exo.psd1 @@ -0,0 +1,78 @@ +@{ + Name = 'Complete Leaver - EntraID + ExchangeOnline Offboarding' + LifecycleEvent = 'Leaver' + Description = 'Complete offboarding workflow: disables EntraID account, converts mailbox to shared, and enables Out of Office.' + Steps = @( + @{ + Name = 'GetMailboxInfo' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + } + } + @{ + Name = 'ConvertToSharedMailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + MailboxType = 'Shared' + } + } + @{ + Name = 'EnableOutOfOffice' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + ExternalAudience = 'All' + } + } + } + @{ + Name = 'RevokeAllGroupMemberships' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + Desired = @() + } + } + @{ + Name = 'ClearManager' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + Name = 'Manager' + Value = $null + } + } + @{ + Name = 'DisableEntraIDAccount' + Type = 'IdLE.Step.DisableIdentity' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Complete offboarding finished: Mailbox converted to Shared, OOF enabled, EntraID account disabled.' + } + } + ) +} diff --git a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 new file mode 100644 index 00000000..713de5fc --- /dev/null +++ b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 @@ -0,0 +1,45 @@ +@{ + Name = 'ExchangeOnline Leaver - Mailbox Offboarding' + LifecycleEvent = 'Leaver' + Description = 'Converts mailbox to shared, enables Out of Office, and optionally delegates access for offboarding users.' + Steps = @( + @{ + Name = 'GetMailboxInfo' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + } + } + @{ + Name = 'ConvertToSharedMailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + MailboxType = 'Shared' + } + } + @{ + Name = 'EnableOutOfOffice' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + ExternalAudience = 'All' + } + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Mailbox offboarding completed.' + } + } + ) +} diff --git a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 new file mode 100644 index 00000000..19b8ef72 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 @@ -0,0 +1,22 @@ +@{ + RootModule = 'IdLE.Provider.ExchangeOnline.psm1' + ModuleVersion = '0.9.0' + GUID = 'e8f9a3b1-4c2d-4a5b-9f7e-3d2c1a9b8e7f' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Exchange Online mailbox provider implementation for IdLE (requires ExchangeOnlineManagement module).' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'New-IdleExchangeOnlineProvider' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Provider', 'ExchangeOnline', 'Mailbox') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + ContactEmail = '13959569+blindzero@users.noreply.github.com' + } + } +} diff --git a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 new file mode 100644 index 00000000..2e0d4945 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 @@ -0,0 +1,29 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' +if (Test-Path -Path $PrivatePath) { + + # Materialize first to avoid enumeration issues during import. + $privateScripts = @(Get-ChildItem -Path $PrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $privateScripts) { + . $script.FullName + } +} + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize first to avoid enumeration issues during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } +} + +# Export Public functions - explicit list for deterministic behavior +Export-ModuleMember -Function @( + 'New-IdleExchangeOnlineProvider' +) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 new file mode 100644 index 00000000..4973f0e9 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -0,0 +1,250 @@ +function New-IdleExchangeOnlineAdapter { + <# + .SYNOPSIS + Creates an internal adapter that wraps Exchange Online Management cmdlets. + + .DESCRIPTION + This adapter provides a testable boundary between the provider and Exchange Online cmdlets. + Unit tests can inject a fake adapter without requiring a real Exchange Online environment. + + The adapter wraps ExchangeOnlineManagement module cmdlets for maximum compatibility. + + .PARAMETER UseRestApi + (Reserved for future use) Switch to indicate use of Graph API REST calls instead of cmdlets. + #> + [CmdletBinding()] + param( + [Parameter()] + [switch] $UseRestApi + ) + + # Regex patterns for sanitizing error messages (captured by scriptblock closure) + $bearerTokenPattern = 'Bearer\s+[^\s]+' + $tokenAssignmentPattern = 'token[^\s]*\s*=\s*[^\s,;]+' + + $adapter = [pscustomobject]@{ + PSTypeName = 'IdLE.ExchangeOnlineAdapter' + UseRestApi = [bool]$UseRestApi + } + + # Helper to safely invoke cmdlets with error handling + $invokeSafely = { + param( + [Parameter(Mandatory)] + [string] $CommandName, + + [Parameter()] + [hashtable] $Parameters = @{} + ) + + try { + $result = & $CommandName @Parameters + return $result + } + catch { + # Build error message without exposing sensitive data + $errorMessage = "Exchange Online command '$CommandName' failed" + if ($_.Exception.Message) { + # Sanitize error message to avoid leaking tokens/secrets + $sanitized = $_.Exception.Message -replace $bearerTokenPattern, 'Bearer ' + $sanitized = $sanitized -replace $tokenAssignmentPattern, 'token=' + $errorMessage += " | $sanitized" + } + + $ex = [System.Exception]::new($errorMessage, $_.Exception) + throw $ex + } + } + + $adapter | Add-Member -MemberType ScriptMethod -Name InvokeSafely -Value $invokeSafely -Force + + # GetMailbox: Retrieve mailbox details by identity (UPN or SMTP address) + $adapter | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + try { + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + $mailbox = $this.InvokeSafely('Get-Mailbox', $params) + + if ($null -eq $mailbox) { + return $null + } + + # Normalize output to hashtable + return @{ + Identity = $mailbox.Identity + PrimarySmtpAddress = $mailbox.PrimarySmtpAddress + UserPrincipalName = $mailbox.UserPrincipalName + DisplayName = $mailbox.DisplayName + RecipientType = $mailbox.RecipientType + RecipientTypeDetails = $mailbox.RecipientTypeDetails + Guid = $mailbox.Guid + } + } + catch { + if ($_.Exception.Message -match 'couldn''t be found|not found|does not exist') { + return $null + } + throw + } + } -Force + + # SetMailboxType: Convert mailbox type (User <-> Shared) + $adapter | Add-Member -MemberType ScriptMethod -Name SetMailboxType -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateSet('User', 'Shared', 'Room', 'Equipment')] + [string] $Type, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + # Map type to RecipientTypeDetails + switch ($Type) { + 'User' { + $params['Type'] = 'Regular' + } + 'Shared' { + $params['Type'] = 'Shared' + } + 'Room' { + $params['Type'] = 'Room' + } + 'Equipment' { + $params['Type'] = 'Equipment' + } + } + + $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( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + try { + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + $config = $this.InvokeSafely('Get-MailboxAutoReplyConfiguration', $params) + + if ($null -eq $config) { + return $null + } + + # Normalize output to hashtable + return @{ + Identity = $config.Identity + AutoReplyState = $config.AutoReplyState + StartTime = $config.StartTime + EndTime = $config.EndTime + InternalMessage = $config.InternalMessage + ExternalMessage = $config.ExternalMessage + ExternalAudience = $config.ExternalAudience + CreateOOFEvent = $config.CreateOOFEvent + OOFEventSubject = $config.OOFEventSubject + DeclineAllEventsForScheduledOOF = $config.DeclineAllEventsForScheduledOOF + } + } + catch { + if ($_.Exception.Message -match 'couldn''t be found|not found|does not exist') { + return $null + } + throw + } + } -Force + + # SetMailboxAutoReplyConfiguration: Update Out of Office settings + $adapter | Add-Member -MemberType ScriptMethod -Name SetMailboxAutoReplyConfiguration -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Config, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + # Map config keys to cmdlet parameters + if ($Config.ContainsKey('Mode')) { + $mode = $Config['Mode'] + switch ($mode) { + 'Disabled' { $params['AutoReplyState'] = 'Disabled' } + 'Enabled' { $params['AutoReplyState'] = 'Enabled' } + 'Scheduled' { $params['AutoReplyState'] = 'Scheduled' } + default { throw "Invalid Mode value: $mode. Expected Disabled, Enabled, or Scheduled." } + } + } + + if ($Config.ContainsKey('Start')) { + $params['StartTime'] = $Config['Start'] + } + + if ($Config.ContainsKey('End')) { + $params['EndTime'] = $Config['End'] + } + + if ($Config.ContainsKey('InternalMessage')) { + $params['InternalMessage'] = $Config['InternalMessage'] + } + + if ($Config.ContainsKey('ExternalMessage')) { + $params['ExternalMessage'] = $Config['ExternalMessage'] + } + + if ($Config.ContainsKey('ExternalAudience')) { + $params['ExternalAudience'] = $Config['ExternalAudience'] + } + + $this.InvokeSafely('Set-MailboxAutoReplyConfiguration', $params) + } -Force + + return $adapter +} diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 new file mode 100644 index 00000000..e949bb59 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -0,0 +1,372 @@ +function New-IdleExchangeOnlineProvider { + <# + .SYNOPSIS + Creates an Exchange Online mailbox provider for IdLE. + + .DESCRIPTION + This provider integrates with Exchange Online for mailbox lifecycle management operations. + It supports mailbox reporting, type conversions, and Out of Office configuration management. + + The provider implements the mailbox-specific provider contract used by IdLE.Steps.Mailbox. + + Identity addressing: + - UserPrincipalName (UPN) - preferred + - Primary SMTP address (email) + - Mailbox GUID (for deterministic operations) + + The canonical identity key for all outputs is the primary SMTP address. + + Authentication: + Provider methods accept an optional AuthSession parameter for runtime credential + selection via the AuthSessionBroker. The provider supports multiple auth session formats: + - String access token (for future Graph API integration) + - Object with AccessToken property + - Object with GetAccessToken() method + - PSCredential (for certificate-based auth) + + By default, mailbox steps should use: + - With.AuthSessionName = 'ExchangeOnline' + - With.AuthSessionOptions = @{ Role = 'Admin' } (or other routing keys) + + Prerequisites: + - ExchangeOnlineManagement PowerShell module must be installed + - For app-only (certificate) auth: Windows platform required (MVP limitation) + - Authenticated session must be established before using provider methods + + .PARAMETER Adapter + Internal parameter for dependency injection during testing. Allows unit tests to inject + a fake adapter without requiring a real Exchange Online environment. + + .EXAMPLE + # Basic usage with delegated auth + # Host establishes connection first + Connect-ExchangeOnline -UserPrincipalName admin@contoso.com + + $provider = New-IdleExchangeOnlineProvider + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + ExchangeOnline = $provider + } + + .EXAMPLE + # Certificate-based app-only auth (Windows only) + # Host establishes connection first + Connect-ExchangeOnline -CertificateThumbprint $thumbprint -AppId $appId -Organization $tenantId + + $provider = New-IdleExchangeOnlineProvider + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + ExchangeOnline = $provider + } + + .OUTPUTS + PSCustomObject with IdLE mailbox provider contract methods + + .NOTES + Requires Exchange Online Management module and appropriate permissions: + - Exchange.ManageAsApp (app-only) + - Exchange Administrator or Global Administrator role (delegated) + - Required role: Mail Recipients (manage mailboxes) + + See the IdLE provider documentation for detailed setup. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Adapter + ) + + if ($null -eq $Adapter) { + # Verify ExchangeOnlineManagement module is available + $module = Get-Module -Name 'ExchangeOnlineManagement' -ListAvailable -ErrorAction SilentlyContinue + if ($null -eq $module) { + throw "ExchangeOnlineManagement module is not installed. Install it with: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser" + } + + $Adapter = New-IdleExchangeOnlineAdapter + } + + $extractAccessToken = { + param( + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + if ($null -eq $AuthSession) { + # For tests/development, allow null but commands will use existing session + return $null + } + + # String token (for future Graph API integration) + if ($AuthSession -is [string]) { + return $AuthSession + } + + # Object with GetAccessToken() method + if ($AuthSession.PSObject.Methods.Name -contains 'GetAccessToken') { + return $AuthSession.GetAccessToken() + } + + # Object with AccessToken property + if ($AuthSession.PSObject.Properties.Name -contains 'AccessToken') { + return $AuthSession.AccessToken + } + + # PSCredential (for certificate-based auth) + if ($AuthSession -is [PSCredential]) { + # Certificate thumbprint might be in password field + return $AuthSession.GetNetworkCredential().Password + } + + # Default: allow null for existing session-based commands + return $null + } + + $normalizeMailboxType = { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $RecipientTypeDetails + ) + + # Map Exchange RecipientTypeDetails to simplified types + switch -Regex ($RecipientTypeDetails) { + '^UserMailbox$|^LinkedMailbox$|^RemoteUserMailbox$' { return 'User' } + '^SharedMailbox$|^RemoteSharedMailbox$' { return 'Shared' } + '^RoomMailbox$|^RemoteRoomMailbox$' { return 'Room' } + '^EquipmentMailbox$|^RemoteEquipmentMailbox$' { return 'Equipment' } + default { return $RecipientTypeDetails } + } + } + + $provider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' + Name = 'ExchangeOnlineProvider' + Adapter = $Adapter + } + + $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -Force + $provider | Add-Member -MemberType ScriptMethod -Name NormalizeMailboxType -Value $normalizeMailboxType -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + $caps = @( + 'IdLE.Mailbox.Read' + 'IdLE.Mailbox.Type.Ensure' + 'IdLE.Mailbox.OutOfOffice.Ensure' + ) + + return $caps + } -Force + + # GetMailbox: Retrieve mailbox details + $provider | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + $mailbox = $this.Adapter.GetMailbox($IdentityKey, $accessToken) + + if ($null -eq $mailbox) { + throw "Mailbox '$IdentityKey' not found." + } + + # Normalize mailbox type + $normalizedType = $this.NormalizeMailboxType($mailbox['RecipientTypeDetails']) + + # Return structured mailbox data + return [pscustomobject]@{ + PSTypeName = 'IdLE.Mailbox' + IdentityKey = [string]$mailbox['PrimarySmtpAddress'] + PrimarySmtpAddress = [string]$mailbox['PrimarySmtpAddress'] + UserPrincipalName = [string]$mailbox['UserPrincipalName'] + DisplayName = [string]$mailbox['DisplayName'] + Type = $normalizedType + RecipientType = [string]$mailbox['RecipientType'] + RecipientTypeDetails = [string]$mailbox['RecipientTypeDetails'] + Guid = [string]$mailbox['Guid'] + } + } -Force + + # EnsureMailboxType: Idempotent mailbox type conversion + $provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxType -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateSet('User', 'Shared', 'Room', 'Equipment')] + [string] $DesiredType, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + # Get current mailbox state + $mailbox = $this.GetMailbox($IdentityKey, $AuthSession) + $currentType = $mailbox.Type + + # Check idempotency + if ($currentType -eq $DesiredType) { + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxType' + IdentityKey = $mailbox.PrimarySmtpAddress + Changed = $false + Type = $DesiredType + } + } + + # Perform conversion + $this.Adapter.SetMailboxType($mailbox.PrimarySmtpAddress, $DesiredType, $accessToken) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxType' + IdentityKey = $mailbox.PrimarySmtpAddress + Changed = $true + Type = $DesiredType + } + } -Force + + # GetOutOfOffice: Retrieve Out of Office configuration + $provider | Add-Member -MemberType ScriptMethod -Name GetOutOfOffice -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + # Verify mailbox exists first + $mailbox = $this.GetMailbox($IdentityKey, $AuthSession) + + $config = $this.Adapter.GetMailboxAutoReplyConfiguration($mailbox.PrimarySmtpAddress, $accessToken) + + if ($null -eq $config) { + throw "Out of Office configuration for mailbox '$IdentityKey' not found." + } + + # Map AutoReplyState to simplified Mode + $mode = switch ($config['AutoReplyState']) { + 'Disabled' { 'Disabled' } + 'Enabled' { 'Enabled' } + 'Scheduled' { 'Scheduled' } + default { 'Disabled' } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.MailboxOutOfOffice' + IdentityKey = $mailbox.PrimarySmtpAddress + Mode = $mode + Start = $config['StartTime'] + End = $config['EndTime'] + InternalMessage = $config['InternalMessage'] + ExternalMessage = $config['ExternalMessage'] + ExternalAudience = $config['ExternalAudience'] + } + } -Force + + # EnsureOutOfOffice: Idempotent Out of Office configuration + $provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Config, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + # Validate Config shape + if (-not $Config.ContainsKey('Mode')) { + throw "OutOfOffice Config must contain 'Mode' key (Disabled, Enabled, or Scheduled)." + } + + $mode = $Config['Mode'] + if ($mode -notin @('Disabled', 'Enabled', 'Scheduled')) { + throw "OutOfOffice Config Mode must be 'Disabled', 'Enabled', or 'Scheduled'. Got: $mode" + } + + if ($mode -eq 'Scheduled') { + if (-not $Config.ContainsKey('Start') -or -not $Config.ContainsKey('End')) { + throw "OutOfOffice Config Mode 'Scheduled' requires 'Start' and 'End' keys." + } + } + + # Verify mailbox exists first + $mailbox = $this.GetMailbox($IdentityKey, $AuthSession) + + # Get current config for idempotency check + $currentConfig = $this.GetOutOfOffice($mailbox.PrimarySmtpAddress, $AuthSession) + + # Simple idempotency check: if mode matches and messages match, skip update + $changed = $false + if ($currentConfig.Mode -ne $mode) { + $changed = $true + } + elseif ($Config.ContainsKey('InternalMessage') -and $currentConfig.InternalMessage -ne $Config['InternalMessage']) { + $changed = $true + } + elseif ($Config.ContainsKey('ExternalMessage') -and $currentConfig.ExternalMessage -ne $Config['ExternalMessage']) { + $changed = $true + } + elseif ($Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) { + $changed = $true + } + elseif ($mode -eq 'Scheduled') { + # Compare dates (allow small tolerance for serialization differences) + # Tolerance: 60 seconds to account for rounding during serialization/deserialization + $dateComparisonToleranceSeconds = 60 + $startDiff = [Math]::Abs(($currentConfig.Start - [DateTime]$Config['Start']).TotalSeconds) + $endDiff = [Math]::Abs(($currentConfig.End - [DateTime]$Config['End']).TotalSeconds) + if ($startDiff -gt $dateComparisonToleranceSeconds -or $endDiff -gt $dateComparisonToleranceSeconds) { + $changed = $true + } + } + + if (-not $changed) { + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureOutOfOffice' + IdentityKey = $mailbox.PrimarySmtpAddress + Changed = $false + } + } + + # Perform update + $this.Adapter.SetMailboxAutoReplyConfiguration($mailbox.PrimarySmtpAddress, $Config, $accessToken) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureOutOfOffice' + IdentityKey = $mailbox.PrimarySmtpAddress + Changed = $true + } + } -Force + + return $provider +} diff --git a/src/IdLE.Provider.ExchangeOnline/README.md b/src/IdLE.Provider.ExchangeOnline/README.md new file mode 100644 index 00000000..51b1d485 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/README.md @@ -0,0 +1,38 @@ +# IdLE.Provider.ExchangeOnline + +Exchange Online mailbox provider for IdLE. + +## Quick Start + +```powershell +# Import the provider +Import-Module IdLE.Provider.ExchangeOnline + +# Host establishes Exchange Online session (delegated or app-only) +Connect-ExchangeOnline -UserPrincipalName admin@contoso.com + +# Create provider +$provider = New-IdleExchangeOnlineProvider + +# Use in workflows +$providers = @{ + ExchangeOnline = $provider +} +$plan = New-IdlePlan -WorkflowPath '.\leaver.psd1' -Request $request -Providers $providers +``` + +## Prerequisites + +- PowerShell 7.0+ +- ExchangeOnlineManagement module (`Install-Module ExchangeOnlineManagement`) +- Authenticated Exchange Online session (host-managed) +- **App-only auth**: Windows only (MVP) + +## Documentation + +See the main IdLE documentation for: +- Full usage guide and examples +- Capabilities and mailbox steps +- Authentication patterns (delegated + app-only) +- Required permissions +- Troubleshooting diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 new file mode 100644 index 00000000..38e4cdad --- /dev/null +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 @@ -0,0 +1,27 @@ +@{ + RootModule = 'IdLE.Steps.Mailbox.psm1' + ModuleVersion = '0.9.0' + GUID = 'f7e6d5c4-b3a2-9180-7e6f-5d4c3b2a1908' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Provider-agnostic mailbox step pack for IdLE.' + PowerShellVersion = '7.0' + + RequiredModules = @('..\IdLE.Steps.Common\IdLE.Steps.Common.psd1') + + FunctionsToExport = @( + 'Get-IdleStepMetadataCatalog', + 'Invoke-IdleStepMailboxGetInfo', + 'Invoke-IdleStepMailboxTypeEnsure', + 'Invoke-IdleStepMailboxOutOfOfficeEnsure' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Steps', 'Mailbox') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + ContactEmail = '13959569+blindzero@users.noreply.github.com' + } + } +} diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 new file mode 100644 index 00000000..f878ea29 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 @@ -0,0 +1,33 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +# Import private helper functions from IdLE.Steps.Common +$commonModule = Get-Module -Name 'IdLE.Steps.Common' +if ($null -ne $commonModule) { + $commonPrivatePath = Join-Path -Path $commonModule.ModuleBase -ChildPath 'Private' + if (Test-Path -Path $commonPrivatePath) { + $privateScripts = @(Get-ChildItem -Path $commonPrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) + foreach ($script in $privateScripts) { + . $script.FullName + } + } +} + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize first to avoid enumeration issues during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } +} + +# Export Public functions - explicit list for deterministic behavior +Export-ModuleMember -Function @( + 'Get-IdleStepMetadataCatalog', + 'Invoke-IdleStepMailboxGetInfo', + 'Invoke-IdleStepMailboxTypeEnsure', + 'Invoke-IdleStepMailboxOutOfOfficeEnsure' +) diff --git a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..8d60b21d --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,45 @@ +function Get-IdleStepMetadataCatalog { + <# + .SYNOPSIS + Returns metadata for mailbox step types. + + .DESCRIPTION + This function provides a metadata catalog mapping Step.Type to metadata objects. + Each metadata object contains RequiredCapabilities (array of capability identifiers). + + The metadata is used during plan building to derive required provider capabilities + for each step, removing the need to declare RequiresCapabilities in workflow definitions. + + This catalog declares mailbox-specific step types that work with any provider + implementing the mailbox provider contract. + + .OUTPUTS + Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). + + .EXAMPLE + $metadata = Get-IdleStepMetadataCatalog + $metadata['IdLE.Step.Mailbox.GetInfo'].RequiredCapabilities + # Returns: @('IdLE.Mailbox.Read') + #> + [CmdletBinding()] + param() + + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # IdLE.Step.Mailbox.GetInfo - read mailbox details + $catalog['IdLE.Step.Mailbox.GetInfo'] = @{ + RequiredCapabilities = @('IdLE.Mailbox.Read') + } + + # IdLE.Step.Mailbox.Type.Ensure - idempotent mailbox type conversion + $catalog['IdLE.Step.Mailbox.Type.Ensure'] = @{ + RequiredCapabilities = @('IdLE.Mailbox.Read', 'IdLE.Mailbox.Type.Ensure') + } + + # IdLE.Step.Mailbox.OutOfOffice.Ensure - idempotent Out of Office configuration + $catalog['IdLE.Step.Mailbox.OutOfOffice.Ensure'] = @{ + RequiredCapabilities = @('IdLE.Mailbox.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') + } + + return $catalog +} diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 new file mode 100644 index 00000000..cd6ff218 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 @@ -0,0 +1,101 @@ +function Invoke-IdleStepMailboxGetInfo { + <# + .SYNOPSIS + Retrieves mailbox details and returns a structured report. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[]. The provider must implement a GetMailbox + method with the signature (IdentityKey, AuthSession) and return a mailbox object. + + The step is read-only and returns Changed = $false. + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method. + - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Admin' }). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + # In workflow definition: + @{ + Name = 'Get mailbox info' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + } + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "Mailbox.GetInfo requires 'With' to be a hashtable." + } + + if (-not $with.ContainsKey('IdentityKey')) { + throw "Mailbox.GetInfo requires With.IdentityKey." + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + # Create execution-local copy of With to avoid mutating the plan + $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } + + # Apply AuthSessionName convention: default to Provider if not specified + if (-not $effectiveWith.ContainsKey('AuthSessionName')) { + $effectiveWith['AuthSessionName'] = $providerAlias + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $effectiveWith ` + -ProviderAlias $providerAlias ` + -MethodName 'GetMailbox' ` + -MethodArguments @([string]$effectiveWith.IdentityKey) + + # Store mailbox data in State for downstream steps + $state = @{ + Mailbox = $result + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $false + Error = $null + State = $state + } +} diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 new file mode 100644 index 00000000..5f371877 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -0,0 +1,197 @@ +function Invoke-IdleStepMailboxOutOfOfficeEnsure { + <# + .SYNOPSIS + Ensures that a mailbox Out of Office (OOF) configuration matches the desired state. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[]. The provider must implement an EnsureOutOfOffice + method with the signature (IdentityKey, Config, AuthSession) and return an object + that contains a boolean property 'Changed'. + + The step is idempotent by design: it converges OOF configuration to the desired state. + + Out of Office Config shape (data-only hashtable): + - Mode: 'Disabled' | 'Enabled' | 'Scheduled' (required) + - Start: DateTime (required when Mode = 'Scheduled') + - End: DateTime (required when Mode = 'Scheduled') + - InternalMessage: string (optional) + - ExternalMessage: string (optional) + - ExternalAudience: 'None' | 'Known' | 'All' (optional, default provider-specific) + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method. + - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Admin' }). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + # In workflow definition (enable OOF): + @{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'I am currently unavailable.' + ExternalAudience = 'All' + } + } + } + + .EXAMPLE + # In workflow definition (with ValueFrom for dynamic values): + @{ + Name = 'Enable Out of Office for Leaver' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + ExternalAudience = 'All' + } + } + } + + .EXAMPLE + # In workflow definition (scheduled OOF): + @{ + Name = 'Schedule Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Scheduled' + Start = '2025-02-01T00:00:00Z' + End = '2025-02-15T00:00:00Z' + InternalMessage = 'I am on vacation until February 15.' + ExternalMessage = 'I am currently out of office.' + } + } + } + + .EXAMPLE + # In workflow definition (disable OOF): + @{ + Name = 'Disable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Disabled' + } + } + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "Mailbox.OutOfOffice.Ensure requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'Config')) { + if (-not $with.ContainsKey($key)) { + throw "Mailbox.OutOfOffice.Ensure requires With.$key." + } + } + + $config = $with.Config + if ($null -eq $config -or -not ($config -is [hashtable])) { + throw "Mailbox.OutOfOffice.Ensure requires With.Config to be a hashtable." + } + + # Validate Config shape + if (-not $config.ContainsKey('Mode')) { + throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode (Disabled, Enabled, or Scheduled)." + } + + $validModes = @('Disabled', 'Enabled', 'Scheduled') + if ($config.Mode -notin $validModes) { + throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode to be one of: $($validModes -join ', '). Got: $($config.Mode)" + } + + # Validate Scheduled mode requirements + if ($config.Mode -eq 'Scheduled') { + foreach ($key in @('Start', 'End')) { + if (-not $config.ContainsKey($key)) { + throw "Mailbox.OutOfOffice.Ensure with Mode 'Scheduled' requires With.Config.$key." + } + } + } + + # Security: reject ScriptBlocks in Config (data-only constraint) + foreach ($key in $config.Keys) { + if ($config[$key] -is [ScriptBlock]) { + throw "Mailbox.OutOfOffice.Ensure With.Config must not contain ScriptBlocks. Found ScriptBlock in key '$key'." + } + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + # Create execution-local copy of With to avoid mutating the plan + $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } + + # Apply AuthSessionName convention: default to Provider if not specified + if (-not $effectiveWith.ContainsKey('AuthSessionName')) { + $effectiveWith['AuthSessionName'] = $providerAlias + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $effectiveWith ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureOutOfOffice' ` + -MethodArguments @([string]$effectiveWith.IdentityKey, $config) + + $changed = $false + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } +} diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 new file mode 100644 index 00000000..6878dda0 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 @@ -0,0 +1,116 @@ +function Invoke-IdleStepMailboxTypeEnsure { + <# + .SYNOPSIS + Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[]. The provider must implement an EnsureMailboxType + method with the signature (IdentityKey, MailboxType, AuthSession) and return an object + that contains a boolean property 'Changed'. + + The step is idempotent by design: it converges state to the desired type. + + Supported mailbox types: + - User (regular user mailbox) + - Shared (shared mailbox for team use) + - Room (room resource mailbox) + - Equipment (equipment resource mailbox) + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method. + - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Admin' }). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + # In workflow definition (convert to shared mailbox): + @{ + Name = 'Convert to shared mailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + MailboxType = 'Shared' + } + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "Mailbox.Type.Ensure requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'MailboxType')) { + if (-not $with.ContainsKey($key)) { + throw "Mailbox.Type.Ensure requires With.$key." + } + } + + # Validate MailboxType + $validTypes = @('User', 'Shared', 'Room', 'Equipment') + if ($with.MailboxType -notin $validTypes) { + throw "Mailbox.Type.Ensure requires With.MailboxType to be one of: $($validTypes -join ', '). Got: $($with.MailboxType)" + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + # Create execution-local copy of With to avoid mutating the plan + $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } + + # Apply AuthSessionName convention: default to Provider if not specified + if (-not $effectiveWith.ContainsKey('AuthSessionName')) { + $effectiveWith['AuthSessionName'] = $providerAlias + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $effectiveWith ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureMailboxType' ` + -MethodArguments @([string]$effectiveWith.IdentityKey, [string]$effectiveWith.MailboxType) + + $changed = $false + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } +} diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md new file mode 100644 index 00000000..5a583fd8 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/README.md @@ -0,0 +1,32 @@ +# IdLE.Steps.Mailbox + +Provider-agnostic mailbox step pack for IdLE. + +## Quick Start + +```powershell +# Step example: Convert to shared mailbox +@{ + Name = 'ConvertToSharedMailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + MailboxType = 'Shared' + } +} +``` + +## Step Types + +- **IdLE.Step.Mailbox.GetInfo** - Read mailbox details +- **IdLE.Step.Mailbox.Type.Ensure** - Convert mailbox type (User/Shared/Room/Equipment) +- **IdLE.Step.Mailbox.OutOfOffice.Ensure** - Configure Out of Office settings + +## Documentation + +See the main IdLE documentation for: +- Detailed step usage and parameters +- Provider contract requirements +- Configuration examples +- Best practices diff --git a/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 b/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 new file mode 100644 index 00000000..b38e7955 --- /dev/null +++ b/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 @@ -0,0 +1,110 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Import Mailbox step pack + $testsRoot = $PSScriptRoot + $repoRoot = Split-Path -Path $testsRoot -Parent + $mailboxModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Steps.Mailbox\IdLE.Steps.Mailbox.psm1' + if (Test-Path -LiteralPath $mailboxModulePath -PathType Leaf) { + Import-Module $mailboxModulePath -Force + } +} + +Describe 'Invoke-IdleStepMailboxGetInfo' { + BeforeEach { + # Create mock ExchangeOnline provider + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + param($IdentityKey, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Mailbox '$IdentityKey' not found." + } + + return $this.Store[$IdentityKey] + } -Force + + # Add test mailbox + $script:Provider.Store['user@contoso.com'] = [pscustomobject]@{ + PSTypeName = 'IdLE.Mailbox' + IdentityKey = 'user@contoso.com' + PrimarySmtpAddress = 'user@contoso.com' + UserPrincipalName = 'user@contoso.com' + DisplayName = 'Test User' + Type = 'User' + RecipientType = 'UserMailbox' + RecipientTypeDetails = 'UserMailbox' + Guid = [System.Guid]::NewGuid().ToString() + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ ExchangeOnline = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Add mock AcquireAuthSession method + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return 'mock-token' + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Get mailbox info' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + } + } + } + + It 'retrieves mailbox and returns data in State' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + $result.State | Should -Not -BeNullOrEmpty + $result.State.Mailbox | Should -Not -BeNullOrEmpty + $result.State.Mailbox.IdentityKey | Should -Be 'user@contoso.com' + $result.State.Mailbox.Type | Should -Be 'User' + } + + It 'applies AuthSessionName convention (defaults to Provider)' { + # Remove AuthSessionName to test default behavior + $step = $script:StepTemplate + $step.With.Remove('AuthSessionName') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + # Step should complete successfully using default AuthSessionName + # (Plan object should remain unmodified - AuthSessionName still absent) + $step.With.ContainsKey('AuthSessionName') | Should -Be $false + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } +} diff --git a/tests/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 b/tests/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 new file mode 100644 index 00000000..2717a799 --- /dev/null +++ b/tests/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 @@ -0,0 +1,216 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Import Mailbox step pack + $testsRoot = $PSScriptRoot + $repoRoot = Split-Path -Path $testsRoot -Parent + $mailboxModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Steps.Mailbox\IdLE.Steps.Mailbox.psm1' + if (Test-Path -LiteralPath $mailboxModulePath -PathType Leaf) { + Import-Module $mailboxModulePath -Force + } +} + +Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { + BeforeEach { + # Create mock ExchangeOnline provider + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { + param($IdentityKey, $Config, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Mailbox '$IdentityKey' not found." + } + + $mailbox = $this.Store[$IdentityKey] + + # Simple idempotency check based on Mode + $changed = ($mailbox['OOFMode'] -ne $Config['Mode']) + + if ($changed) { + $mailbox['OOFMode'] = $Config['Mode'] + $mailbox['OOFInternalMessage'] = if ($Config.ContainsKey('InternalMessage')) { $Config['InternalMessage'] } else { '' } + $mailbox['OOFExternalMessage'] = if ($Config.ContainsKey('ExternalMessage')) { $Config['ExternalMessage'] } else { '' } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureOutOfOffice' + IdentityKey = $IdentityKey + Changed = $changed + } + } -Force + + # Add test mailbox + $script:Provider.Store['user@contoso.com'] = @{ + IdentityKey = 'user@contoso.com' + OOFMode = 'Disabled' + OOFInternalMessage = '' + OOFExternalMessage = '' + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ ExchangeOnline = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Add mock AcquireAuthSession method + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return 'mock-token' + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'Currently unavailable.' + } + } + } + } + + It 'enables Out of Office and reports Changed = true' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + + # Verify OOF was updated + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Enabled' + $script:Provider.Store['user@contoso.com']['OOFInternalMessage'] | Should -Be 'I am out of office.' + } + + It 'is idempotent when OOF already matches desired state' { + # Set OOF to Enabled first + $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + } + + It 'disables Out of Office' { + # First enable it + $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' + + $step = $script:StepTemplate + $step.With.Config = @{ Mode = 'Disabled' } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Disabled' + } + + It 'configures scheduled Out of Office' { + $start = [DateTime]::Parse('2025-02-01T00:00:00Z') + $end = [DateTime]::Parse('2025-02-15T00:00:00Z') + + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + Start = $start + End = $end + InternalMessage = 'On vacation' + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Scheduled' + } + + It 'throws when Config is missing' { + $step = $script:StepTemplate + $step.With.Remove('Config') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config*" + } + + It 'throws when Config.Mode is missing' { + $step = $script:StepTemplate + $step.With.Config = @{ InternalMessage = 'Test' } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config.Mode*" + } + + It 'throws when Config.Mode is invalid' { + $step = $script:StepTemplate + $step.With.Config.Mode = 'InvalidMode' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode to be one of: Disabled, Enabled, Scheduled*" + } + + It 'throws when Scheduled mode is missing Start' { + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + End = [DateTime]::Now + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode 'Scheduled' requires With.Config.Start*" + } + + It 'throws when Scheduled mode is missing End' { + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + Start = [DateTime]::Now + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode 'Scheduled' requires With.Config.End*" + } + + It 'rejects ScriptBlocks in Config (security boundary)' { + $step = $script:StepTemplate + $step.With.Config.InternalMessage = { Write-Host "malicious" } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*must not contain ScriptBlocks*" + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } +} diff --git a/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 b/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 new file mode 100644 index 00000000..2535dc95 --- /dev/null +++ b/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 @@ -0,0 +1,148 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Import Mailbox step pack + $testsRoot = $PSScriptRoot + $repoRoot = Split-Path -Path $testsRoot -Parent + $mailboxModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Steps.Mailbox\IdLE.Steps.Mailbox.psm1' + if (Test-Path -LiteralPath $mailboxModulePath -PathType Leaf) { + Import-Module $mailboxModulePath -Force + } +} + +Describe 'Invoke-IdleStepMailboxTypeEnsure' { + BeforeEach { + # Create mock ExchangeOnline provider + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxType -Value { + param($IdentityKey, $MailboxType, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Mailbox '$IdentityKey' not found." + } + + $mailbox = $this.Store[$IdentityKey] + $currentType = $mailbox['Type'] + $changed = ($currentType -ne $MailboxType) + + if ($changed) { + $mailbox['Type'] = $MailboxType + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxType' + IdentityKey = $IdentityKey + Changed = $changed + Type = $MailboxType + } + } -Force + + # Add test mailbox + $script:Provider.Store['user@contoso.com'] = @{ + IdentityKey = 'user@contoso.com' + Type = 'User' + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ ExchangeOnline = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Add mock AcquireAuthSession method + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return 'mock-token' + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Convert to shared mailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + MailboxType = 'Shared' + } + } + } + + It 'converts mailbox type and reports Changed = true' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + + # Verify mailbox was updated + $script:Provider.Store['user@contoso.com']['Type'] | Should -Be 'Shared' + } + + It 'is idempotent when mailbox already has desired type' { + # Set mailbox to Shared first + $script:Provider.Store['user@contoso.com']['Type'] = 'Shared' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + } + + It 'throws when MailboxType is invalid' { + $step = $script:StepTemplate + $step.With.MailboxType = 'InvalidType' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*MailboxType to be one of: User, Shared, Room, Equipment*" + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } + + It 'throws when MailboxType is missing' { + $step = $script:StepTemplate + $step.With.Remove('MailboxType') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.MailboxType*" + } + + It 'supports all valid mailbox types' { + foreach ($type in @('Shared', 'Room', 'Equipment', 'User')) { + # Always set to a different type first + $startType = if ($type -eq 'User') { 'Shared' } else { 'User' } + $script:Provider.Store['user@contoso.com']['Type'] = $startType + + $step = $script:StepTemplate + $step.With.MailboxType = $type + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['Type'] | Should -Be $type + } + } +} diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 new file mode 100644 index 00000000..a3986e3a --- /dev/null +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -0,0 +1,396 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + Import-IdleTestModule + + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + + # Import ExchangeOnline provider + $exoModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\IdLE.Provider.ExchangeOnline.psm1' + if (-not (Test-Path -LiteralPath $exoModulePath -PathType Leaf)) { + throw "ExchangeOnline provider module not found at: $exoModulePath" + } + Import-Module $exoModulePath -Force +} + +Describe 'ExchangeOnline provider - Unit tests' { + BeforeAll { + # Create a fake adapter for tests + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.ExchangeOnlineAdapter.Fake' + Store = @{ + Mailboxes = @{} + AutoReply = @{} + } + } + + # GetMailbox: Retrieve mailbox by identity + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + param($MailboxIdentity, $AccessToken) + + # Try direct key lookup + if ($this.Store.Mailboxes.ContainsKey($MailboxIdentity)) { + return $this.Store.Mailboxes[$MailboxIdentity] + } + + # Search by UPN or SMTP + foreach ($key in $this.Store.Mailboxes.Keys) { + $mailbox = $this.Store.Mailboxes[$key] + if ($mailbox['UserPrincipalName'] -eq $MailboxIdentity -or + $mailbox['PrimarySmtpAddress'] -eq $MailboxIdentity) { + return $mailbox + } + } + + return $null + } -Force + + # SetMailboxType: Convert mailbox type + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name SetMailboxType -Value { + param($MailboxIdentity, $Type, $AccessToken) + + $mailbox = $this.GetMailbox($MailboxIdentity, $AccessToken) + if ($null -eq $mailbox) { + throw "Mailbox '$MailboxIdentity' not found." + } + + # Update RecipientTypeDetails based on Type + $mailbox['RecipientTypeDetails'] = switch ($Type) { + 'User' { 'UserMailbox' } + 'Shared' { 'SharedMailbox' } + 'Room' { 'RoomMailbox' } + 'Equipment' { 'EquipmentMailbox' } + default { 'UserMailbox' } + } + } -Force + + # GetMailboxAutoReplyConfiguration: Get OOF settings + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetMailboxAutoReplyConfiguration -Value { + param($MailboxIdentity, $AccessToken) + + $key = $MailboxIdentity + if (-not $this.Store.AutoReply.ContainsKey($key)) { + # Initialize default OOF config + $this.Store.AutoReply[$key] = @{ + Identity = $MailboxIdentity + AutoReplyState = 'Disabled' + StartTime = $null + EndTime = $null + InternalMessage = '' + ExternalMessage = '' + ExternalAudience = 'All' + } + } + return $this.Store.AutoReply[$key] + } -Force + + # SetMailboxAutoReplyConfiguration: Update OOF settings + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name SetMailboxAutoReplyConfiguration -Value { + param($MailboxIdentity, $Config, $AccessToken) + + $key = $MailboxIdentity + if (-not $this.Store.AutoReply.ContainsKey($key)) { + $this.Store.AutoReply[$key] = @{ + Identity = $MailboxIdentity + AutoReplyState = 'Disabled' + StartTime = $null + EndTime = $null + InternalMessage = '' + ExternalMessage = '' + ExternalAudience = 'All' + } + } + + $current = $this.Store.AutoReply[$key] + + # Map Mode to AutoReplyState (same as real adapter) + if ($Config.ContainsKey('Mode')) { + $mode = $Config['Mode'] + switch ($mode) { + 'Disabled' { $current['AutoReplyState'] = 'Disabled' } + 'Enabled' { $current['AutoReplyState'] = 'Enabled' } + 'Scheduled' { $current['AutoReplyState'] = 'Scheduled' } + } + } + if ($Config.ContainsKey('AutoReplyState')) { + $current['AutoReplyState'] = $Config['AutoReplyState'] + } + if ($Config.ContainsKey('Start')) { + $current['StartTime'] = $Config['Start'] + } + if ($Config.ContainsKey('StartTime')) { + $current['StartTime'] = $Config['StartTime'] + } + if ($Config.ContainsKey('End')) { + $current['EndTime'] = $Config['End'] + } + if ($Config.ContainsKey('EndTime')) { + $current['EndTime'] = $Config['EndTime'] + } + if ($Config.ContainsKey('InternalMessage')) { + $current['InternalMessage'] = $Config['InternalMessage'] + } + if ($Config.ContainsKey('ExternalMessage')) { + $current['ExternalMessage'] = $Config['ExternalMessage'] + } + if ($Config.ContainsKey('ExternalAudience')) { + $current['ExternalAudience'] = $Config['ExternalAudience'] + } + } -Force + + # Helper to create test mailboxes + function Add-TestMailbox { + param( + [string]$PrimarySmtpAddress, + [string]$UserPrincipalName = $PrimarySmtpAddress, + [string]$DisplayName = "User Mailbox", + [string]$RecipientTypeDetails = 'UserMailbox', + [string]$RecipientType = 'UserMailbox' + ) + + $guid = [System.Guid]::NewGuid().ToString() + $mailbox = @{ + Identity = $PrimarySmtpAddress + PrimarySmtpAddress = $PrimarySmtpAddress + UserPrincipalName = $UserPrincipalName + DisplayName = $DisplayName + RecipientType = $RecipientType + RecipientTypeDetails = $RecipientTypeDetails + Guid = $guid + } + + $fakeAdapter.Store.Mailboxes[$PrimarySmtpAddress] = $mailbox + return $mailbox + } + + # Create provider with fake adapter + $provider = New-IdleExchangeOnlineProvider -Adapter $fakeAdapter + } + + Context 'GetCapabilities' { + It 'returns mailbox-specific capabilities' { + $caps = $provider.GetCapabilities() + $caps | Should -Contain 'IdLE.Mailbox.Read' + $caps | Should -Contain 'IdLE.Mailbox.Type.Ensure' + $caps | Should -Contain 'IdLE.Mailbox.OutOfOffice.Ensure' + } + } + + Context 'GetMailbox' { + It 'retrieves mailbox by primary SMTP address' { + Add-TestMailbox -PrimarySmtpAddress 'user1@contoso.com' -DisplayName 'User One' + + $mailbox = $provider.GetMailbox('user1@contoso.com', $null) + + $mailbox | Should -Not -BeNullOrEmpty + $mailbox.PrimarySmtpAddress | Should -Be 'user1@contoso.com' + $mailbox.DisplayName | Should -Be 'User One' + $mailbox.Type | Should -Be 'User' + } + + It 'throws error when mailbox not found' { + { $provider.GetMailbox('nonexistent@contoso.com', $null) } | + Should -Throw "*Mailbox 'nonexistent@contoso.com' not found*" + } + + It 'normalizes mailbox type correctly' { + Add-TestMailbox -PrimarySmtpAddress 'shared1@contoso.com' -RecipientTypeDetails 'SharedMailbox' + + $mailbox = $provider.GetMailbox('shared1@contoso.com', $null) + + $mailbox.Type | Should -Be 'Shared' + $mailbox.RecipientTypeDetails | Should -Be 'SharedMailbox' + } + } + + Context 'EnsureMailboxType' { + It 'converts user mailbox to shared mailbox' { + Add-TestMailbox -PrimarySmtpAddress 'user2@contoso.com' -RecipientTypeDetails 'UserMailbox' + + $result = $provider.EnsureMailboxType('user2@contoso.com', 'Shared', $null) + + $result.Changed | Should -Be $true + $result.Operation | Should -Be 'EnsureMailboxType' + $result.Type | Should -Be 'Shared' + + # Verify mailbox was actually updated + $mailbox = $provider.GetMailbox('user2@contoso.com', $null) + $mailbox.Type | Should -Be 'Shared' + } + + It 'is idempotent when mailbox already has desired type' { + Add-TestMailbox -PrimarySmtpAddress 'shared2@contoso.com' -RecipientTypeDetails 'SharedMailbox' + + $result = $provider.EnsureMailboxType('shared2@contoso.com', 'Shared', $null) + + $result.Changed | Should -Be $false + } + + It 'supports all mailbox types' { + foreach ($type in @('Shared', 'Room', 'Equipment', 'User')) { + $email = "test-$type@contoso.com".ToLowerInvariant() + # Always start with UserMailbox, except for last iteration testing User type + $startType = if ($type -eq 'User') { 'SharedMailbox' } else { 'UserMailbox' } + Add-TestMailbox -PrimarySmtpAddress $email -RecipientTypeDetails $startType + + $result = $provider.EnsureMailboxType($email, $type, $null) + + $result.Changed | Should -Be $true + $result.Type | Should -Be $type + } + } + } + + Context 'GetOutOfOffice' { + It 'retrieves Out of Office configuration' { + Add-TestMailbox -PrimarySmtpAddress 'user3@contoso.com' + + $config = $provider.GetOutOfOffice('user3@contoso.com', $null) + + $config | Should -Not -BeNullOrEmpty + $config.Mode | Should -Be 'Disabled' + $config.IdentityKey | Should -Be 'user3@contoso.com' + } + + It 'throws error when mailbox not found' { + { $provider.GetOutOfOffice('nonexistent@contoso.com', $null) } | + Should -Throw "*Mailbox 'nonexistent@contoso.com' not found*" + } + } + + Context 'EnsureOutOfOffice' { + It 'enables Out of Office' { + Add-TestMailbox -PrimarySmtpAddress 'user4@contoso.com' + + $config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'Currently unavailable.' + } + + $result = $provider.EnsureOutOfOffice('user4@contoso.com', $config, $null) + + $result.Changed | Should -Be $true + $result.Operation | Should -Be 'EnsureOutOfOffice' + + # Verify OOF was actually updated + $oofConfig = $provider.GetOutOfOffice('user4@contoso.com', $null) + $oofConfig.Mode | Should -Be 'Enabled' + $oofConfig.InternalMessage | Should -Be 'I am out of office.' + } + + It 'is idempotent when OOF already matches desired state' { + Add-TestMailbox -PrimarySmtpAddress 'user5@contoso.com' + + $config = @{ + Mode = 'Enabled' + InternalMessage = 'Out of office' + } + + # Set initial state + $provider.EnsureOutOfOffice('user5@contoso.com', $config, $null) | Out-Null + + # Second call should report no change + $result = $provider.EnsureOutOfOffice('user5@contoso.com', $config, $null) + $result.Changed | Should -Be $false + } + + It 'configures scheduled Out of Office' { + Add-TestMailbox -PrimarySmtpAddress 'user6@contoso.com' + + $start = [DateTime]::Parse('2025-02-01T00:00:00Z') + $end = [DateTime]::Parse('2025-02-15T00:00:00Z') + + $config = @{ + Mode = 'Scheduled' + Start = $start + End = $end + InternalMessage = 'On vacation' + } + + $result = $provider.EnsureOutOfOffice('user6@contoso.com', $config, $null) + + $result.Changed | Should -Be $true + + # Verify OOF was updated + $oofConfig = $provider.GetOutOfOffice('user6@contoso.com', $null) + $oofConfig.Mode | Should -Be 'Scheduled' + } + + It 'disables Out of Office' { + Add-TestMailbox -PrimarySmtpAddress 'user7@contoso.com' + + # First enable it + $enableConfig = @{ Mode = 'Enabled'; InternalMessage = 'Away' } + $provider.EnsureOutOfOffice('user7@contoso.com', $enableConfig, $null) | Out-Null + + # Now disable it + $disableConfig = @{ Mode = 'Disabled' } + $result = $provider.EnsureOutOfOffice('user7@contoso.com', $disableConfig, $null) + + $result.Changed | Should -Be $true + + # Verify OOF is disabled + $oofConfig = $provider.GetOutOfOffice('user7@contoso.com', $null) + $oofConfig.Mode | Should -Be 'Disabled' + } + + It 'throws error when Config is missing Mode' { + Add-TestMailbox -PrimarySmtpAddress 'user8@contoso.com' + + $badConfig = @{ InternalMessage = 'Test' } + + { $provider.EnsureOutOfOffice('user8@contoso.com', $badConfig, $null) } | + Should -Throw -ExpectedMessage "*must contain 'Mode' key*" + } + + It 'throws error when Scheduled mode is missing Start/End' { + Add-TestMailbox -PrimarySmtpAddress 'user9@contoso.com' + + $badConfig = @{ Mode = 'Scheduled' } + + { $provider.EnsureOutOfOffice('user9@contoso.com', $badConfig, $null) } | + Should -Throw -ExpectedMessage "*requires 'Start' and 'End' keys*" + } + + It 'throws error when Mode is invalid' { + Add-TestMailbox -PrimarySmtpAddress 'user10@contoso.com' + + $badConfig = @{ Mode = 'InvalidMode' } + + { $provider.EnsureOutOfOffice('user10@contoso.com', $badConfig, $null) } | + Should -Throw -ExpectedMessage "*must be 'Disabled', 'Enabled', or 'Scheduled'*" + } + + It 'detects changes to ExternalAudience and updates accordingly' { + Add-TestMailbox -PrimarySmtpAddress 'user11@contoso.com' + + # Set initial OOF with ExternalAudience = 'Known' + $initialConfig = @{ + Mode = 'Enabled' + InternalMessage = 'Out of office' + ExternalMessage = 'Currently unavailable' + ExternalAudience = 'Known' + } + $provider.EnsureOutOfOffice('user11@contoso.com', $initialConfig, $null) | Out-Null + + # Change only ExternalAudience to 'All', keep messages the same + $updatedConfig = @{ + Mode = 'Enabled' + InternalMessage = 'Out of office' + ExternalMessage = 'Currently unavailable' + ExternalAudience = 'All' + } + $result = $provider.EnsureOutOfOffice('user11@contoso.com', $updatedConfig, $null) + + # Should detect the change and update + $result.Changed | Should -Be $true + + # Verify ExternalAudience was actually updated + $oofConfig = $provider.GetOutOfOffice('user11@contoso.com', $null) + $oofConfig.ExternalAudience | Should -Be 'All' + } + } +}