From 1fc5197de6f9b4bf841216cadd61789369693710 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:03:22 +0000 Subject: [PATCH 1/4] Initial plan From 18f116abe6a503bf020d74496a124bd546a3f7e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:25:18 +0000 Subject: [PATCH 2/4] Fix precondition plan warning visibility and PSSA issues - Add Write-Warning to Assert-IdleConditionPathsResolvable so missing Request.Context.* paths in preconditions emit a visible terminal warning during New-IdlePlan in addition to being stored in plan.Warnings - Rename Normalize-IdleExchangeOnlineAutoReplyMessage to Format-IdleExchangeOnlineAutoReplyMessage (PSUseApprovedVerbs) - Apply Invoke-Formatter to fix PSUseConsistentWhitespace and PSUseConsistentIndentation across 7 source files Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Assert-IdleConditionPathsResolvable.ps1 | 34 +- .../Private/Get-IdleReadOnlyCapabilities.ps1 | 2 +- .../Public/Invoke-IdlePlanObject.ps1 | 6 +- .../Public/New-IdleAuthSessionBroker.ps1 | 8 +- .../Public/New-IdleRequestObject.ps1 | 4 +- .../Private/Get-IdleADAttributeContract.ps1 | 96 +++--- .../Private/New-IdleADAdapter.ps1 | 2 +- ...at-IdleExchangeOnlineAutoReplyMessage.ps1} | 8 +- .../Public/New-IdleExchangeOnlineProvider.ps1 | 290 +++++++++--------- .../ExchangeOnlineProvider.Tests.ps1 | 22 +- 10 files changed, 238 insertions(+), 234 deletions(-) rename src/IdLE.Provider.ExchangeOnline/Private/{Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 => Format-IdleExchangeOnlineAutoReplyMessage.ps1} (91%) diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index d8f1f801..83945bfe 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -152,22 +152,26 @@ function Assert-IdleConditionPathsResolvable { } } - if ($softMissingContextPaths.Count -gt 0 -and $null -ne $WarningSink) { - $warningItem = [ordered]@{ - Code = 'PreconditionContextPathUnresolvedAtPlan' - Type = 'Warning' - Step = $StepName - Source = $Source - Paths = @($softMissingContextPaths | Select-Object -Unique) - Message = ("Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', @($softMissingContextPaths | Select-Object -Unique)))) - } + if ($softMissingContextPaths.Count -gt 0) { + $uniqueSoftPaths = @($softMissingContextPaths | Select-Object -Unique) + $warningMessage = "Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', $uniqueSoftPaths)) + + # Emit a visible PowerShell warning for immediate host feedback during planning. + Write-Warning $warningMessage + + if ($null -ne $WarningSink) { + $warningItem = [ordered]@{ + Code = 'PreconditionContextPathUnresolvedAtPlan' + Type = 'Warning' + Step = $StepName + Source = $Source + Paths = $uniqueSoftPaths + Message = $warningMessage + } - if ($WarningSink -is [System.Collections.IList]) { - $null = $WarningSink.Add($warningItem) - } - elseif ($WarningSink -is [object[]]) { - # Fallback for fixed arrays: cannot mutate by reference safely. - # Caller should pass an IList (plan.Warnings is an ArrayList) for collection. + if ($WarningSink -is [System.Collections.IList]) { + $null = $WarningSink.Add($warningItem) + } } } diff --git a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 index 634bc049..e956032e 100644 --- a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 @@ -70,7 +70,7 @@ function Get-IdleCapabilityContextPath { switch ($Capability) { 'IdLE.Entitlement.List' { return 'Identity.Entitlements' } - 'IdLE.Identity.Read' { return 'Identity.Profile' } + 'IdLE.Identity.Read' { return 'Identity.Profile' } default { throw [System.ArgumentException]::new( "No predefined context path defined for capability '$Capability'.", diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 9cf265cb..2374cca9 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -118,7 +118,7 @@ function Invoke-IdlePlanObject { # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries if ($null -ne $planProviders) { $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or - ($planProviders.PSObject -and $planProviders.PSObject.Properties) + ($planProviders.PSObject -and $planProviders.PSObject.Properties) if ($isValidProvider) { $effectiveProviders = $planProviders } @@ -359,7 +359,7 @@ function Invoke-IdlePlanObject { $pcEvt = Get-IdlePropertyValue -Object $step -Name 'PreconditionEvent' if ($null -ne $pcEvt) { $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type') - $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') + $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data' # PreconditionEvent.Data is validated as a hashtable at planning time and # stored via Copy-IdleDataObject, so it will be a hashtable (IDictionary) here. @@ -659,7 +659,7 @@ function Invoke-IdlePlanObject { $ofPcEvt = Get-IdlePropertyValue -Object $ofStep -Name 'PreconditionEvent' if ($null -ne $ofPcEvt) { $ofPcEvtType = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Type') - $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') + $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') $ofPcEvtData = Get-IdlePropertyValue -Object $ofPcEvt -Name 'Data' $ofPcEvtDataHt = if ($ofPcEvtData -is [System.Collections.IDictionary]) { [hashtable]$ofPcEvtData } else { $null } $context.EventSink.WriteEvent($ofPcEvtType, $ofPcEvtMsg, $ofName, $ofPcEvtDataHt) diff --git a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 index 8a29b9dc..e4b709f3 100644 --- a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 +++ b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 @@ -370,10 +370,10 @@ function New-IdleAuthSessionBroker { # If multiple matches, this is ambiguous - fail with clear error if ($matchingEntries.Count -gt 1) { $matchDetails = ($matchingEntries | ForEach-Object { - $currentEntry = $_ - $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' - "{ $keyStr }" - }) -join '; ' + $currentEntry = $_ + $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' + "{ $keyStr }" + }) -join '; ' throw "Ambiguous auth session match for Name='$Name'. Multiple entries matched: $matchDetails. Provide AuthSessionOptions to disambiguate." } diff --git a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 index 77d8d096..48e6b915 100644 --- a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -100,8 +100,8 @@ function New-IdleRequestObject { # Clone hashtables to avoid external mutation after object creation # shallow clone is sufficient as we have already validated no ScriptBlocks are present $IdentityKeys = if ($null -eq $IdentityKeys) { @{} } else { $IdentityKeys.Clone() } - $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } - $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } + $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } + $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } # Construct and return the core domain object defined in Private/IdleLifecycleRequest.ps1 return [IdleLifecycleRequest]::new( diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 index 1e8cb77d..e42c0f70 100644 --- a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -46,88 +46,88 @@ function Get-IdleADAttributeContract { if ($Operation -eq 'CreateIdentity') { $contract = @{ # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Name Attributes - Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Password Attributes AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false } - AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } - AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # State Attributes - Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # Extension Container (keys must be valid LDAP attribute names) - OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } } } elseif ($Operation -eq 'EnsureAttributes') { $contract = @{ # Name Attributes - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Address Attributes - StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - City = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - State = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + City = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + State = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Web / Profile Attributes - HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Account / Profile Path Attributes - HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Extension Container (keys must be valid LDAP attribute names, e.g. 'mobile', 'telephoneNumber') OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 28b77786..461609ad 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -331,7 +331,7 @@ function New-IdleADAdapter { Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from DisplayName" } elseif ($effectiveAttributes.ContainsKey('GivenName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['GivenName']) -and - $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { + $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { $derivedName = "$($effectiveAttributes['GivenName']) $($effectiveAttributes['Surname'])" Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from GivenName+Surname" } diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 similarity index 91% rename from src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 rename to src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 index 43536e1b..3303a5de 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -1,7 +1,7 @@ -function Normalize-IdleExchangeOnlineAutoReplyMessage { +function Format-IdleExchangeOnlineAutoReplyMessage { <# .SYNOPSIS - Normalizes Exchange Online auto-reply messages for stable idempotency comparison. + Formats Exchange Online auto-reply messages for stable idempotency comparison. .DESCRIPTION Exchange Online may introduce server-side canonicalization when storing automatic reply messages, @@ -26,8 +26,8 @@ function Normalize-IdleExchangeOnlineAutoReplyMessage { System.String - The normalized message string. .EXAMPLE - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentMessage - if ($normalized -eq (Normalize-IdleExchangeOnlineAutoReplyMessage -Message $desiredMessage)) { + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentMessage + if ($normalized -eq (Format-IdleExchangeOnlineAutoReplyMessage -Message $desiredMessage)) { # Messages are functionally equivalent } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index be0b0aa2..281b29a1 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -369,8 +369,8 @@ function New-IdleExchangeOnlineProvider { # Check internal message with normalization if ($Config.ContainsKey('InternalMessage')) { # Use normalization to handle server-side HTML canonicalization - $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage - $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] + $normalizedCurrent = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage + $normalizedDesired = Format-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] if ($normalizedCurrent -ne $normalizedDesired) { $changed = $true } @@ -379,8 +379,8 @@ function New-IdleExchangeOnlineProvider { # Check external message with normalization if ($Config.ContainsKey('ExternalMessage')) { # Use normalization to handle server-side HTML canonicalization - $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage - $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] + $normalizedCurrent = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage + $normalizedDesired = Format-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] if ($normalizedCurrent -ne $normalizedDesired) { $changed = $true } @@ -457,172 +457,172 @@ function New-IdleExchangeOnlineProvider { # Normalize current delegates (case-insensitive) $currentFullAccessUsers = @($currentPerms | - Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | - ForEach-Object { $_.User.ToLowerInvariant() }) - - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "FullAccess current state evaluated for '$mailboxSmtp'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; CurrentUsers = $currentFullAccessUsers } - ) - } - - foreach ($entry in $desiredFullAccess) { - $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentFullAccessUsers -contains $userLower + Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | + ForEach-Object { $_.User.ToLowerInvariant() }) + + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Evaluated', + "FullAccess current state evaluated for '$mailboxSmtp'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; CurrentUsers = $currentFullAccessUsers } + ) + } - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting FullAccess on '$mailboxSmtp' to '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Add' } - ) + foreach ($entry in $desiredFullAccess) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentFullAccessUsers -contains $userLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting FullAccess on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Add' } + ) + } + $this.Adapter.AddMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true } - $this.Adapter.AddMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking FullAccess on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking FullAccess on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'FullAccess'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) + } + $this.Adapter.RemoveMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true } - $this.Adapter.RemoveMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true } } - } - # --- SendAs --- - $desiredSendAs = @($Permissions | Where-Object { $_.Right -eq 'SendAs' }) - if ($desiredSendAs.Count -gt 0) { - $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) - - $currentSendAsTrustees = @($currentRecipientPerms | - Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | - ForEach-Object { $_.Trustee.ToLowerInvariant() }) - - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "SendAs current state evaluated for '$mailboxSmtp'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; CurrentUsers = $currentSendAsTrustees } - ) - } + # --- SendAs --- + $desiredSendAs = @($Permissions | Where-Object { $_.Right -eq 'SendAs' }) + if ($desiredSendAs.Count -gt 0) { + $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) - foreach ($entry in $desiredSendAs) { - $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentSendAsTrustees -contains $trusteeLower + $currentSendAsTrustees = @($currentRecipientPerms | + Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | + ForEach-Object { $_.Trustee.ToLowerInvariant() }) - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting SendAs on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'Provider.ExchangeOnline.Permissions.Evaluated', + "SendAs current state evaluated for '$mailboxSmtp'", 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Add' } + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; CurrentUsers = $currentSendAsTrustees } ) } - $this.Adapter.AddRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking SendAs on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) + + foreach ($entry in $desiredSendAs) { + $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentSendAsTrustees -contains $trusteeLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting SendAs on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Add' } + ) + } + $this.Adapter.AddRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking SendAs on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendAs'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) + } + $this.Adapter.RemoveRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } } - $this.Adapter.RemoveRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) - $changed = $true } - } - } - # --- SendOnBehalf --- - $desiredSendOnBehalf = @($Permissions | Where-Object { $_.Right -eq 'SendOnBehalf' }) - if ($desiredSendOnBehalf.Count -gt 0) { - $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) - $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) - - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Evaluated', - "SendOnBehalf current state evaluated for '$mailboxSmtp'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; CurrentUsers = $currentDelegatesLower } - ) - } - - # Compute desired final list based on Present/Absent entries - $updatedDelegates = [System.Collections.Generic.List[string]]::new() - foreach ($d in $currentDelegates) { $updatedDelegates.Add($d) } - - $sobChanged = $false - foreach ($entry in $desiredSendOnBehalf) { - $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() - $isPresent = $currentDelegatesLower -contains $userLower + # --- SendOnBehalf --- + $desiredSendOnBehalf = @($Permissions | Where-Object { $_.Right -eq 'SendOnBehalf' }) + if ($desiredSendOnBehalf.Count -gt 0) { + $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) + $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) - if ($entry.Ensure -eq 'Present' -and -not $isPresent) { if ($hasEventSink) { $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Granting SendOnBehalf on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'Provider.ExchangeOnline.Permissions.Evaluated', + "SendOnBehalf current state evaluated for '$mailboxSmtp'", 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Add' } + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; CurrentUsers = $currentDelegatesLower } ) } - $updatedDelegates.Add([string]$entry.AssignedUser) - $sobChanged = $true - } - elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Applying', - "Revoking SendOnBehalf on '$mailboxSmtp' from '$($entry.AssignedUser)'", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Remove' } - ) + + # Compute desired final list based on Present/Absent entries + $updatedDelegates = [System.Collections.Generic.List[string]]::new() + foreach ($d in $currentDelegates) { $updatedDelegates.Add($d) } + + $sobChanged = $false + foreach ($entry in $desiredSendOnBehalf) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentDelegatesLower -contains $userLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Granting SendOnBehalf on '$mailboxSmtp' to '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Add' } + ) + } + $updatedDelegates.Add([string]$entry.AssignedUser) + $sobChanged = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Applying', + "Revoking SendOnBehalf on '$mailboxSmtp' from '$($entry.AssignedUser)'", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Right = 'SendOnBehalf'; User = [string]$entry.AssignedUser; Action = 'Remove' } + ) + } + # Remove case-insensitively + $toRemove = $updatedDelegates | Where-Object { $_.ToLowerInvariant() -eq $userLower } + foreach ($r in @($toRemove)) { $updatedDelegates.Remove($r) | Out-Null } + $sobChanged = $true + } + } + + if ($sobChanged) { + $this.Adapter.SetMailboxSendOnBehalf($mailboxSmtp, [string[]]$updatedDelegates, $accessToken) + $changed = $true } - # Remove case-insensitively - $toRemove = $updatedDelegates | Where-Object { $_.ToLowerInvariant() -eq $userLower } - foreach ($r in @($toRemove)) { $updatedDelegates.Remove($r) | Out-Null } - $sobChanged = $true } - } - if ($sobChanged) { - $this.Adapter.SetMailboxSendOnBehalf($mailboxSmtp, [string[]]$updatedDelegates, $accessToken) - $changed = $true - } - } + if ($hasEventSink) { + $null = $this.EventSink.WriteEvent( + 'Provider.ExchangeOnline.Permissions.Result', + "EnsureMailboxPermissions completed for '$mailboxSmtp': Changed=$changed", + 'EnsureMailboxPermissions', + @{ MailboxSmtp = $mailboxSmtp; Changed = $changed } + ) + } - if ($hasEventSink) { - $null = $this.EventSink.WriteEvent( - 'Provider.ExchangeOnline.Permissions.Result', - "EnsureMailboxPermissions completed for '$mailboxSmtp': Changed=$changed", - 'EnsureMailboxPermissions', - @{ MailboxSmtp = $mailboxSmtp; Changed = $changed } - ) - } + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxPermissions' + IdentityKey = $mailboxSmtp + Changed = $changed + } + } -Force - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'EnsureMailboxPermissions' - IdentityKey = $mailboxSmtp - Changed = $changed + return $provider } - } -Force - - return $provider -} diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 5c934912..8152d17f 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -633,14 +633,14 @@ Describe 'ExchangeOnline provider - Unit tests' { } } - Context 'Normalize-IdleExchangeOnlineAutoReplyMessage' { + Context 'Format-IdleExchangeOnlineAutoReplyMessage' { BeforeAll { # Import the private normalization function for direct testing $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent - $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Normalize-IdleExchangeOnlineAutoReplyMessage.ps1' + $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Format-IdleExchangeOnlineAutoReplyMessage.ps1' if (-not (Test-Path -LiteralPath $normalizeFunctionPath -PathType Leaf)) { - throw "Normalize-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" + throw "Format-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" } # Dot-source the private function @@ -649,55 +649,55 @@ Describe 'ExchangeOnline provider - Unit tests' { It 'removes HTML wrappers' { $input = '
Test message
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'Test message
' } It 'normalizes CRLF to LF' { $input = "Line 1`r`nLine 2`r`nLine 3" - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be "Line 1`nLine 2`nLine 3" } It 'trims leading and trailing whitespace' { $input = "Test message
`n`n" - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'Test message
' } It 'normalizes excessive spaces conservatively' { $input = 'Test message here
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input # 3+ spaces become 2 spaces (conservative normalization) $normalized | Should -Be 'Test message here
' } It 'handles empty string input' { - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message '' + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message '' $normalized | Should -Be '' } It 'handles null input' { - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $null + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $null $normalized | Should -Be '' } It 'removes DOCTYPE declarations' { $input = 'Test
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'Test
' } It 'preserves intentional HTML formatting' { $input = 'This is important and contact us.
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'This is important and contact us.
' } From 5c6e585eb7d4688b880c123b43927a6f202ade80 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:01:25 +0100 Subject: [PATCH 3/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 2374cca9..5c7b007d 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -118,7 +118,7 @@ function Invoke-IdlePlanObject { # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries if ($null -ne $planProviders) { $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or - ($planProviders.PSObject -and $planProviders.PSObject.Properties) + ($planProviders.PSObject -and $planProviders.PSObject.Properties) if ($isValidProvider) { $effectiveProviders = $planProviders } From 41d31e6915018bb171e26d2aca0b7cb4a59c3420 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:11:30 +0000 Subject: [PATCH 4/4] Fix EnsureMailboxPermissions indentation and normalize test terminology - Re-apply Invoke-Formatter to New-IdleExchangeOnlineProvider.ps1 to produce zero PSUseConsistentIndentation violations (fixes #2865282671) - Fix Invoke-IdlePlanObject.ps1 line 121 continuation indent to match formatter expectation (no extra 4 spaces on the -or continuation) - Update ExchangeOnlineProvider.Tests.ps1 BeforeAll: rename variable $normalizeFunctionPath -> $formatHelperPath to match file name, and clarify comment with consistent normalization/format terminology Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 2 +- .../Providers/ExchangeOnlineProvider.Tests.ps1 | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 5c7b007d..2374cca9 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -118,7 +118,7 @@ function Invoke-IdlePlanObject { # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries if ($null -ne $planProviders) { $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or - ($planProviders.PSObject -and $planProviders.PSObject.Properties) + ($planProviders.PSObject -and $planProviders.PSObject.Properties) if ($isValidProvider) { $effectiveProviders = $planProviders } diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 8152d17f..aff6119a 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -635,16 +635,17 @@ Describe 'ExchangeOnline provider - Unit tests' { Context 'Format-IdleExchangeOnlineAutoReplyMessage' { BeforeAll { - # Import the private normalization function for direct testing + # Import the private auto-reply message normalization helper for direct testing. + # Named Format-* (approved PS verb) but performs normalization for idempotency comparison. $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent - $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Format-IdleExchangeOnlineAutoReplyMessage.ps1' - - if (-not (Test-Path -LiteralPath $normalizeFunctionPath -PathType Leaf)) { - throw "Format-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" + $formatHelperPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Format-IdleExchangeOnlineAutoReplyMessage.ps1' + + if (-not (Test-Path -LiteralPath $formatHelperPath -PathType Leaf)) { + throw "Format-IdleExchangeOnlineAutoReplyMessage script not found at: $formatHelperPath" } - - # Dot-source the private function - . $normalizeFunctionPath + + # Dot-source the private helper + . $formatHelperPath } It 'removes HTML wrappers' {