diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 0fb58b0a..89c09ede 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,149 @@ 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) + +When using delegated (user) authentication, mint the token for the Exchange Online resource: + +```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 +``` + +> **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' +``` -Your host/runtime must establish the Exchange Online session and (optionally) route it via the AuthSessionBroker. -Mailbox steps typically reference that session via: +For **delegated** flows, the token's `scp` claim must include: +- `https://outlook.office365.com/Exchange.Manage` — full mailbox management (delegated) -- `AuthSessionName = 'ExchangeOnline'` -- `AuthSessionOptions = @{ Role = 'Admin' }` (optional routing key) +For **app-only** flows, the token's `roles` claim must include: +- `Exchange.ManageAsApp` — app-only/service principal access -> Keep credentials/secrets **out of workflow files**. Resolve them in the host/runtime and provide them via the broker. +> **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. -## Supported Step Types +:::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 +208,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 +219,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` + +- **`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`** + → 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/Test-IdleExchangeOnlinePrerequisites.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 index 0d44fb8b..fd5dd7bb 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 — 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 @@ -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' + } + + # Get-Mailbox is a session proxy cmdlet — only available after Connect-ExchangeOnline. + # Its absence means no active Exchange Online session exists in this runspace. + $getMailboxCmd = Get-Command -Name 'Get-Mailbox' -ErrorAction SilentlyContinue + if ($null -eq $getMailboxCmd) { + $missingRequired += 'ExchangeOnlineSession' + $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." + } + } $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..9ac3faed 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -75,8 +75,13 @@ function New-IdleExchangeOnlineProvider { [object] $Adapter ) - # Check prerequisites and emit warnings if required components are missing + # 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'))" + 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 +90,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,6 +109,8 @@ function New-IdleExchangeOnlineProvider { $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ExchangeOnlineAdapter') if ($isRealAdapter) { + # 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 ', ' diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 45063a83..e578b9d8 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 'Get-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 (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 'Get-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 'Get-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 = 'Get-Mailbox' } } ` + -ParameterFilter { $Name -eq 'Get-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 = 'Get-Mailbox' } } ` + -ParameterFilter { $Name -eq 'Get-Mailbox' } + + $result = Test-IdleExchangeOnlinePrerequisites + + $result.PSObject.TypeNames | Should -Contain 'IdLE.PrerequisitesResult' + $result.ProviderName | Should -Be 'ExchangeOnlineProvider' + $result.CheckedAt | Should -BeOfType [datetime] + } + } + } +}