From 318e29dde4a40bc5bf018cebe9ccaed6e2ac73b7 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:53:18 -0500 Subject: [PATCH 1/9] Send through every service a request names Send-ScriptMessage put all of -Service into one MessageServiceType and looked the services up as one string, so naming two services threw "not a registered messaging service" before connecting. It now loops over the services in each entry and looks each one up by name (Research_Notes/PowerShell-Language-Behavior.md section 5). It also checks every service's registration and settings before connecting to any, so a bad setting for one service stops the call before a message goes out through another. It connects through the registered handler, as it already sent through one, rather than through Connect-ScriptMessage. No changelog entry: MicrosoftGraph is the only service, so only a -ServiceType entry naming it twice could reach this. --- Research_Notes/Microsoft-Graph-Behavior.md | 8 +- .../PowerShell-Language-Behavior.md | 4 +- ScriptMessage/Public/Send-ScriptMessage.ps1 | 184 +++++++++--------- Tests/Send-ScriptMessage.Tests.ps1 | 39 ++-- Tests/ServiceDispatch.Tests.ps1 | 13 ++ 5 files changed, 144 insertions(+), 104 deletions(-) diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index a07d24b..7529b70 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -222,10 +222,10 @@ managed identity, and environment variable sign-ins, and to `CurrentUser` only f process's connection whatever kind it was. `AuthenticationHelpers.LogoutAsync`, which `Disconnect-MgGraph` calls, clears the in-memory token cache, clears the connection, and deletes `mg.authrecord.json`. -**From source:** `Send-ScriptMessage` runs `Connect-ScriptMessage` before every send, and `Disconnect-MgGraph` -after it only when the configuration's `MgDisconnectWhenDone` is true, so a configuration without the setting -stays connected. `Connect-ScriptMessage_MicrosoftGraph` passes no `-ContextScope`, so every application -authentication type gets `Process` scope. +**From source:** `Send-ScriptMessage` runs `Connect-ScriptMessage_MicrosoftGraph` before every send, and +`Disconnect-MgGraph` after it only when the configuration's `MgDisconnectWhenDone` is true, so a configuration +without the setting stays connected. `Connect-ScriptMessage_MicrosoftGraph` passes no `-ContextScope`, so every +application authentication type gets `Process` scope. **Inference, for `MgDisconnectWhenDone` with application permissions:** diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 63e7d29..04ca1aa 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -136,8 +136,10 @@ With `$Config = [pscustomobject]@{ MicrosoftGraph = 'cfg' }`: A one-element array works as the property name, so code that looks a property up by a variable holding service names works while there is one service and silently returns nothing once there are two. -**From source:** `Send-ScriptMessage` looks up each service's configuration with +**From source:** every release up to `1.1.1` had `Send-ScriptMessage` look up each service's configuration with `$ScriptMessageConfig.$($serviceTypeObj.Service)`, where `Service` is an array holding every requested service. +`Send-ScriptMessage` now loops over the services in each `MessageServiceType` and looks each one up by its +registered name, a single string. ## 6. Measured: without a `process` block, piped input binds only the last item diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index 4b11f7b..1cd9f72 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -287,110 +287,120 @@ function Send-ScriptMessage } } - foreach ($serviceTypeObj in $ServiceType) # TODO: Catch errors once we have multiple services so if one fails the other(s) can still be processed. + # Prepare a send for every service before connecting to any of them, so a service that is not registered or + # not configured stops the call before a message goes out through another one. + $PreparedSends = foreach ($serviceTypeObj in $ServiceType) { - # Look the service up first, so an unregistered one stops the call before any other work. - $Registration = Get-ScriptMessageServiceRegistration -Service ([string]$serviceTypeObj.Service) - - # Take this service's section out of the configuration read above, using the registered name rather - # than the parameter's value: looking a property up by a name held in an array comes back empty. - $ServiceConfig = $ScriptMessageConfig.$($Registration.Name) - if ($null -eq $ServiceConfig) + # A MessageServiceType can name several services, and each one gets its own send. + foreach ($serviceItem in $serviceTypeObj.Service) { - throw "The ScriptMessage configuration file has no `'$($Registration.Name)`' section. Add one for that messaging service, or send from a service the file configures." - } - - # Set the connection parameters. - $ConnectionParameters = @{ - ServiceConfig = $ServiceConfig - } - - # Set default values for anything the caller did not specify. These test the bound parameters rather than - # the values because truthiness cannot tell either parameter apart from an unsupplied one: 'OneOnOne' is - # ChatType enum value 0, and $false is a valid 'IncludeBCCInGroupChat' choice. The results go into - # separate variables because a parameter keeps its type constraint through later assignments, and - # [ChatType] cannot hold the $null of a configuration file that has no 'ChatType' setting. - if ($PSBoundParameters.ContainsKey('ChatType')) - { - $ResolvedChatType = $ChatType - } - else - { - $ResolvedChatType = $ConnectionParameters.ServiceConfig.ChatType - } + $Registration = Get-ScriptMessageServiceRegistration -Service ([string]$serviceItem) - # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value", which is also what an - # unsupplied [Nullable[bool]] holds. The configured value is an opt-in flag, on only when it equals - # $true. Text equals $true only when it reads "true" in any letter case, so anything else, including - # the text "false", leaves BCC recipients out. Keep the setting on the left: a [bool] cast, or $true - # on the left, reads the text "false" as $true. - if ($null -ne $IncludeBCCInGroupChat) - { - [bool]$ResolvedIncludeBCCInGroupChat = $IncludeBCCInGroupChat - } - else - { - [bool]$ResolvedIncludeBCCInGroupChat = ($ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat -eq $true) - } + # Take this service's section out of the configuration read above. + $ServiceConfig = $ScriptMessageConfig.$($Registration.Name) + if ($null -eq $ServiceConfig) + { + throw "The ScriptMessage configuration file has no `'$($Registration.Name)`' section. Add one for that messaging service, or send from a service the file configures." + } - # 'MailType' is resolved only for a send that includes Mail, so a configuration value that only Mail would - # use cannot stop a Chat send. 'Group' applies when neither the parameter nor the configuration file sets - # one, including the empty string the configuration template uses for a setting it leaves unset, so a - # configuration file without the setting sends one message to every recipient. - $ResolvedMailType = $null - if ($serviceTypeObj.Type -contains [MessageType]::Mail) - { - $ConfiguredMailType = $ConnectionParameters.ServiceConfig.MailType - if ($PSBoundParameters.ContainsKey('MailType')) + # Set default values for anything the caller did not specify. These test the bound parameters rather + # than the values because truthiness cannot tell either parameter apart from an unsupplied one: + # 'OneOnOne' is ChatType enum value 0, and $false is a valid 'IncludeBCCInGroupChat' choice. The results + # go into separate variables because a parameter keeps its type constraint through later assignments, + # and [ChatType] cannot hold the $null of a configuration file that has no 'ChatType' setting. + if ($PSBoundParameters.ContainsKey('ChatType')) { - $ResolvedMailType = $MailType + $ResolvedChatType = $ChatType } - elseif ([string]::IsNullOrWhiteSpace($ConfiguredMailType)) + else { - $ResolvedMailType = [MailType]::Group + $ResolvedChatType = $ServiceConfig.ChatType } - elseif ($ConfiguredMailType -in [Enum]::GetNames([MailType])) + + # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value", which is also what an + # unsupplied [Nullable[bool]] holds. The configured value is an opt-in flag, on only when it equals + # $true. Text equals $true only when it reads "true" in any letter case, so anything else, including + # the text "false", leaves BCC recipients out. Keep the setting on the left: a [bool] cast, or $true + # on the left, reads the text "false" as $true. + if ($null -ne $IncludeBCCInGroupChat) { - $ResolvedMailType = [MailType]$ConfiguredMailType + [bool]$ResolvedIncludeBCCInGroupChat = $IncludeBCCInGroupChat } else { - throw "The `'MailType`' setting in the ScriptMessage configuration file must be one of: $([Enum]::GetNames([MailType]) -join ', '). Found `'$ConfiguredMailType`'." + [bool]$ResolvedIncludeBCCInGroupChat = ($ServiceConfig.IncludeBCCInGroupChat -eq $true) } - } - # Connect to the messaging service, if necessary (e.g., API service). - Connect-ScriptMessage -Service $($serviceTypeObj.Service) -ServiceConfig $ServiceConfig -ErrorAction Stop - - $SendMessageParameters = [ordered]@{ - From = $From - ReplyTo = $ReplyTo - To = $To - CC = $CC - BCC = $BCC - SaveToSentItems = $SaveToSentItems - Subject = $Subject - Body = $Body - Attachment = $Attachment - SenderId = $SenderId - Type = $serviceTypeObj.Type - IncludeBCCInGroupChat = $ResolvedIncludeBCCInGroupChat - ServiceConfig = $ServiceConfig - } + # 'MailType' is resolved only for a send that includes Mail, so a configuration value that only Mail + # would use cannot stop a Chat send. 'Group' applies when neither the parameter nor the configuration + # file sets one, including the empty string the configuration template uses for a setting it leaves + # unset, so a configuration file without the setting sends one message to every recipient. + $ResolvedMailType = $null + if ($serviceTypeObj.Type -contains [MessageType]::Mail) + { + $ConfiguredMailType = $ServiceConfig.MailType + if ($PSBoundParameters.ContainsKey('MailType')) + { + $ResolvedMailType = $MailType + } + elseif ([string]::IsNullOrWhiteSpace($ConfiguredMailType)) + { + $ResolvedMailType = [MailType]::Group + } + elseif ($ConfiguredMailType -in [Enum]::GetNames([MailType])) + { + $ResolvedMailType = [MailType]$ConfiguredMailType + } + else + { + throw "The `'MailType`' setting in the ScriptMessage configuration file must be one of: $([Enum]::GetNames([MailType]) -join ', '). Found `'$ConfiguredMailType`'." + } + } - # [ChatType] cannot be bound to $null, nor to the empty string a configuration file uses for a setting - # it leaves unset, so pass it only when there is a real value. A Mail-only send does not need one, and - # the service reports a Chat message that has none. - if (-not [string]::IsNullOrWhiteSpace($ResolvedChatType)) - { - $SendMessageParameters['ChatType'] = $ResolvedChatType - } + $SendMessageParameters = [ordered]@{ + From = $From + ReplyTo = $ReplyTo + To = $To + CC = $CC + BCC = $BCC + SaveToSentItems = $SaveToSentItems + Subject = $Subject + Body = $Body + Attachment = $Attachment + SenderId = $SenderId + Type = $serviceTypeObj.Type + IncludeBCCInGroupChat = $ResolvedIncludeBCCInGroupChat + ServiceConfig = $ServiceConfig + } - if ($null -ne $ResolvedMailType) - { - $SendMessageParameters['MailType'] = $ResolvedMailType + # [ChatType] cannot be bound to $null, nor to the empty string a configuration file uses for a setting + # it leaves unset, so pass it only when there is a real value. A Mail-only send does not need one, and + # the service reports a Chat message that has none. + if (-not [string]::IsNullOrWhiteSpace($ResolvedChatType)) + { + $SendMessageParameters['ChatType'] = $ResolvedChatType + } + + if ($null -ne $ResolvedMailType) + { + $SendMessageParameters['MailType'] = $ResolvedMailType + } + + [PSCustomObject]@{ + Registration = $Registration + ServiceConfig = $ServiceConfig + SendMessageParameters = $SendMessageParameters + } } + } + + foreach ($preparedSend in $PreparedSends) # TODO: Catch errors once we have multiple services so if one fails the other(s) can still be processed. + { + # Connect to the messaging service, if necessary (e.g., API service), through its registered handler, the + # same one Connect-ScriptMessage uses. + & $preparedSend.Registration.ConnectFunction -ServiceConfig $preparedSend.ServiceConfig -ErrorAction Stop - & $Registration.SendFunction @SendMessageParameters + $SendMessageParameters = $preparedSend.SendMessageParameters + & $preparedSend.Registration.SendFunction @SendMessageParameters } } \ No newline at end of file diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index 7a8b0bb..642adf9 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -40,7 +40,7 @@ Describe 'Send-ScriptMessage recipient check' { # MicrosoftGraph is enum value 0, which is false, so compare with $null rather than testing $Service. if ($null -ne $Service) { $Config.$Service } else { $Config } } - Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } Mock -ModuleName ScriptMessage Send-MgUserMail { $true } $MessageArguments = @{ @@ -99,19 +99,19 @@ Describe 'Send-ScriptMessage recipient check' { @{ Name = 'a hashtable with a blank Address'; To = @{ Name = 'A'; Address = '' } } ) { { Send-ScriptMessage @MessageArguments -To $To } | Should -Throw '*at least one parameter value*' - Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph -Times 0 -Exactly } It 'throws when no To, CC, or BCC is given' { { Send-ScriptMessage @MessageArguments } | Should -Throw '*at least one parameter value*' - Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph -Times 0 -Exactly } } } Describe 'Send-ScriptMessage chat message' { BeforeAll { - Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } Mock -ModuleName ScriptMessage Get-ScriptMessageContext { [pscustomobject]@{ Account = 'sender@example.org' @@ -276,7 +276,7 @@ Describe 'Send-ScriptMessage mail attachment' { } if ($null -ne $Service) { $Config.$Service } else { $Config } } - Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } Mock -ModuleName ScriptMessage Send-MgUserMail { $true } $MailArguments = @{ @@ -304,7 +304,7 @@ Describe 'Send-ScriptMessage mail attachment' { Describe 'Send-ScriptMessage mail type' { BeforeAll { - Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } Mock -ModuleName ScriptMessage Send-MgUserMail { $true } $MailArguments = @{ @@ -497,10 +497,27 @@ Describe 'Send-ScriptMessage mail type' { It 'stops a Mail send before connecting, naming the setting' { { Send-ScriptMessage @MailArguments } | Should -Throw "*'MailType' setting*must be one of: OneOnOne, Group*'Individual'*" - Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph -Times 0 -Exactly Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 0 -Exactly } + # Every entry is checked before connecting, so a Chat entry that could be sent goes nowhere when a later + # Mail entry cannot be. + It 'stops a send with several -ServiceType entries before connecting for any of them' { + $Arguments = $MailArguments.Clone() + $Arguments.Remove('Service') + $Arguments.Remove('Type') + $ServiceType = @( + @{ Service = 'MicrosoftGraph'; Type = 'Chat' } + @{ Service = 'MicrosoftGraph'; Type = 'Mail' } + ) + + { Send-ScriptMessage @Arguments -ServiceType $ServiceType } | + Should -Throw "*'MailType' setting*'Individual'*" + Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 0 -Exactly + } + # MailType only affects Mail, so a value only Mail would read must not stop a Chat send. It 'does not stop a Chat send' { $Arguments = $MailArguments.Clone() @@ -517,7 +534,7 @@ Describe 'Send-ScriptMessage mail type' { Describe 'Send-ScriptMessage configuration defaults' { BeforeAll { - Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } Mock -ModuleName ScriptMessage Get-ScriptMessageContext { [pscustomobject]@{ Account = 'sender@example.org' @@ -815,8 +832,8 @@ Describe 'Send-ScriptMessage configuration defaults' { } } - # Send-ScriptMessage reads the file, then hands the service's section to Connect-ScriptMessage and to - # the service, so neither reads it again. Three reads gave a send no single consistent view of its own + # Send-ScriptMessage reads the file, then hands the service's section to the service's connect and send + # handlers, so neither reads it again. Three reads gave a send no single consistent view of its own # settings, because the file can change between them. It 'reads the configuration file once for a Mail and Chat send' { $Arguments = $ChatArguments.Clone() @@ -828,8 +845,6 @@ Describe 'Send-ScriptMessage configuration defaults' { } It 'still reads the file when Connect-ScriptMessage is called on its own' { - Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } - $null = Connect-ScriptMessage -Service MicrosoftGraph Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageConfig -Times 1 -Exactly diff --git a/Tests/ServiceDispatch.Tests.ps1 b/Tests/ServiceDispatch.Tests.ps1 index b45cda5..c81873a 100644 --- a/Tests/ServiceDispatch.Tests.ps1 +++ b/Tests/ServiceDispatch.Tests.ps1 @@ -116,4 +116,17 @@ Describe 'The public cmdlets dispatch through the service table' { $script:StandInCalls | Should -Contain 'Send' } } + + # MicrosoftGraph is the only MessagingService member, so naming it twice is the only way to put two services + # in one -ServiceType entry. -Service removes duplicates, so it cannot do the same. + It 'Send-ScriptMessage connects and sends once for each service one -ServiceType entry names' { + $Result = @(Send-ScriptMessage -ServiceType @{ Service = @('MicrosoftGraph', 'MicrosoftGraph'); Type = 'Mail' } ` + -From 'sender@example.org' -To 'recipient@example.org' -Subject 'Test subject' -Body 'Test body') + + $Result.Count | Should -Be 2 + InModuleScope ScriptMessage { + @($script:StandInCalls | Where-Object { $_ -eq 'Connect' }).Count | Should -Be 2 + @($script:StandInCalls | Where-Object { $_ -eq 'Send' }).Count | Should -Be 2 + } + } } From cb5391b13417fdbd0a7c9ee2f4bf12a1910a4cd2 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:21:32 -0500 Subject: [PATCH 2/9] Resolve a relative MgApp_CertificatePath from the config file's folder A relative path now means the configuration file's folder. A file found only in PowerShell's current location is still used, with a deprecation warning, until the next major version. Settings passed to Connect-ScriptMessage -ServiceConfig carry no file path and keep resolving from the current location. Send-ScriptMessage and Connect-ScriptMessage pass the file's path to the service's connect handler as -ConfigFilePath. Which paths count as relative is in Research_Notes/PowerShell-Language-Behavior.md section 13. The TODO on the send loop now says what a connect failure does to the other services. --- CHANGELOG.md | 5 + .../PowerShell-Language-Behavior.md | 45 +++++- .../Private/Register-ScriptMessageService.ps1 | 3 + .../Public/Connect-ScriptMessage.ps1 | 13 +- ScriptMessage/Public/Send-ScriptMessage.ps1 | 14 +- ScriptMessage/Services/MicrosoftGraph.ps1 | 36 ++++- Tests/Connect-ScriptMessage.Tests.ps1 | 131 ++++++++++++++++++ Tests/ServiceDispatch.Tests.ps1 | 2 +- 8 files changed, 236 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558fabd..c095649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,16 @@ - `Group` sends one email to all of the To, CC, and BCC recipients. - `OneOnOne` sends each To, CC, and BCC recipient a separate email with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once gets one email. - New Parameter: `Get-ScriptMessageConfig -ReturnConfigFilePath` > Adds a `ConfigFilePath` property to the returned settings, holding the full path of the configuration file that was read. +- `MgApp_CertificatePath` can now be relative to the configuration file's folder, so a certificate file kept beside the configuration file can be given as just its name, such as `PrivateKeyCertificate.pfx`. Settings passed to `Connect-ScriptMessage -ServiceConfig` don't come with a configuration file, so a relative path in them is still resolved from PowerShell's current location. ### Changed - Importing ScriptMessage again with `Import-Module -Force`, or removing and importing it, now clears the configuration file path set by `Set-ScriptMessageConfigFilePath`. Call `Set-ScriptMessageConfigFilePath` after importing the module. +### Deprecated + +- Finding a relative `MgApp_CertificatePath` in PowerShell's current location is deprecated. When the file isn't in the configuration file's folder but is in the current location, ScriptMessage still uses it and shows a warning; the next major version will look only in the configuration file's folder. Move the file there, or set `MgApp_CertificatePath` to the file's full path. + ### Fixed - `Send-ScriptMessage` now honors an explicit `-ChatType OneOnOne`. The configuration file's `ChatType` setting was used in its place, so a call asking for one-on-one chats sent a single group chat when that setting was `Group`. An explicit `-ChatType Group` was unaffected. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 04ca1aa..3f4c536 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -112,9 +112,10 @@ replaced by stand-ins inside the module, passed `...\ScriptMessage\Services\Conf function defined in the module's `Services` folder (`MgGraph.ps1`, then `MicrosoftGraph.ps1`), so that setting ran any code written into it, and `$PSScriptRoot` in it meant the module's `Services` folder. `Connect-ScriptMessage_MicrosoftGraph` now expands only `$env:NAME` and `${env:NAME}` in that setting, with a -`-replace` that looks each name up with `[Environment]::GetEnvironmentVariable`, and uses the rest as written. A +`-replace` that looks each name up with `[Environment]::GetEnvironmentVariable`, and expands nothing else. A scriptblock replacement needs PowerShell 6 or later, which is safe there because certificate file -authentication throws before PowerShell 7.4. +authentication throws before PowerShell 7.4. It then resolves a relative result from the configuration file's +folder, as section 13 describes. ### What this does not establish @@ -440,3 +441,43 @@ read the path; `Get-ScriptMessageConfig -ReturnConfigFilePath` is. - Two versions of the module loaded side by side, and `Import-Module -Scope Local`, were not tested. - Other runspaces (jobs, `ForEach-Object -Parallel`) were not tested. + +## 13. Measured: which paths .NET and PowerShell count as absolute + +Measured on **2026-09-23** on Windows 11 (10.0.26200) with PowerShell **7.6.6** only, in a standalone advanced +function with the module not imported. PowerShell's current location was the temporary folder, and `MyCerts:` was +a FileSystem drive created with `New-PSDrive`. + +| Path | `Split-Path -IsAbsolute` | `IsPSAbsolute()` | `[System.IO.Path]::IsPathRooted()` | `IsPathFullyQualified()` | +| --- | --- | --- | --- | --- | +| `app.pfx`, `.\app.pfx`, `..\app.pfx`, `sub\app.pfx` | `False` | `False` | `False` | `False` | +| `C:\Certs\app.pfx` | `True` | `True` | `True` | `True` | +| `C:app.pfx` | `True` | `True` | `True` | `False` | +| `\Certs\app.pfx`, `/Certs/app.pfx` | `False` | `False` | `True` | `False` | +| `\\server\share\app.pfx` | `False` | `False` | `True` | `True` | +| `~\app.pfx` | `False` | `False` | `False` | `False` | +| `MyCerts:\app.pfx`, `Env:\TEMP` | `True` | `True` | `False` | `False` | +| `Microsoft.PowerShell.Core\FileSystem::C:\Certs\app.pfx` | `True` | `True` | `False` | `False` | + +`IsPSAbsolute()` is `$PSCmdlet.SessionState.Path.IsPSAbsolute()`, and it agreed with `Split-Path -IsAbsolute` on +every row. So PowerShell does not count a UNC path, a path from the root of the current drive, or `~` as +absolute, and .NET does not count a PowerShell drive or a provider-qualified path. +`GetUnresolvedProviderPathFromPSPath` resolved `~\app.pfx` to the home folder, not the current location. + +`IsPSAbsolute()` counts anything before a colon as a drive, whether or not that drive exists: it returned `True` +for `Q:\app.pfx`, `NoSuchDrive:\app.pfx`, `cert:app.pfx`, `app.pfx:stream`, and `http://example.com/app.pfx`. It +threw for none of the inputs tried, including an empty string and a single space, which both returned `False`. + +**From source:** `Connect-ScriptMessage_MicrosoftGraph` treats `MgApp_CertificatePath`, after expanding environment +variables, as relative only when `IsPathRooted()` and `IsPSAbsolute()` both return `False` and it does not start +with `~`. When the settings came from a configuration file, it looks for a relative path in that file's folder, +and uses the path as written (so relative to PowerShell's current location) only when the file is missing from the +folder but present in the current location, with a deprecation warning. Settings passed to +`Connect-ScriptMessage -ServiceConfig` carry no file path, so their relative paths are used as written. + +### What this does not establish + +- Windows PowerShell 5.1 was not run. The setting is unused there, since certificate file authentication needs + PowerShell 7.4 or later. +- Linux and macOS were not run, so how `~/app.pfx` or a path with backslashes classifies there is unmeasured. +- `Get-PfxCertificate -FilePath` was never called with any of these paths; only the classification was measured. diff --git a/ScriptMessage/Private/Register-ScriptMessageService.ps1 b/ScriptMessage/Private/Register-ScriptMessageService.ps1 index ca41bc3..5dfa20b 100644 --- a/ScriptMessage/Private/Register-ScriptMessageService.ps1 +++ b/ScriptMessage/Private/Register-ScriptMessageService.ps1 @@ -4,6 +4,9 @@ function Register-ScriptMessageService Called once by each file in Services/ as it is dot-sourced, so the public cmdlets can dispatch to a service without naming it. Handlers are recorded as function NAMES, not as command objects or script blocks: the name is resolved at call time, which is what lets Pester's mocks shadow a handler. + + The connect handler takes -ServiceConfig, the service's section of the configuration file, and + -ConfigFilePath, the full path of that file, which is empty when the settings did not come from a file. #> [CmdletBinding()] diff --git a/ScriptMessage/Public/Connect-ScriptMessage.ps1 b/ScriptMessage/Public/Connect-ScriptMessage.ps1 index 1da54f8..2f4cc66 100644 --- a/ScriptMessage/Public/Connect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Connect-ScriptMessage.ps1 @@ -15,6 +15,7 @@ function Connect-ScriptMessage .PARAMETER ServiceConfig Optional. The messaging service's section of the configuration file, for a caller that has already read it. If not provided, the configuration file is read. + Settings passed here do not carry the configuration file's location, so a relative path in them is resolved from PowerShell's current location rather than from the configuration file's folder. .PARAMETER ReturnConnectionInfo Returns connection information after performing function. @@ -45,14 +46,18 @@ function Connect-ScriptMessage [switch]$ReturnConnectionInfo ) - # Set the connection parameters. A caller that has already read the configuration file passes the service's - # section, which is what keeps one send to a single read of the file. + # Set the connection parameters. A caller that has already read the configuration file can pass the service's + # section, so the file is not read again. Only settings read here come with the file's path, which the service + # uses to resolve a relative path in them. + $ConfigFilePath = $null if (-not $PSBoundParameters.ContainsKey('ServiceConfig')) { - $ServiceConfig = Get-ScriptMessageConfig -Service $Service + $ServiceConfig = Get-ScriptMessageConfig -Service $Service -ReturnConfigFilePath + $ConfigFilePath = $ServiceConfig.ConfigFilePath } $ConnectionParameters = @{ - ServiceConfig = $ServiceConfig + ServiceConfig = $ServiceConfig + ConfigFilePath = $ConfigFilePath } # Connect to the proper service. Each service checks for the modules its allowed message types need. diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index 1cd9f72..6538ae3 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -251,8 +251,9 @@ function Send-ScriptMessage [Nullable[bool]]$IncludeBCCInGroupChat # Nullable so it can be set to $null, meaning "use the configured value" ) - # Set the necessary configuration variables. - $ScriptMessageConfig = Get-ScriptMessageConfig + # Set the necessary configuration variables. The file's path comes with them, so a service can resolve a relative + # path in its settings from the file's folder. + $ScriptMessageConfig = Get-ScriptMessageConfig -ReturnConfigFilePath # Convert recipient types into properly formatted PSObject. $From = ConvertTo-ScriptMessageRecipientObject -Recipient $From # Note that From is NOT an array. There should only be one. @@ -394,11 +395,16 @@ function Send-ScriptMessage } } - foreach ($preparedSend in $PreparedSends) # TODO: Catch errors once we have multiple services so if one fails the other(s) can still be processed. + # Connect and send one service at a time. A service reports a failure while sending in its results, but a failure + # to connect throws and stops the call. + # TODO: Before adding a second service, report a connect failure without stopping the call. As it is, the services + # after the one that failed are not tried, which defeats sending through several for redundancy, and a caller that + # assigns the output loses the results of the ones before it, even though their messages went out. + foreach ($preparedSend in $PreparedSends) { # Connect to the messaging service, if necessary (e.g., API service), through its registered handler, the # same one Connect-ScriptMessage uses. - & $preparedSend.Registration.ConnectFunction -ServiceConfig $preparedSend.ServiceConfig -ErrorAction Stop + & $preparedSend.Registration.ConnectFunction -ServiceConfig $preparedSend.ServiceConfig -ConfigFilePath $ScriptMessageConfig.ConfigFilePath -ErrorAction Stop $SendMessageParameters = $preparedSend.SendMessageParameters & $preparedSend.Registration.SendFunction @SendMessageParameters diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index aaeeae4..ae0fbcd 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -255,7 +255,13 @@ function Connect-ScriptMessage_MicrosoftGraph Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] - [pscustomobject]$ServiceConfig + [pscustomobject]$ServiceConfig, + + # The full path of the configuration file the settings came from, or empty when they did not come from one. + [Parameter( + Mandatory = $false, + ValueFromPipelineByPropertyName = $true)] + [string]$ConfigFilePath ) # Collect the connection settings from the service configuration. @@ -372,11 +378,37 @@ function Connect-ScriptMessage_MicrosoftGraph throw $NewMessage } - # Expand only environment variables, so the configuration file cannot run code; the rest is used as written. + # Expand only environment variables, so the configuration file cannot run code. $MgApp_CertificatePath = $ServiceConfig.MgApp_CertificatePath -replace '\$\{env:([^}]+)\}|\$env:(\w+)', { [Environment]::GetEnvironmentVariable($_.Groups[1].Value + $_.Groups[2].Value) } + # A relative path is resolved from the configuration file's folder. .NET does not count a + # PowerShell drive as absolute and PowerShell does not count a UNC path, so a path is relative + # only when neither does; '~' is the home folder. Settings that did not come from a file keep + # resolving from the current location. + $DriveName = $null + $IsRelativePath = -not ([string]::IsNullOrWhiteSpace($MgApp_CertificatePath) -or + [System.IO.Path]::IsPathRooted($MgApp_CertificatePath) -or + $PSCmdlet.SessionState.Path.IsPSAbsolute($MgApp_CertificatePath, [ref]$DriveName) -or + $MgApp_CertificatePath.StartsWith('~')) + if ($IsRelativePath -and -not [string]::IsNullOrEmpty($ConfigFilePath)) + { + $ConfigFolder = [System.IO.Path]::GetDirectoryName($ConfigFilePath) + $PathInConfigFolder = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($ConfigFolder, $MgApp_CertificatePath)) + + # Deprecated: a file found only relative to the current location is still used, with a + # warning. Remove this fallback in the next major version. + if (-not (Test-Path -LiteralPath $PathInConfigFolder -PathType Leaf) -and (Test-Path -LiteralPath $MgApp_CertificatePath -PathType Leaf)) + { + Write-Warning -Message "The MgApp_CertificatePath file '$MgApp_CertificatePath' was found in PowerShell's current location, not in the configuration file's folder '$ConfigFolder'. Finding a relative path from the current location is deprecated, and the next major version of ScriptMessage will look only in the configuration file's folder. Move the file there, or set MgApp_CertificatePath to the file's full path." + } + else + { + $MgApp_CertificatePath = $PathInConfigFolder + } + } + # Try accessing private key certificate without password using current process credentials. [X509Certificate]$MgApp_Certificate = $null try diff --git a/Tests/Connect-ScriptMessage.Tests.ps1 b/Tests/Connect-ScriptMessage.Tests.ps1 index 64e7d5a..468a534 100644 --- a/Tests/Connect-ScriptMessage.Tests.ps1 +++ b/Tests/Connect-ScriptMessage.Tests.ps1 @@ -108,4 +108,135 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { $FilePath -eq $Expected } } + + Context 'A configuration file with a relative path' { + BeforeAll { + # The configuration file and PowerShell's current location are in different folders, so each test can + # put the certificate file in either one, both, or neither. + $ConfigFolder = Join-Path $TestDrive 'Config' + $CurrentFolder = Join-Path $TestDrive 'Current' + $null = New-Item -ItemType Directory -Path $ConfigFolder, $CurrentFolder + $ConfigFile = Join-Path $ConfigFolder 'config_scriptmessage.json' + Set-ScriptMessageConfigFilePath -Path $ConfigFile + + function Set-TestCertificatePath + { + param([string]$CertificatePath) + @{ + MicrosoftGraph = @{ + AllowableMessageTypes = @('Chat') + MgPermissionType = 'Application' + MgTenantID = 'tenant-id' + MgClientID = 'client-id' + MgApp_AuthenticationType = 'CertificateFile' + MgApp_CertificatePath = $CertificatePath + } + } | ConvertTo-Json -Depth 3 | Set-Content -Path $ConfigFile + } + + Push-Location -Path $CurrentFolder + } + + AfterAll { + Pop-Location + } + + BeforeEach { + Set-TestCertificatePath 'app.pfx' + Remove-Item -Path (Join-Path $ConfigFolder 'app.pfx'), (Join-Path $CurrentFolder 'app.pfx') -ErrorAction SilentlyContinue + } + + It 'opens the file in the configuration file''s folder' { + $null = New-Item -ItemType File -Path (Join-Path $ConfigFolder 'app.pfx') + + Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop -WarningVariable Warnings + + $Warnings | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + } + } + + It 'prefers the configuration file''s folder when the current location has the file too' { + $null = New-Item -ItemType File -Path (Join-Path $ConfigFolder 'app.pfx'), (Join-Path $CurrentFolder 'app.pfx') + + Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop -WarningVariable Warnings + + $Warnings | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + } + } + + It 'still opens a file found only in the current location, with a deprecation warning' { + $null = New-Item -ItemType File -Path (Join-Path $CurrentFolder 'app.pfx') + + Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop -WarningVariable Warnings -WarningAction SilentlyContinue + + @($Warnings).Count | Should -Be 1 + "$Warnings" | Should -BeLike '*current location is deprecated*' + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq 'app.pfx' + } + } + + It 'names the configuration file''s folder when neither has the file' { + Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop -WarningVariable Warnings + + $Warnings | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + } + } + + It 'resolves a path that leaves the configuration file''s folder' { + Set-TestCertificatePath '..\Certs\app.pfx' + + Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop + + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq (Join-Path $TestDrive 'Certs\app.pfx') + } + } + + # .NET does not count a PowerShell drive as absolute, and PowerShell does not count a UNC path or '~'. + It 'uses as written' -ForEach @( + @{ CertificatePath = '~\app.pfx' } + @{ CertificatePath = 'TestDrive:\app.pfx' } + @{ CertificatePath = '\\server.example.com\share\app.pfx' } + ) { + Set-TestCertificatePath $CertificatePath + + Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop + + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq $CertificatePath + } + } + + It 'resolves settings passed to -ServiceConfig from the current location, without a warning' { + $null = New-Item -ItemType File -Path (Join-Path $ConfigFolder 'app.pfx') + $ServiceConfig = Get-ScriptMessageConfig -Service MicrosoftGraph + + Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop -WarningVariable Warnings + + $Warnings | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq 'app.pfx' + } + } + + It 'resolves from the configuration file''s folder when Send-ScriptMessage connects' { + Mock -ModuleName ScriptMessage Send-ScriptMessage_MicrosoftGraph { } + $null = New-Item -ItemType File -Path (Join-Path $ConfigFolder 'app.pfx') + + Send-ScriptMessage -Service MicrosoftGraph -Type Chat -From 'sender@example.org' -To 'recipient@example.org' ` + -Subject 'Test subject' -Body 'Test body' -ErrorAction Stop + + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { + $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + } + Should -Invoke -ModuleName ScriptMessage Send-ScriptMessage_MicrosoftGraph -Times 1 -Exactly + } + } } diff --git a/Tests/ServiceDispatch.Tests.ps1 b/Tests/ServiceDispatch.Tests.ps1 index c81873a..e376e7f 100644 --- a/Tests/ServiceDispatch.Tests.ps1 +++ b/Tests/ServiceDispatch.Tests.ps1 @@ -57,7 +57,7 @@ Describe 'The public cmdlets dispatch through the service table' { } InModuleScope ScriptMessage { - function script:Connect-StandInService { param($ServiceConfig) $script:StandInCalls += 'Connect' } + function script:Connect-StandInService { param($ServiceConfig, $ConfigFilePath) $script:StandInCalls += 'Connect' } function script:Disconnect-StandInService { $script:StandInCalls += 'Disconnect'; [pscustomobject]@{ StandIn = $true } } function script:Get-StandInServiceContext { $script:StandInCalls += 'GetContext'; [pscustomobject]@{ StandIn = $true } } function script:Send-StandInService { $script:StandInCalls += 'Send'; [pscustomobject]@{ StandIn = $true } } From 99ca3ad241f32c6f19bf6a04f799cf280d0d4a22 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:24:00 -0500 Subject: [PATCH 3/9] Record how certificate files load in each edition Windows PowerShell 5.1's Get-PfxCertificate cannot take a password, but X509Certificate2 loads the same .pfx in both editions, and Connect-MgGraph signs in with it. Under PowerShell 7, loading with Get-PfxCertificate or the default key storage leaves the app's private key in the user profile after the process exits, which the module's CertificateFile sign-in does in every release. EphemeralKeySet writes nothing and signs in the same. Also records that -replace with a scriptblock turns the scriptblock into text on 5.1, and that the relative-path checks agree across editions. --- Research_Notes/Certificate-File-Behavior.md | 145 ++++++++++++++++++ .../PowerShell-Language-Behavior.md | 23 ++- 2 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 Research_Notes/Certificate-File-Behavior.md diff --git a/Research_Notes/Certificate-File-Behavior.md b/Research_Notes/Certificate-File-Behavior.md new file mode 100644 index 0000000..30ae124 --- /dev/null +++ b/Research_Notes/Certificate-File-Behavior.md @@ -0,0 +1,145 @@ +# Certificate file behavior + +How PowerShell and .NET load a `.pfx` certificate file, where that matters for `CertificateFile` authentication in +`Connect-ScriptMessage_MicrosoftGraph` (`ScriptMessage/Services/MicrosoftGraph.ps1`). That authentication type +throws before PowerShell 7.4, and this file records what stands in the way of Windows PowerShell 5.1 and what loading +a certificate leaves behind in either edition. + +Measured on **2026-09-23** on Windows 11 (10.0.26200), with Windows PowerShell **5.1.26100.9444** (.NET Framework +4.8.9345.0), PowerShell **7.6.6**, and `Microsoft.Graph.Authentication` **2.25.0**, in standalone scripts. The +certificates were throwaway self-signed RSA 2048 certificates, built in memory with PowerShell 7's +`CertificateRequest` and exported as `.pfx` files with and without a password. No certificate store was written to. +Only section 5 connected to a tenant, with application permissions, and nothing was sent. For part of it, one +throwaway certificate's public key was registered on the app temporarily. + +**Claims are labelled with their evidence.** + +- **Measured** means observed on the date above. +- **From source** means read from a file in this repository, or from a release tag. +- **Unverified** means nobody has tested it. + +> **The one-line summary:** Windows PowerShell 5.1's `Get-PfxCertificate` cannot take a password, but .NET's +> `X509Certificate2` loads the same file in both editions, and `Connect-MgGraph` signs in with it in both. Loading +> normally writes the private key to the user profile, and under PowerShell 7 that file stays behind when the +> process exits, which the module's own sign-in does today. `EphemeralKeySet` writes nothing and signs in the same. + +## 1. Measured: `Get-PfxCertificate` differs by edition + +| Edition | `Get-Command Get-PfxCertificate -Syntax` | +| --- | --- | +| Windows PowerShell 5.1 | `[-FilePath] ` or `-LiteralPath `, nothing else | +| PowerShell 7.6.6 | the same, plus `[-Password ] [-NoPromptForPassword]` | + +In Windows PowerShell 5.1, `Get-PfxCertificate -FilePath` loaded a `.pfx` with no password, with a private key that +could sign. For a `.pfx` with a password it printed `Enter password:` and waited. With `-NonInteractive`, as a +scheduled task runs, it failed instead: "Windows PowerShell is in NonInteractive mode. Read and Prompt functionality is +not available." + +**From source:** `Connect-ScriptMessage_MicrosoftGraph` calls `Get-PfxCertificate -NoPromptForPassword`, then again +with `-Password` from `MgApp_EncryptedCertificatePassword` if that fails, and throws before PowerShell 7.4. Every +release from `1.0.0` to `1.1.1` loads the file with `Get-PfxCertificate`. + +## 2. Measured: loading with `X509Certificate2` works in both editions + +`[System.Security.Cryptography.X509Certificates.X509Certificate2]::new(, , )`, +with PowerShell's location set to the folder holding the files and the process's working directory elsewhere. Both +editions gave the same results unless the table says otherwise. + +| Case | Result | +| --- | --- | +| password file, right password | loads; `HasPrivateKey` is `True`, and the key signs SHA-256 data with PKCS #1 padding | +| password file, no password argument | `CryptographicException`: "The specified network password is not correct." | +| password file, empty `SecureString` | the same | +| password file, wrong password | the same | +| no-password file, no password argument | loads, with a private key | +| no-password file, a password given | `CryptographicException`: "The specified network password is not correct." | +| a file that does not exist | `CryptographicException`: "The system cannot find the file specified." | +| a bare file name, relative to PowerShell's location | the same; the process's working directory is used | +| that name after `$PSCmdlet.GetUnresolvedProviderPathFromPSPath()` | loads | +| a PowerShell drive path, `MyCerts:\with-password.pfx` | 5.1: `NotSupportedException`, "The given path's format is not supported." 7: `CryptographicException`, "The filename, directory name, or volume label syntax is incorrect." | + +So .NET needs a full file system path, the same as `System.IO.File` in section 8 of +[PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md), and it reports a missing password and a wrong +one with the same message. + +## 3. Measured: the private key is written to the user profile while the certificate is loaded + +The folders watched were `%APPDATA%\Microsoft\Crypto\Keys` and `%APPDATA%\Microsoft\Crypto\RSA\`, by file +name only. Every key loaded as `RSACng`, and every file written went to `Keys`. + +| Loader | Edition | While loaded | After `Dispose()` | After the process exits without `Dispose()` | +| --- | --- | --- | --- | --- | +| `X509Certificate2`, `DefaultKeySet` | 7 | one key file | removed | **left behind** (1,735 bytes) | +| `Get-PfxCertificate -Password` | 7 | one key file | removed | **left behind** (1,735 bytes) | +| `X509Certificate2`, `EphemeralKeySet` | 7 | none | none | none written | +| `X509Certificate2`, `DefaultKeySet` | 5.1 | one key file | removed | removed | +| `Get-PfxCertificate`, no-password file | 5.1 | one key file | removed | not tested | +| `X509Certificate2`, `EphemeralKeySet` | 5.1 | none | none | none written | + +With `EphemeralKeySet` the private key still signed in both editions. + +Through the module itself, under PowerShell 7: a child process imported this repository's module, replaced +`Connect-MgGraph` inside the module with a stand-in that kept the certificate it was given, and ran +`Connect-ScriptMessage` with `CertificateFile` settings and an encrypted password. After the process exited, one key +file (1,735 bytes) was left in `Keys`. The key files these runs left behind were deleted afterwards. + +**From source:** `Connect-ScriptMessage_MicrosoftGraph` never disposes the certificate; it passes it to +`Connect-MgGraph -Certificate`. So under PowerShell 7, a script that connects with `CertificateFile` authentication +leaves a copy of the app's private key in the profile of the account that runs it. Section 5 measured this with the +real `Connect-MgGraph` after a successful sign-in, with and without `Disconnect-ScriptMessage`. + +## 4. Measured: `Connect-MgGraph` takes a loaded certificate in both editions + +With `Microsoft.Graph.Authentication` 2.25.0 imported in each edition, and no connection made, `Connect-MgGraph` had +the same three certificate parameters in both: `-Certificate` (`X509Certificate2`), `-CertificateSubjectName`, and +`-CertificateThumbprint`. None takes a file path, so the module has to load the file itself. + +## 5. Measured: a certificate loaded from a file signs in, in both editions + +Each attempt ran in a new process with application permissions. The direct attempts ran +`Connect-MgGraph -TenantId -ClientId -NoWelcome`, then `Get-MgContext`, then `Disconnect-MgGraph`. The module +attempts ran this repository's `Connect-ScriptMessage -ReturnConnectionInfo` with `CertificateFile` settings, an +encrypted password, and the real `Connect-MgGraph`, which is what every release does to load the file. + +The throwaway certificate was tried twice: first while it was not registered on the app, then after its public key +was registered. The first run shows that a sign-in uses the certificate it is given, since Entra rejected it; the +second shows the sign-in itself. As a control, the app's own certificate from `Cert:\CurrentUser\My`, used by +thumbprint, signed in under both editions. + +| Loaded with | Edition | Not registered | Registered | Key file left after the process exited | +| --- | --- | --- | --- | --- | +| the module, `Connect-ScriptMessage` (`Get-PfxCertificate`) | 7 | not run | signed in | **one** | +| the module, then `Disconnect-ScriptMessage` | 7 | not run | signed in | **one** | +| `Get-PfxCertificate -Password` | 7 | `AADSTS700027` | signed in | **one** | +| `X509Certificate2`, `DefaultKeySet` | 7 | `AADSTS700027` | signed in | **one** | +| `X509Certificate2`, `EphemeralKeySet` | 7 | `AADSTS700027` | signed in | none | +| `X509Certificate2`, `DefaultKeySet` | 5.1 | `AADSTS700027` | signed in | none | +| `X509Certificate2`, `EphemeralKeySet` | 5.1 | `AADSTS700027` | signed in | none | + +"Signed in" means `Get-MgContext` reported `AuthType` `AppOnly` and the app's `Mail.Send` role. The key file column +was the same in both runs where both ran, and a key file was left behind whether or not the attempt disconnected. +Every `AADSTS700027` arrived as a `Microsoft.Identity.Client.MsalServiceException`: "The certificate with identifier +used to sign the client assertion is not registered on application. [Reason - The key was not found. ...]". No +attempt failed on the machine. + +Separately, in both editions, a signature over test data made with the private key, loaded with either +`DefaultKeySet` or `EphemeralKeySet`, verified with a copy of the certificate holding only the public key. + +## 6. From source: what else in `Connect-ScriptMessage_MicrosoftGraph` needs PowerShell 7 + +- Expanding environment variables in `MgApp_CertificatePath` uses `-replace` with a scriptblock, which Windows + PowerShell 5.1 turns into text instead of running + ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md) section 4). +- Deciding whether `MgApp_CertificatePath` is relative uses `IsPathRooted()` and `IsPSAbsolute()`, which gave the same + results in both editions ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md) section 13). + +## What this does not establish + +- Only a sign-in was tried, with one certificate and the app's `Mail.Send` role. No message was sent, so a token's use + after the sign-in was not exercised. +- Only one kind of `.pfx` was tried: a key created by .NET, which landed in `Keys` in both editions. A `.pfx` whose + key names a legacy CryptoAPI provider, such as one exported from the Windows certificate store, may be written to + `RSA\` instead. +- PowerShell 7.0 to 7.3 were not run, so why the module requires 7.4 rather than an earlier 7.x is not established. +- Linux and macOS were not run. +- Whether Windows ever removes a key file left behind this way was not examined. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 3f4c536..32db3cc 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -117,11 +117,22 @@ scriptblock replacement needs PowerShell 6 or later, which is safe there because authentication throws before PowerShell 7.4. It then resolves a relative result from the configuration file's folder, as section 13 describes. +The replacement was measured in both editions on **2026-09-23**, expanding `$env:SM_TEST_CERTS\app.pfx` with that +variable set to `C:\Certs` and the pattern `\$\{env:([^}]+)\}|\$env:(\w+)`: + +| Replacement | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| `-replace $Pattern, { ... }`, as the module does | no error; the scriptblock's own text became the replacement, so the result was a space followed by `[Environment]::GetEnvironmentVariable($env:SM_TEST_CERTS\app.pfx.Groups[` and more | `C:\Certs\app.pfx` | +| `[regex]::Replace($Setting, $Pattern, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) ... })` | `C:\Certs\app.pfx` | `C:\Certs\app.pfx` | + +So the module's expansion would give a wrong path under 5.1 without any error. +[Certificate-File-Behavior.md](./Certificate-File-Behavior.md) covers the rest of what certificate file +authentication needs there. + ### What this does not establish - The scope table comes from the stand-in module. Only `$PSScriptRoot` was checked against the real function. -- Windows PowerShell 5.1 was not run. The setting is unused there, since certificate file authentication needs - PowerShell 7.4 or later. +- Only the replacement table above was run under Windows PowerShell 5.1, not the scope table. - **Unverified:** why a calling script's variables are visible under `pwsh -File`. The results fit `-File` running the script in the global scope, but that is inferred, not traced. @@ -468,6 +479,11 @@ absolute, and .NET does not count a PowerShell drive or a provider-qualified pat for `Q:\app.pfx`, `NoSuchDrive:\app.pfx`, `cert:app.pfx`, `app.pfx:stream`, and `http://example.com/app.pfx`. It threw for none of the inputs tried, including an empty string and a single space, which both returned `False`. +Later the same day, `IsPSAbsolute()` and `IsPathRooted()` gave the same results under Windows PowerShell +5.1.26100.9444 as under PowerShell 7 for `app.pfx`, `..\app.pfx`, `C:\Certs\app.pfx`, `C:app.pfx`, `\Certs\app.pfx`, +`\\server\share\app.pfx`, `~\app.pfx`, a FileSystem drive path, the provider-qualified path above, an empty string, +and `NoSuchDrive:\app.pfx`. + **From source:** `Connect-ScriptMessage_MicrosoftGraph` treats `MgApp_CertificatePath`, after expanding environment variables, as relative only when `IsPathRooted()` and `IsPSAbsolute()` both return `False` and it does not start with `~`. When the settings came from a configuration file, it looks for a relative path in that file's folder, @@ -477,7 +493,6 @@ folder but present in the current location, with a deprecation warning. Settings ### What this does not establish -- Windows PowerShell 5.1 was not run. The setting is unused there, since certificate file authentication needs - PowerShell 7.4 or later. +- Under Windows PowerShell 5.1, only `IsPSAbsolute()` and `IsPathRooted()` were run, for the inputs listed above. - Linux and macOS were not run, so how `~/app.pfx` or a path with backslashes classifies there is unmeasured. - `Get-PfxCertificate -FilePath` was never called with any of these paths; only the classification was measured. From 112857473cff9f6af41ddbe244c510b13145c54f Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:40:05 -0500 Subject: [PATCH 4/9] Load certificate files with .NET, in memory, on both editions CertificateFile authentication loaded the .pfx with Get-PfxCertificate, which cannot take a password in Windows PowerShell 5.1, so it required PowerShell 7.4. It now loads the file with X509Certificate2 and EphemeralKeySet in both editions, after resolving the path with PowerShell, and expands environment variables with [regex]::Replace, since 5.1 turns a -replace scriptblock into text. EphemeralKeySet also stops a leak in every release: under PowerShell 7 the default key storage left the app's private key in %APPDATA%\Microsoft\Crypto\Keys after each process exited. A missing certificate file is now reported by its path. Measurements, including sign-ins on both editions with a registered throwaway certificate, are in Research_Notes/Certificate-File-Behavior.md. --- CHANGELOG.md | 3 + Research_Notes/Certificate-File-Behavior.md | 61 ++++--- .../File-Encoding-And-Line-Endings.md | 6 +- .../PowerShell-Language-Behavior.md | 17 +- ScriptMessage/Services/MicrosoftGraph.ps1 | 46 ++--- Tests/Connect-ScriptMessage.Tests.ps1 | 164 +++++++++++++++--- 6 files changed, 224 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c095649..24cc9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - `OneOnOne` sends each To, CC, and BCC recipient a separate email with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once gets one email. - New Parameter: `Get-ScriptMessageConfig -ReturnConfigFilePath` > Adds a `ConfigFilePath` property to the returned settings, holding the full path of the configuration file that was read. - `MgApp_CertificatePath` can now be relative to the configuration file's folder, so a certificate file kept beside the configuration file can be given as just its name, such as `PrivateKeyCertificate.pfx`. Settings passed to `Connect-ScriptMessage -ServiceConfig` don't come with a configuration file, so a relative path in them is still resolved from PowerShell's current location. +- Certificate file authentication, with `MgApp_AuthenticationType` set to `CertificateFile`, now works on Windows PowerShell 5.1. It no longer needs PowerShell 7.4 or later, where `Connect-ScriptMessage` and `Send-ScriptMessage` stopped with "Connecting to Microsoft Graph using a certificate file is only supported with PowerShell version 7.4 and later." ### Changed @@ -28,10 +29,12 @@ - Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank. - Minor: The fourth `Send-ScriptMessage` help example, which sends attachments held in variables, now runs as written. It failed with "Missing closing ')' in subexpression" when copied, and it ended without calling `Send-ScriptMessage`. - Minor: `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` now say why a configuration file could not be used. +- Minor: With `MgApp_AuthenticationType` set to `CertificateFile`, a certificate file that doesn't exist is now reported by its path instead of as a missing password. ### Security - `MgApp_CertificatePath` ran any PowerShell code written into it, so anyone who could edit the configuration file could run commands as the account sending messages. This affected every configuration file on PowerShell 7.4 and later, whatever authentication it was set up for. Only environment variables written as `$env:NAME` or `${env:NAME}` are expanded now, and the rest of the path is used exactly as written. +- With `MgApp_AuthenticationType` set to `CertificateFile`, connecting on PowerShell 7 left a copy of the certificate's private key in the profile of the account running the script, under `%APPDATA%\Microsoft\Crypto\Keys`: one more copy for each PowerShell session that connected, whether or not it disconnected. The private key is now kept only in memory. Copies left by earlier versions stay where they are. --- diff --git a/Research_Notes/Certificate-File-Behavior.md b/Research_Notes/Certificate-File-Behavior.md index 30ae124..db05fb3 100644 --- a/Research_Notes/Certificate-File-Behavior.md +++ b/Research_Notes/Certificate-File-Behavior.md @@ -1,16 +1,16 @@ # Certificate file behavior How PowerShell and .NET load a `.pfx` certificate file, where that matters for `CertificateFile` authentication in -`Connect-ScriptMessage_MicrosoftGraph` (`ScriptMessage/Services/MicrosoftGraph.ps1`). That authentication type -throws before PowerShell 7.4, and this file records what stands in the way of Windows PowerShell 5.1 and what loading -a certificate leaves behind in either edition. +`Connect-ScriptMessage_MicrosoftGraph` (`ScriptMessage/Services/MicrosoftGraph.ps1`). Through `1.1.1` that +authentication type threw before PowerShell 7.4. This file records what stood in the way of Windows PowerShell 5.1, +what loading a certificate leaves behind in either edition, and how the module loads the file now (section 6). Measured on **2026-09-23** on Windows 11 (10.0.26200), with Windows PowerShell **5.1.26100.9444** (.NET Framework 4.8.9345.0), PowerShell **7.6.6**, and `Microsoft.Graph.Authentication` **2.25.0**, in standalone scripts. The certificates were throwaway self-signed RSA 2048 certificates, built in memory with PowerShell 7's `CertificateRequest` and exported as `.pfx` files with and without a password. No certificate store was written to. -Only section 5 connected to a tenant, with application permissions, and nothing was sent. For part of it, one -throwaway certificate's public key was registered on the app temporarily. +Only sections 5 and 6 connected to a tenant, with application permissions, and nothing was sent. For part of +section 5 and all of section 6, one throwaway certificate's public key was registered on the app temporarily. **Claims are labelled with their evidence.** @@ -21,7 +21,8 @@ throwaway certificate's public key was registered on the app temporarily. > **The one-line summary:** Windows PowerShell 5.1's `Get-PfxCertificate` cannot take a password, but .NET's > `X509Certificate2` loads the same file in both editions, and `Connect-MgGraph` signs in with it in both. Loading > normally writes the private key to the user profile, and under PowerShell 7 that file stays behind when the -> process exits, which the module's own sign-in does today. `EphemeralKeySet` writes nothing and signs in the same. +> process exits, as the module's sign-in did through `1.1.1`. `EphemeralKeySet` writes nothing and signs in the +> same. ## 1. Measured: `Get-PfxCertificate` differs by edition @@ -35,9 +36,8 @@ could sign. For a `.pfx` with a password it printed `Enter password:` and waited scheduled task runs, it failed instead: "Windows PowerShell is in NonInteractive mode. Read and Prompt functionality is not available." -**From source:** `Connect-ScriptMessage_MicrosoftGraph` calls `Get-PfxCertificate -NoPromptForPassword`, then again -with `-Password` from `MgApp_EncryptedCertificatePassword` if that fails, and throws before PowerShell 7.4. Every -release from `1.0.0` to `1.1.1` loads the file with `Get-PfxCertificate`. +**From source:** every release from `1.0.0` to `1.1.1` called `Get-PfxCertificate -NoPromptForPassword`, then again +with `-Password` from `MgApp_EncryptedCertificatePassword` if that failed, and threw before PowerShell 7.4. ## 2. Measured: loading with `X509Certificate2` works in both editions @@ -84,9 +84,10 @@ Through the module itself, under PowerShell 7: a child process imported this rep file (1,735 bytes) was left in `Keys`. The key files these runs left behind were deleted afterwards. **From source:** `Connect-ScriptMessage_MicrosoftGraph` never disposes the certificate; it passes it to -`Connect-MgGraph -Certificate`. So under PowerShell 7, a script that connects with `CertificateFile` authentication -leaves a copy of the app's private key in the profile of the account that runs it. Section 5 measured this with the -real `Connect-MgGraph` after a successful sign-in, with and without `Disconnect-ScriptMessage`. +`Connect-MgGraph -Certificate`, which keeps it. So through `1.1.1`, a script that connected with `CertificateFile` +authentication under PowerShell 7 left a copy of the app's private key in the profile of the account that ran it. +Section 5 measured this with the real `Connect-MgGraph` after a successful sign-in, with and without +`Disconnect-ScriptMessage`. ## 4. Measured: `Connect-MgGraph` takes a loaded certificate in both editions @@ -99,7 +100,8 @@ the same three certificate parameters in both: `-Certificate` (`X509Certificate2 Each attempt ran in a new process with application permissions. The direct attempts ran `Connect-MgGraph -TenantId -ClientId -NoWelcome`, then `Get-MgContext`, then `Disconnect-MgGraph`. The module attempts ran this repository's `Connect-ScriptMessage -ReturnConnectionInfo` with `CertificateFile` settings, an -encrypted password, and the real `Connect-MgGraph`, which is what every release does to load the file. +encrypted password, and the real `Connect-MgGraph`, before section 6's change, so they loaded the file as every +release through `1.1.1` did. The throwaway certificate was tried twice: first while it was not registered on the app, then after its public key was registered. The first run shows that a sign-in uses the certificate it is given, since Entra rejected it; the @@ -125,13 +127,31 @@ attempt failed on the machine. Separately, in both editions, a signature over test data made with the private key, loaded with either `DefaultKeySet` or `EphemeralKeySet`, verified with a copy of the certificate holding only the public key. -## 6. From source: what else in `Connect-ScriptMessage_MicrosoftGraph` needs PowerShell 7 +## 6. From source: how the module loads the file now -- Expanding environment variables in `MgApp_CertificatePath` uses `-replace` with a scriptblock, which Windows - PowerShell 5.1 turns into text instead of running - ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md) section 4). -- Deciding whether `MgApp_CertificatePath` is relative uses `IsPathRooted()` and `IsPSAbsolute()`, which gave the same - results in both editions ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md) section 13). +`Connect-ScriptMessage_MicrosoftGraph` has no PowerShell version check for `CertificateFile` authentication. It: + +- Expands environment variables in `MgApp_CertificatePath` with `[regex]::Replace`, because a `-replace` scriptblock + turns into text under Windows PowerShell 5.1 ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md) + section 4). +- Decides whether the path is relative with `IsPathRooted()` and `IsPSAbsolute()`, which gave the same answers in + both editions ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md) section 13). +- Resolves the result with `$PSCmdlet.GetUnresolvedProviderPathFromPSPath()`, since .NET needs a full file system + path (section 2). +- Loads it with `New-Object` for `X509Certificate2` and `EphemeralKeySet`: first with an empty password, which opens a + file that has none, then with `MgApp_EncryptedCertificatePassword`. When the first attempt fails, it reports a file + that does not exist by its path before looking for a password, since .NET gives the same message for a missing + password and a wrong one but names no file when the file is missing. `New-Object` rather than `::new()` lets the + tests mock the load. + +`Tests/Connect-ScriptMessage.Tests.ps1` loads real `.pfx` files, with and without a password, in both editions, and +fails if loading writes a key file to either folder in section 3. A copy of the module that loaded with +`DefaultKeySet` failed those tests in both editions. + +**Measured:** after the change, each edition signed in through the module with the registered throwaway certificate, +each attempt in a new process: once with `Connect-ScriptMessage -ServiceConfig` and the full path, and once from a +configuration file whose `MgApp_CertificatePath` was the file name alone, with PowerShell's current location in +another folder. All four signed in with no warning, and none left a key file after the process exited. ## What this does not establish @@ -140,6 +160,7 @@ Separately, in both editions, a signature over test data made with the private k - Only one kind of `.pfx` was tried: a key created by .NET, which landed in `Keys` in both editions. A `.pfx` whose key names a legacy CryptoAPI provider, such as one exported from the Windows certificate store, may be written to `RSA\` instead. -- PowerShell 7.0 to 7.3 were not run, so why the module requires 7.4 rather than an earlier 7.x is not established. +- PowerShell 7.0 to 7.3 were not run, so why releases through `1.1.1` required 7.4 is not established, and certificate + file authentication was not tried on those versions. - Linux and macOS were not run. - Whether Windows ever removes a key file left behind this way was not examined. diff --git a/Research_Notes/File-Encoding-And-Line-Endings.md b/Research_Notes/File-Encoding-And-Line-Endings.md index ece809d..8ae1153 100644 --- a/Research_Notes/File-Encoding-And-Line-Endings.md +++ b/Research_Notes/File-Encoding-And-Line-Endings.md @@ -165,9 +165,9 @@ code page for a file with no byte order mark. covers UTF-8 with and without a BOM, UTF-16 LE with a BOM, and the legacy code page. - **From source:** the only configuration settings that hold free text are `MgApp_CertificateName` and `MgApp_CertificatePath`; the rest are GUIDs, a thumbprint, booleans, names from a fixed set, and encrypted - strings. `Connect-ScriptMessage_MicrosoftGraph` uses `MgApp_CertificatePath` only for `CertificateFile` - authentication, which throws before PowerShell 7.4, so on 5.1 only `MgApp_CertificateName` can be misread in a - way that matters. On PowerShell 7 both settings can be misread from a legacy code page file. + strings. With the `1.1.0` reader, a misread character mattered in `MgApp_CertificateName` in either edition, but + in `MgApp_CertificatePath` only on PowerShell 7, because `CertificateFile` authentication, the only use of that + setting, needed PowerShell 7.4 through `1.1.1`. **What this does not establish:** diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 32db3cc..7b975fb 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -111,23 +111,22 @@ replaced by stand-ins inside the module, passed `...\ScriptMessage\Services\Conf **From source:** every release from `1.0.0` to `1.1.1` passed `MgApp_CertificatePath` through `ExpandString` in a function defined in the module's `Services` folder (`MgGraph.ps1`, then `MicrosoftGraph.ps1`), so that setting ran any code written into it, and `$PSScriptRoot` in it meant the module's `Services` folder. -`Connect-ScriptMessage_MicrosoftGraph` now expands only `$env:NAME` and `${env:NAME}` in that setting, with a -`-replace` that looks each name up with `[Environment]::GetEnvironmentVariable`, and expands nothing else. A -scriptblock replacement needs PowerShell 6 or later, which is safe there because certificate file -authentication throws before PowerShell 7.4. It then resolves a relative result from the configuration file's -folder, as section 13 describes. +`Connect-ScriptMessage_MicrosoftGraph` now expands only `$env:NAME` and `${env:NAME}` in that setting, with +`[regex]::Replace` and a `MatchEvaluator` that looks each name up with `[Environment]::GetEnvironmentVariable`, and +expands nothing else. It then resolves a relative result from the configuration file's folder, as section 13 +describes. The replacement was measured in both editions on **2026-09-23**, expanding `$env:SM_TEST_CERTS\app.pfx` with that variable set to `C:\Certs` and the pattern `\$\{env:([^}]+)\}|\$env:(\w+)`: | Replacement | Windows PowerShell 5.1 | PowerShell 7 | | --- | --- | --- | -| `-replace $Pattern, { ... }`, as the module does | no error; the scriptblock's own text became the replacement, so the result was a space followed by `[Environment]::GetEnvironmentVariable($env:SM_TEST_CERTS\app.pfx.Groups[` and more | `C:\Certs\app.pfx` | +| `-replace $Pattern, { ... }` | no error; the scriptblock's own text became the replacement, so the result was a space followed by `[Environment]::GetEnvironmentVariable($env:SM_TEST_CERTS\app.pfx.Groups[` and more | `C:\Certs\app.pfx` | | `[regex]::Replace($Setting, $Pattern, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) ... })` | `C:\Certs\app.pfx` | `C:\Certs\app.pfx` | -So the module's expansion would give a wrong path under 5.1 without any error. -[Certificate-File-Behavior.md](./Certificate-File-Behavior.md) covers the rest of what certificate file -authentication needs there. +So a `-replace` scriptblock gives a wrong path under 5.1 without any error. The module used one while certificate +file authentication needed PowerShell 7.4, and switched to `[regex]::Replace` when it stopped needing it. +[Certificate-File-Behavior.md](./Certificate-File-Behavior.md) covers the rest of that change. ### What this does not establish diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index ae0fbcd..c3623d2 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -371,17 +371,13 @@ function Connect-ScriptMessage_MicrosoftGraph switch ($MgApp_AuthenticationType) { CertificateFile { - # This is only supported using PowerShell 7.4 and later because 5.1 is missing the necessary parameters when using 'Get-PfxCertificate'. - if ($PSVersionTable.PSVersion -lt [Version]'7.4') - { - $NewMessage = "Connecting to Microsoft Graph using a certificate file is only supported with PowerShell version 7.4 and later." - throw $NewMessage - } - - # Expand only environment variables, so the configuration file cannot run code. - $MgApp_CertificatePath = $ServiceConfig.MgApp_CertificatePath -replace '\$\{env:([^}]+)\}|\$env:(\w+)', { - [Environment]::GetEnvironmentVariable($_.Groups[1].Value + $_.Groups[2].Value) - } + # Expand only environment variables, so the configuration file cannot run code. This uses + # [regex]::Replace because Windows PowerShell 5.1 turns a -replace scriptblock into text. + $MgApp_CertificatePath = [regex]::Replace([string]$ServiceConfig.MgApp_CertificatePath, '\$\{env:([^}]+)\}|\$env:(\w+)', + [System.Text.RegularExpressions.MatchEvaluator]{ + param($Match) + [Environment]::GetEnvironmentVariable($Match.Groups[1].Value + $Match.Groups[2].Value) + }) # A relative path is resolved from the configuration file's folder. .NET does not count a # PowerShell drive as absolute and PowerShell does not count a UNC path, so a path is relative @@ -409,25 +405,35 @@ function Connect-ScriptMessage_MicrosoftGraph } } - # Try accessing private key certificate without password using current process credentials. - [X509Certificate]$MgApp_Certificate = $null + # Load the file with .NET, because Windows PowerShell 5.1's Get-PfxCertificate cannot take a + # password. .NET resolves a relative path against the process directory and knows nothing of + # PowerShell drives, so it gets the full path. EphemeralKeySet keeps the private key in memory; + # otherwise it is written to the user profile, and PowerShell 7 leaves that file behind. New-Object + # rather than ::new() lets the tests mock the load. See Research_Notes/Certificate-File-Behavior.md. + $MgApp_CertificatePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($MgApp_CertificatePath) + $CertificateType = 'System.Security.Cryptography.X509Certificates.X509Certificate2' + $KeyStorage = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet try { - [X509Certificate]$MgApp_Certificate = Get-PfxCertificate -FilePath $MgApp_CertificatePath -NoPromptForPassword + # A certificate file without a password. + $MgApp_Certificate = New-Object -TypeName $CertificateType -ArgumentList $MgApp_CertificatePath, '', $KeyStorage } - catch # If that doesn't work try the included credentials. + catch # Otherwise use the password from the configuration file. { + if (-not (Test-Path -LiteralPath $MgApp_CertificatePath -PathType Leaf)) + { + throw "The Microsoft Graph certificate file named by MgApp_CertificatePath does not exist: '$MgApp_CertificatePath'." + } + $MgApp_EncryptedCertificatePassword = $ServiceConfig.MgApp_EncryptedCertificatePassword if ([string]::IsNullOrEmpty($MgApp_EncryptedCertificatePassword)) { $NewMessage = "Cannot access Microsoft Graph .pfx private key certificate file and no password has been provided." throw $NewMessage } - else - { - [SecureString]$MgApp_EncryptedCertificateSecureString = $MgApp_EncryptedCertificatePassword | ConvertTo-SecureString # Can only be decrypted by the same AD account on the same computer. - [X509Certificate]$MgApp_Certificate = Get-PfxCertificate -FilePath $MgApp_CertificatePath -NoPromptForPassword -Password $MgApp_EncryptedCertificateSecureString - } + + [SecureString]$MgApp_EncryptedCertificateSecureString = $MgApp_EncryptedCertificatePassword | ConvertTo-SecureString # Can only be decrypted by the same AD account on the same computer. + $MgApp_Certificate = New-Object -TypeName $CertificateType -ArgumentList $MgApp_CertificatePath, $MgApp_EncryptedCertificateSecureString, $KeyStorage } $null = Connect-MgGraph -TenantId $MgTenantID -ClientId $MgClientID -Certificate $MgApp_Certificate diff --git a/Tests/Connect-ScriptMessage.Tests.ps1 b/Tests/Connect-ScriptMessage.Tests.ps1 index 468a534..2b7f77f 100644 --- a/Tests/Connect-ScriptMessage.Tests.ps1 +++ b/Tests/Connect-ScriptMessage.Tests.ps1 @@ -69,14 +69,17 @@ Describe 'Connect-ScriptMessage Microsoft Graph module check' { } } -# Certificate file authentication throws before PowerShell 7.4, so there is no path to check there. -Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PSVersion -lt [version]'7.4') { +# The certificate is loaded with New-Object, so mocking it checks the path without a certificate file. The last Describe +# loads real files. +Describe 'Connect-ScriptMessage certificate file path' { BeforeAll { $env:SCRIPTMESSAGE_TEST_CERTS = 'C:\Certs' Mock -ModuleName ScriptMessage Connect-MgGraph { } Mock -ModuleName ScriptMessage Import-Module { } - Mock -ModuleName ScriptMessage Get-PfxCertificate { } + Mock -ModuleName ScriptMessage New-Object { [pscustomobject]@{ Thumbprint = 'stand-in' } } -ParameterFilter { + $TypeName -eq 'System.Security.Cryptography.X509Certificates.X509Certificate2' + } Mock -ModuleName ScriptMessage Get-Module { $Modules = @( New-Module -Name 'Microsoft.Graph.Authentication' -ScriptBlock { } @@ -106,7 +109,65 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS } Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { $FilePath -eq $Expected } + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { $ArgumentList[0] -eq $Expected } + } + + Context 'Opening the certificate file' { + BeforeAll { + $CertificateFile = Join-Path $TestDrive 'app.pfx' + $null = New-Item -ItemType File -Path $CertificateFile + $ServiceConfig = [pscustomobject]@{ + AllowableMessageTypes = @('Chat') + MgPermissionType = 'Application' + MgTenantID = 'tenant-id' + MgClientID = 'client-id' + MgApp_AuthenticationType = 'CertificateFile' + MgApp_CertificatePath = $CertificateFile + MgApp_EncryptedCertificatePassword = '' + } + } + + # Any other storage option writes the private key to the user profile, and PowerShell 7 leaves it there. + It 'keeps the private key in memory' { + Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop + + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[2] -eq [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + } + } + + Context 'A file that cannot be opened without a password' { + BeforeAll { + Mock -ModuleName ScriptMessage New-Object { throw 'The specified network password is not correct.' } -ParameterFilter { + $TypeName -eq 'System.Security.Cryptography.X509Certificates.X509Certificate2' -and $ArgumentList[1] -is [string] + } + } + + It 'opens it with the password in MgApp_EncryptedCertificatePassword' { + $WithPassword = $ServiceConfig.PSObject.Copy() + $WithPassword.MgApp_EncryptedCertificatePassword = ConvertTo-SecureString -String 'test-password' -AsPlainText -Force | ConvertFrom-SecureString + + Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $WithPassword -ErrorAction Stop + + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { $ArgumentList[1] -is [securestring] } + Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 1 -Exactly + } + + It 'says so when MgApp_EncryptedCertificatePassword is empty' { + { Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop } | + Should -Throw '*no password has been provided*' + Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 0 -Exactly + } + + It 'names a file that does not exist rather than asking for a password' { + $Missing = $ServiceConfig.PSObject.Copy() + $Missing.MgApp_CertificatePath = Join-Path $TestDrive 'missing.pfx' + + { Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $Missing -ErrorAction Stop } | + Should -Throw "*does not exist: '$(Join-Path $TestDrive 'missing.pfx')'*" + Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 0 -Exactly + } + } } Context 'A configuration file with a relative path' { @@ -152,8 +213,8 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop -WarningVariable Warnings $Warnings | Should -BeNullOrEmpty - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $ConfigFolder 'app.pfx') } } @@ -163,8 +224,8 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop -WarningVariable Warnings $Warnings | Should -BeNullOrEmpty - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $ConfigFolder 'app.pfx') } } @@ -175,8 +236,8 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS @($Warnings).Count | Should -Be 1 "$Warnings" | Should -BeLike '*current location is deprecated*' - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq 'app.pfx' + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $CurrentFolder 'app.pfx') } } @@ -184,8 +245,8 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop -WarningVariable Warnings $Warnings | Should -BeNullOrEmpty - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $ConfigFolder 'app.pfx') } } @@ -194,23 +255,24 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq (Join-Path $TestDrive 'Certs\app.pfx') + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $TestDrive 'Certs\app.pfx') } } # .NET does not count a PowerShell drive as absolute, and PowerShell does not count a UNC path or '~'. - It 'uses as written' -ForEach @( + It 'does not look for in the configuration file''s folder' -ForEach @( @{ CertificatePath = '~\app.pfx' } @{ CertificatePath = 'TestDrive:\app.pfx' } @{ CertificatePath = '\\server.example.com\share\app.pfx' } ) { Set-TestCertificatePath $CertificatePath + $Expected = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CertificatePath) Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq $CertificatePath + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq $Expected } } @@ -221,8 +283,8 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop -WarningVariable Warnings $Warnings | Should -BeNullOrEmpty - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq 'app.pfx' + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $CurrentFolder 'app.pfx') } } @@ -233,10 +295,70 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS Send-ScriptMessage -Service MicrosoftGraph -Type Chat -From 'sender@example.org' -To 'recipient@example.org' ` -Subject 'Test subject' -Body 'Test body' -ErrorAction Stop - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { - $FilePath -eq (Join-Path $ConfigFolder 'app.pfx') + Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $ConfigFolder 'app.pfx') } Should -Invoke -ModuleName ScriptMessage Send-ScriptMessage_MicrosoftGraph -Times 1 -Exactly } } } + +# Real certificate files, so the load itself is checked in each edition. +Describe 'Connect-ScriptMessage certificate file loading' { + BeforeAll { + Mock -ModuleName ScriptMessage Connect-MgGraph { } + Mock -ModuleName ScriptMessage Import-Module { } + Mock -ModuleName ScriptMessage Get-Module { + $Modules = @( + New-Module -Name 'Microsoft.Graph.Authentication' -ScriptBlock { } + New-Module -Name 'Microsoft.Graph.Teams' -ScriptBlock { } + ) + if ($Name) { $Modules | Where-Object { $_.Name -in $Name } } else { $Modules } + } + + # A throwaway self-signed certificate, made in memory and saved with and without a password. + $Rsa = [System.Security.Cryptography.RSA]::Create(2048) + $Request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new('CN=ScriptMessage Test', $Rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $TestCertificate = $Request.CreateSelfSigned([DateTimeOffset]::Now.AddMinutes(-5), [DateTimeOffset]::Now.AddHours(1)) + $TestThumbprint = $TestCertificate.Thumbprint + $Pfx = [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx + [System.IO.File]::WriteAllBytes((Join-Path $TestDrive 'with-password.pfx'), $TestCertificate.Export($Pfx, 'test-password')) + [System.IO.File]::WriteAllBytes((Join-Path $TestDrive 'no-password.pfx'), $TestCertificate.Export($Pfx)) + $TestCertificate.Dispose() + $Rsa.Dispose() + $EncryptedPassword = ConvertTo-SecureString -String 'test-password' -AsPlainText -Force | ConvertFrom-SecureString + + # The folders Windows keeps a user's private keys in, so a test can see whether loading wrote one. + $Sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $KeyFolders = @("$env:APPDATA\Microsoft\Crypto\Keys", "$env:APPDATA\Microsoft\Crypto\RSA\$Sid") + function Get-KeyFileNames + { + @(foreach ($Folder in $KeyFolders) { if (Test-Path -LiteralPath $Folder) { Get-ChildItem -LiteralPath $Folder -Force -File | ForEach-Object FullName } }) + } + } + + It 'signs in with , keeping its private key out of the user profile' -ForEach @( + @{ File = 'with-password.pfx'; NeedsPassword = $true } + @{ File = 'no-password.pfx'; NeedsPassword = $false } + ) { + $ServiceConfig = [pscustomobject]@{ + AllowableMessageTypes = @('Chat') + MgPermissionType = 'Application' + MgTenantID = 'tenant-id' + MgClientID = 'client-id' + MgApp_AuthenticationType = 'CertificateFile' + MgApp_CertificatePath = Join-Path $TestDrive $File + MgApp_EncryptedCertificatePassword = $(if ($NeedsPassword) { $EncryptedPassword } else { '' }) + } + $Before = Get-KeyFileNames + + Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop + + # The Connect-MgGraph mock still holds the certificate, so a key file written while loading it would be here. + @(Get-KeyFileNames | Where-Object { $_ -notin $Before }) | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 1 -Exactly -ParameterFilter { + $Certificate.Thumbprint -eq $TestThumbprint -and $Certificate.HasPrivateKey + } + } +} From 446e84b8dd6eeea7a327f2981ee2c95dc7a4d77a Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:58:02 -0500 Subject: [PATCH 5/9] Remove Connect-ScriptMessage -ServiceConfig before it ships The parameter was added on develop so Send-ScriptMessage could hand Connect-ScriptMessage the settings it had already read. Send-ScriptMessage now calls the service's connect handler directly, so nothing used it, and it made a relative MgApp_CertificatePath mean something different when settings were passed in. It was never released, so it goes without a changelog entry. Connect-ScriptMessage again takes exactly the parameters it had in 1.1.1 and always reads the configuration file. The certificate tests now hand settings to the Microsoft Graph connect function directly. --- CHANGELOG.md | 2 +- Research_Notes/Certificate-File-Behavior.md | 7 ++-- .../PowerShell-Language-Behavior.md | 3 +- .../Private/Register-ScriptMessageService.ps1 | 2 +- .../Public/Connect-ScriptMessage.ps1 | 23 +++--------- ScriptMessage/Services/MicrosoftGraph.ps1 | 6 ++-- Tests/Connect-ScriptMessage.Tests.ps1 | 35 +++++++++---------- Tests/Send-ScriptMessage.Tests.ps1 | 2 +- 8 files changed, 32 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24cc9eb..06c0ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - `Group` sends one email to all of the To, CC, and BCC recipients. - `OneOnOne` sends each To, CC, and BCC recipient a separate email with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once gets one email. - New Parameter: `Get-ScriptMessageConfig -ReturnConfigFilePath` > Adds a `ConfigFilePath` property to the returned settings, holding the full path of the configuration file that was read. -- `MgApp_CertificatePath` can now be relative to the configuration file's folder, so a certificate file kept beside the configuration file can be given as just its name, such as `PrivateKeyCertificate.pfx`. Settings passed to `Connect-ScriptMessage -ServiceConfig` don't come with a configuration file, so a relative path in them is still resolved from PowerShell's current location. +- `MgApp_CertificatePath` can now be relative to the configuration file's folder, so a certificate file kept beside the configuration file can be given as just its name, such as `PrivateKeyCertificate.pfx`. - Certificate file authentication, with `MgApp_AuthenticationType` set to `CertificateFile`, now works on Windows PowerShell 5.1. It no longer needs PowerShell 7.4 or later, where `Connect-ScriptMessage` and `Send-ScriptMessage` stopped with "Connecting to Microsoft Graph using a certificate file is only supported with PowerShell version 7.4 and later." ### Changed diff --git a/Research_Notes/Certificate-File-Behavior.md b/Research_Notes/Certificate-File-Behavior.md index db05fb3..a4a58c9 100644 --- a/Research_Notes/Certificate-File-Behavior.md +++ b/Research_Notes/Certificate-File-Behavior.md @@ -149,9 +149,10 @@ fails if loading writes a key file to either folder in section 3. A copy of the `DefaultKeySet` failed those tests in both editions. **Measured:** after the change, each edition signed in through the module with the registered throwaway certificate, -each attempt in a new process: once with `Connect-ScriptMessage -ServiceConfig` and the full path, and once from a -configuration file whose `MgApp_CertificatePath` was the file name alone, with PowerShell's current location in -another folder. All four signed in with no warning, and none left a key file after the process exited. +each attempt in a new process: once with the settings and the full path handed to `Connect-ScriptMessage` directly, +through a `-ServiceConfig` parameter that was removed before release, and once from a configuration file whose +`MgApp_CertificatePath` was the file name alone, with PowerShell's current location in another folder. All four +signed in with no warning, and none left a key file after the process exited. ## What this does not establish diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 7b975fb..a77fd9a 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -487,8 +487,7 @@ and `NoSuchDrive:\app.pfx`. variables, as relative only when `IsPathRooted()` and `IsPSAbsolute()` both return `False` and it does not start with `~`. When the settings came from a configuration file, it looks for a relative path in that file's folder, and uses the path as written (so relative to PowerShell's current location) only when the file is missing from the -folder but present in the current location, with a deprecation warning. Settings passed to -`Connect-ScriptMessage -ServiceConfig` carry no file path, so their relative paths are used as written. +folder but present in the current location, with a deprecation warning. ### What this does not establish diff --git a/ScriptMessage/Private/Register-ScriptMessageService.ps1 b/ScriptMessage/Private/Register-ScriptMessageService.ps1 index 5dfa20b..85bd345 100644 --- a/ScriptMessage/Private/Register-ScriptMessageService.ps1 +++ b/ScriptMessage/Private/Register-ScriptMessageService.ps1 @@ -6,7 +6,7 @@ function Register-ScriptMessageService blocks: the name is resolved at call time, which is what lets Pester's mocks shadow a handler. The connect handler takes -ServiceConfig, the service's section of the configuration file, and - -ConfigFilePath, the full path of that file, which is empty when the settings did not come from a file. + -ConfigFilePath, the full path of that file. #> [CmdletBinding()] diff --git a/ScriptMessage/Public/Connect-ScriptMessage.ps1 b/ScriptMessage/Public/Connect-ScriptMessage.ps1 index 2f4cc66..30ce652 100644 --- a/ScriptMessage/Public/Connect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Connect-ScriptMessage.ps1 @@ -12,10 +12,6 @@ function Connect-ScriptMessage .PARAMETER Service Specify the messaging service to connect to. - .PARAMETER ServiceConfig - Optional. The messaging service's section of the configuration file, for a caller that has already read it. - If not provided, the configuration file is read. - Settings passed here do not carry the configuration file's location, so a relative path in them is resolved from PowerShell's current location rather than from the configuration file's folder. .PARAMETER ReturnConnectionInfo Returns connection information after performing function. @@ -33,11 +29,6 @@ function Connect-ScriptMessage ValueFromPipelineByPropertyName = $true)] [MessagingService]$Service, - [Parameter( - Mandatory = $false, - ValueFromPipelineByPropertyName = $true)] - [pscustomobject]$ServiceConfig, - [parameter( Position=1, Mandatory=$false, @@ -46,18 +37,12 @@ function Connect-ScriptMessage [switch]$ReturnConnectionInfo ) - # Set the connection parameters. A caller that has already read the configuration file can pass the service's - # section, so the file is not read again. Only settings read here come with the file's path, which the service - # uses to resolve a relative path in them. - $ConfigFilePath = $null - if (-not $PSBoundParameters.ContainsKey('ServiceConfig')) - { - $ServiceConfig = Get-ScriptMessageConfig -Service $Service -ReturnConfigFilePath - $ConfigFilePath = $ServiceConfig.ConfigFilePath - } + # Set the connection parameters. The file's path comes with the settings, so the service can resolve a relative + # path in them from the file's folder. + $ServiceConfig = Get-ScriptMessageConfig -Service $Service -ReturnConfigFilePath $ConnectionParameters = @{ ServiceConfig = $ServiceConfig - ConfigFilePath = $ConfigFilePath + ConfigFilePath = $ServiceConfig.ConfigFilePath } # Connect to the proper service. Each service checks for the modules its allowed message types need. diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index c3623d2..8e95b0e 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -257,7 +257,7 @@ function Connect-ScriptMessage_MicrosoftGraph ValueFromPipelineByPropertyName = $true)] [pscustomobject]$ServiceConfig, - # The full path of the configuration file the settings came from, or empty when they did not come from one. + # The full path of the configuration file the settings came from. [Parameter( Mandatory = $false, ValueFromPipelineByPropertyName = $true)] @@ -381,8 +381,8 @@ function Connect-ScriptMessage_MicrosoftGraph # A relative path is resolved from the configuration file's folder. .NET does not count a # PowerShell drive as absolute and PowerShell does not count a UNC path, so a path is relative - # only when neither does; '~' is the home folder. Settings that did not come from a file keep - # resolving from the current location. + # only when neither does; '~' is the home folder. Without a configuration file path, a relative + # path resolves from the current location. $DriveName = $null $IsRelativePath = -not ([string]::IsNullOrWhiteSpace($MgApp_CertificatePath) -or [System.IO.Path]::IsPathRooted($MgApp_CertificatePath) -or diff --git a/Tests/Connect-ScriptMessage.Tests.ps1 b/Tests/Connect-ScriptMessage.Tests.ps1 index 2b7f77f..ed022fb 100644 --- a/Tests/Connect-ScriptMessage.Tests.ps1 +++ b/Tests/Connect-ScriptMessage.Tests.ps1 @@ -10,6 +10,17 @@ BeforeAll { InModuleScope ScriptMessage { function script:Connect-MgGraph { param($Scopes, $TenantId, $ClientId, $Certificate, $CertificateName, $CertificateThumbprint, $ClientSecretCredential) throw 'Connect-MgGraph stand-in called without a mock.' } } + + # Hands settings straight to the Microsoft Graph connect function, for tests of what it does with them. + # Connect-ScriptMessage always reads them from the configuration file. + function Invoke-MicrosoftGraphConnect + { + param([pscustomobject]$ServiceConfig) + InModuleScope ScriptMessage -Parameters @{ ServiceConfig = $ServiceConfig } { + param($ServiceConfig) + Connect-ScriptMessage_MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop + } + } } AfterAll { @@ -108,7 +119,7 @@ Describe 'Connect-ScriptMessage certificate file path' { MgApp_CertificatePath = $Setting } - Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop + Invoke-MicrosoftGraphConnect -ServiceConfig $ServiceConfig Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { $ArgumentList[0] -eq $Expected } } @@ -129,7 +140,7 @@ Describe 'Connect-ScriptMessage certificate file path' { # Any other storage option writes the private key to the user profile, and PowerShell 7 leaves it there. It 'keeps the private key in memory' { - Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop + Invoke-MicrosoftGraphConnect -ServiceConfig $ServiceConfig Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { $ArgumentList[2] -eq [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet @@ -147,14 +158,14 @@ Describe 'Connect-ScriptMessage certificate file path' { $WithPassword = $ServiceConfig.PSObject.Copy() $WithPassword.MgApp_EncryptedCertificatePassword = ConvertTo-SecureString -String 'test-password' -AsPlainText -Force | ConvertFrom-SecureString - Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $WithPassword -ErrorAction Stop + Invoke-MicrosoftGraphConnect -ServiceConfig $WithPassword Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { $ArgumentList[1] -is [securestring] } Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 1 -Exactly } It 'says so when MgApp_EncryptedCertificatePassword is empty' { - { Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop } | + { Invoke-MicrosoftGraphConnect -ServiceConfig $ServiceConfig } | Should -Throw '*no password has been provided*' Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 0 -Exactly } @@ -163,7 +174,7 @@ Describe 'Connect-ScriptMessage certificate file path' { $Missing = $ServiceConfig.PSObject.Copy() $Missing.MgApp_CertificatePath = Join-Path $TestDrive 'missing.pfx' - { Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $Missing -ErrorAction Stop } | + { Invoke-MicrosoftGraphConnect -ServiceConfig $Missing } | Should -Throw "*does not exist: '$(Join-Path $TestDrive 'missing.pfx')'*" Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 0 -Exactly } @@ -276,18 +287,6 @@ Describe 'Connect-ScriptMessage certificate file path' { } } - It 'resolves settings passed to -ServiceConfig from the current location, without a warning' { - $null = New-Item -ItemType File -Path (Join-Path $ConfigFolder 'app.pfx') - $ServiceConfig = Get-ScriptMessageConfig -Service MicrosoftGraph - - Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop -WarningVariable Warnings - - $Warnings | Should -BeNullOrEmpty - Should -Invoke -ModuleName ScriptMessage New-Object -Times 1 -Exactly -ParameterFilter { - $ArgumentList[0] -eq (Join-Path $CurrentFolder 'app.pfx') - } - } - It 'resolves from the configuration file''s folder when Send-ScriptMessage connects' { Mock -ModuleName ScriptMessage Send-ScriptMessage_MicrosoftGraph { } $null = New-Item -ItemType File -Path (Join-Path $ConfigFolder 'app.pfx') @@ -353,7 +352,7 @@ Describe 'Connect-ScriptMessage certificate file loading' { } $Before = Get-KeyFileNames - Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop + Invoke-MicrosoftGraphConnect -ServiceConfig $ServiceConfig # The Connect-MgGraph mock still holds the certificate, so a key file written while loading it would be here. @(Get-KeyFileNames | Where-Object { $_ -notin $Before }) | Should -BeNullOrEmpty diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index 642adf9..b01122c 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -844,7 +844,7 @@ Describe 'Send-ScriptMessage configuration defaults' { Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageConfig -Times 1 -Exactly } - It 'still reads the file when Connect-ScriptMessage is called on its own' { + It 'Connect-ScriptMessage reads the file itself' { $null = Connect-ScriptMessage -Service MicrosoftGraph Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageConfig -Times 1 -Exactly From a922283de19585928607d9298f6c73109b15c8ed Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:02:03 -0500 Subject: [PATCH 6/9] Describe -MailType as what it does, not as a new parameter 1.1.1 already listed -MailType, with help that said it was not implemented, and its sample configuration file had a MailType setting that nothing read, so "New Parameter" was inaccurate. Nobody could have relied on either, so the entry just says what the parameter and the setting do. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c0ef1..da966a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- New Parameter: `Send-ScriptMessage -MailType` > Chooses how an email with more than one recipient is sent. It overrides the configuration file's `MailType` setting, and `Group` is used when neither one sets it. +- `Send-ScriptMessage -MailType` and the configuration file's `MailType` setting choose how an email with more than one recipient is sent. `-MailType` overrides the setting, and `Group` is used when neither one sets it. - `Group` sends one email to all of the To, CC, and BCC recipients. - `OneOnOne` sends each To, CC, and BCC recipient a separate email with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once gets one email. - New Parameter: `Get-ScriptMessageConfig -ReturnConfigFilePath` > Adds a `ConfigFilePath` property to the returned settings, holding the full path of the configuration file that was read. From 62d5fe64aa54e250feeea187f2d924fb2e206317 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:13:47 -0500 Subject: [PATCH 7/9] Record what a caller keeps when a command stops partway A terminating error discards everything the command already wrote to an assignment, while the pipeline, -OutVariable, and an assigned try statement keep it. This is what makes a later service's failed connection lose the results of the services that already sent, which the TODO on Send-ScriptMessage's send loop covers. --- .../PowerShell-Language-Behavior.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index a77fd9a..6d72757 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -494,3 +494,34 @@ folder but present in the current location, with a deprecation warning. - Under Windows PowerShell 5.1, only `IsPSAbsolute()` and `IsPathRooted()` were run, for the inputs listed above. - Linux and macOS were not run, so how `~/app.pfx` or a path with backslashes classifies there is unmeasured. - `Get-PfxCertificate -FilePath` was never called with any of these paths; only the classification was measured. + +## 14. Measured: output written before a terminating error + +Measured on **2026-09-23**, same machine and editions as the header, in a standalone script with the module not +imported. Both editions agreed on every row. Two advanced functions each wrote one object and then stopped: one with +`throw`, and one by calling a function that uses `Write-Error` with `-ErrorAction Stop`, the way `Send-ScriptMessage` +calls a service's connect handler. Both gave the same results. + +| How the caller ran the function | What the caller kept | +| --- | --- | +| `$r = f`, inside `try`/`catch` | nothing; `$r` kept its earlier value | +| `$r = @(f)`, inside `try`/`catch` | nothing | +| `f -OutVariable r` | the object | +| `f \| ForEach-Object { ... }` | the object, processed before the error arrived | +| `$r = try { f } catch { 'caught' }` | the object, followed by what the `catch` block wrote | +| `f -ErrorAction SilentlyContinue` | nothing; the function still stopped | + +So a terminating error discards everything the command wrote to an assignment, while the pipeline, `-OutVariable`, +and an assigned `try` statement keep what came before it. The caller's `-ErrorAction` does not help, because both +functions stop whatever the caller's preference is. + +**From source:** `Send-ScriptMessage` connects and sends one service at a time and calls each connect handler with +`-ErrorAction Stop`. When a later service fails to connect, a script that assigns its output loses the results of +the services that already sent, although their messages went out, and the services after it are not tried. The +TODO on the send loop covers this. Nothing can reach it while `MicrosoftGraph` is the only service. + +### What this does not establish + +- Only `throw` and `Write-Error -ErrorAction Stop` were tried; `$PSCmdlet.ThrowTerminatingError()` and a `trap` + statement were not. +- The functions were called directly, not through `Send-ScriptMessage`, which cannot reach this with one service. From d96011377b26dfe8bee963cfccb99a2ef36ecaf0 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:33:21 -0500 Subject: [PATCH 8/9] Stop Send-ScriptMessage and the switches taking pipeline input No piped input ever sent the intended message: one piped value bound by value to several parameters at once, such as To, CC, BCC, Body, Attachment and SenderId. Send-ScriptMessage's parameters no longer take pipeline input, and its body runs in a process block, so piped input is reported as unbound and sends nothing instead of running the body anyway. The -ReturnConnectionInfo and -ReturnCachedContext switches no longer take pipeline input, and -ReturnConnectionInfo loses Position=1, which made "Connect-ScriptMessage MicrosoftGraph" fail and let a positional $true turn it on. PositionalBinding = $false keeps -Service named-only on Connect-ScriptMessage and Disconnect-ScriptMessage, as in 1.1.1. Tests/ScriptMessage.Module.Tests.ps1 now holds these rules. The measurements, and what by-name pipeline input would need later, are in Research_Notes/PowerShell-Language-Behavior.md section 6. --- CHANGELOG.md | 4 + .../PowerShell-Language-Behavior.md | 64 +++- .../Public/Connect-ScriptMessage.ps1 | 8 +- .../Public/Disconnect-ScriptMessage.ps1 | 8 +- .../Public/Get-ScriptMessageContext.ps1 | 4 +- ScriptMessage/Public/Send-ScriptMessage.ps1 | 334 ++++++++---------- Tests/ScriptMessage.Module.Tests.ps1 | 47 +++ Tests/Send-ScriptMessage.Tests.ps1 | 54 +++ 8 files changed, 328 insertions(+), 195 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da966a4..82e931f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ - Finding a relative `MgApp_CertificatePath` in PowerShell's current location is deprecated. When the file isn't in the configuration file's folder but is in the current location, ScriptMessage still uses it and shows a warning; the next major version will look only in the configuration file's folder. Move the file there, or set `MgApp_CertificatePath` to the file's full path. +### Removed + +- `Send-ScriptMessage` no longer accepts pipeline input, which never sent the intended message: a piped value was bound to several parameters at once, such as the recipients, the sender, and the attachments. Piped input is now reported as an error and nothing is sent. + ### Fixed - `Send-ScriptMessage` now honors an explicit `-ChatType OneOnOne`. The configuration file's `ChatType` setting was used in its place, so a call asking for one-on-one chats sent a single group chat when that setting was `Group`. An explicit `-ChatType Group` was unaffected. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 6d72757..f7737d6 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -152,7 +152,7 @@ names works while there is one service and silently returns nothing once there a `Send-ScriptMessage` now loops over the services in each `MessageServiceType` and looks each one up by its registered name, a single string. -## 6. Measured: without a `process` block, piped input binds only the last item +## 6. Measured: pipeline input, positional binding, and the `process` block Piping `'a','b','c'` into an advanced function whose parameter has `ValueFromPipeline = $true`: @@ -161,9 +161,65 @@ Piping `'a','b','c'` into an advanced function whose parameter has `ValueFromPip | no `process` block | runs once, with `c` | | a `process { }` block | runs three times, with `a`, `b`, and `c` | -**From source:** every function in `ScriptMessage/Public/` declares `ValueFromPipeline = $true` on its -parameters and none has a `process` block, so piping several objects into any of them acts on the last object -only. +The rest of this section was measured on **2026-09-23**, same machine and editions as the header, both agreeing on +every result, in standalone functions or with this module's service handlers and configuration read replaced inside +the module, so nothing connected or sent. + +**Input nothing can bind.** Piping `'x','y'` into a function with no pipeline parameters writes one error per item, +`InputObjectNotBound`: + +| Function | Error preference | Result | +| --- | --- | --- | +| no `process` block | `Continue` | the errors, then the body runs once anyway | +| a `process` block | `Continue` | the errors; the body never runs | +| either | `Stop` | throws `InputObjectNotBound`; the body never runs | + +For a function in a module, the preference that applies is `-ErrorAction` on the call, or else the global +`$ErrorActionPreference`, never a caller's local one: a caller that set `Stop` in its own scope still got a +non-terminating error. `-ErrorVariable` on the call did not collect the error; `2>&1` did. + +**Switches.** A switch that declares `ValueFromPipeline` binds a piped `$true` or `$false`, but not a string. A +`Position` on a switch is honored: a positional `$true` turns it on, and a positional string fails with +`ParameterArgumentTransformationError`. When no parameter declares a `Position`, PowerShell numbers the others from +0 itself, so removing the only declared one made `Connect-ScriptMessage -Service` positional; +`[CmdletBinding(PositionalBinding = $false)]` stops that. + +**What piping into the public functions did through `1.1.1`.** Their parameters were the same as `1.1.1`'s: + +| Call | Result | +| --- | --- | +| a message object (`Service`, `Type`, `From`, `To`, `Subject`, `Body`) piped to `Send-ScriptMessage` | the object bound by value to `To`, `CC`, `BCC`, `Body`, and `Attachment` at once; stopped with "provide at least one ... To, CC, or BCC" | +| an address or file path piped to `Send-ScriptMessage`, with the rest named | a missing file: "Invalid path to attachment"; an existing file reached the service as the attachment and also as `CC`, `BCC`, `ReplyTo`, and `SenderId` | +| an attachment hashtable piped the same way | reached the service with `SenderId` set to the text `System.Collections.Hashtable` | +| `$true` or `$false` piped the same way | stopped with "Unexpected attachment object type." | +| `Connect-ScriptMessage MicrosoftGraph` | failed: the switch at position 1 took the service name | +| `$true \| Connect-ScriptMessage -Service MicrosoftGraph`, or a positional `$true` | returned connection information; `Disconnect-ScriptMessage` behaved the same | +| `'MicrosoftGraph' \| Get-ScriptMessageConfig` | tried to read a file named `MicrosoftGraph` | + +**Pipeline input by property name, for a possible later `Send-ScriptMessage`.** A stand-in with `Send-ScriptMessage`'s +parameter types, every one taking pipeline input by property name only, and a `process` block, sent one message +per piped object, took what an object lacked from named parameters, and left a named-only call unchanged. Two things +carried over from one piped object to the next, as another project's notes had reported: + +- `$PSBoundParameters` keeps the keys of earlier objects, so `ContainsKey()` answers for an earlier object. +- A value the body writes back into a parameter variable stays for the next object, unless that object supplies + the parameter or the previous object supplied it from the pipeline; either resets it. + +**From source:** `Send-ScriptMessage` takes no pipeline input and runs its body in a `process` block, so piped input +sends nothing. No switch takes pipeline input or a position, and `Connect-ScriptMessage` and +`Disconnect-ScriptMessage` set `PositionalBinding = $false`. `Tests/ScriptMessage.Module.Tests.ps1` holds those +rules. `-Service` on `Connect-ScriptMessage`, `Disconnect-ScriptMessage` and `Get-ScriptMessageContext`, `-Path` and +`-Service` on `Get-ScriptMessageConfig`, and `-Path` on `Set-ScriptMessageConfigFilePath` still take pipeline input +with no `process` block, so the last piped object wins; each acts on one service or one path. Before +`Send-ScriptMessage` takes pipeline input, its body must stop writing back into `$From`, `$ReplyTo`, `$To`, `$CC`, +`$BCC`, `$Body`, `$Attachment`, `$Service`, `$Type`, and `$ServiceType`, and stop reading `$PSBoundParameters` for +`ChatType` and `MailType`. + +### What this does not establish + +- Whether any script piped into these functions. Nothing in the help, README, or wiki shows it. +- What Microsoft Graph did with the piped hashtable's sender; nothing was sent. +- Only strings, one kind of object, a hashtable, and booleans were piped. ## 7. Measured: `System.Web.HttpUtility` and URL encoding diff --git a/ScriptMessage/Public/Connect-ScriptMessage.ps1 b/ScriptMessage/Public/Connect-ScriptMessage.ps1 index 30ce652..4b84109 100644 --- a/ScriptMessage/Public/Connect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Connect-ScriptMessage.ps1 @@ -21,7 +21,8 @@ function Connect-ScriptMessage Connect-ScriptMessage -Service MicrosoftGraph -ReturnConnectionInfo #> - [CmdletBinding()] + # Named parameters only. With no Position declared anywhere, PowerShell would otherwise make -Service positional. + [CmdletBinding(PositionalBinding = $false)] param( [Parameter( Mandatory = $true, @@ -30,10 +31,7 @@ function Connect-ScriptMessage [MessagingService]$Service, [parameter( - Position=1, - Mandatory=$false, - ValueFromPipeline=$true, - ValueFromPipelineByPropertyName=$true)] + Mandatory=$false)] [switch]$ReturnConnectionInfo ) diff --git a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 index 598d216..292fcad 100644 --- a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 @@ -21,7 +21,8 @@ function Disconnect-ScriptMessage Disconnect-ScriptMessage -Service MicrosoftGraph -ReturnConnectionInfo #> - [CmdletBinding()] + # Named parameters only. With no Position declared anywhere, PowerShell would otherwise make -Service positional. + [CmdletBinding(PositionalBinding = $false)] param( [Parameter( Mandatory = $true, @@ -30,10 +31,7 @@ function Disconnect-ScriptMessage [MessagingService]$Service, [parameter( - Position=1, - Mandatory=$false, - ValueFromPipeline=$true, - ValueFromPipelineByPropertyName=$true)] + Mandatory=$false)] [switch]$ReturnConnectionInfo ) diff --git a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 index c9a3d12..539b4f8 100644 --- a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 @@ -32,9 +32,7 @@ function Get-ScriptMessageContext [MessagingService]$Service, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [Switch]$ReturnCachedContext ) diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index 6538ae3..8f178cc 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -149,264 +149,242 @@ function Send-ScriptMessage param( [Parameter( ParameterSetName = 'ServiceAndTypeSeparate', - Mandatory = $true, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $true)] [MessagingService[]]$Service, [Parameter( ParameterSetName = 'ServiceAndTypeSeparate', - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [MessageType[]]$Type = 'Mail', [Parameter( ParameterSetName = 'ServiceAndTypeCombined', - Mandatory = $true, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $true)] [MessageServiceType[]]$ServiceType, [Parameter( - Mandatory = $true, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $true)] [pscustomobject]$From, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [AllowEmptyString()] [pscustomobject]$ReplyTo, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [AllowEmptyString()] [pscustomobject]$To, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [AllowEmptyString()] [pscustomobject]$CC, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [AllowEmptyString()] [pscustomobject]$BCC, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [bool]$SaveToSentItems = $true, [Parameter( - Mandatory = $true, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $true)] [string]$Subject, [Parameter( - Mandatory = $true, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $true)] [pscustomobject]$Body, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [array]$Attachment, # Array of Content (bytes), File paths, and/or Directory paths, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [string]$SenderId, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [MailType]$MailType, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [ChatType]$ChatType, [Parameter( - Mandatory = $false, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] + Mandatory = $false)] [Nullable[bool]]$IncludeBCCInGroupChat # Nullable so it can be set to $null, meaning "use the configured value" ) - # Set the necessary configuration variables. The file's path comes with them, so a service can resolve a relative - # path in its settings from the file's folder. - $ScriptMessageConfig = Get-ScriptMessageConfig -ReturnConfigFilePath - - # Convert recipient types into properly formatted PSObject. - $From = ConvertTo-ScriptMessageRecipientObject -Recipient $From # Note that From is NOT an array. There should only be one. - [array]$ReplyTo = ConvertTo-ScriptMessageRecipientObject -Recipient $ReplyTo - [array]$To = ConvertTo-ScriptMessageRecipientObject -Recipient $To - [array]$CC = ConvertTo-ScriptMessageRecipientObject -Recipient $CC - [array]$BCC = ConvertTo-ScriptMessageRecipientObject -Recipient $BCC - - # Make sure that at least one To, CC, or BCC recipient has an address. - $RecipientsWithAddress = @(@($To) + @($CC) + @($BCC) | Where-Object {($null -ne $_) -and (-not [string]::IsNullOrWhiteSpace($_.AddressObj))}) - if ($RecipientsWithAddress.Count -eq 0) + # No parameter takes pipeline input, so piped input is reported as unbound and the process block never runs for + # it. Without the block, the body would run anyway and send the message the named parameters describe. It also + # runs once per piped object if pipeline input is ever added, and then this body must stop writing its results + # back into parameter variables and reading $PSBoundParameters, since both would carry one object's values into + # the next. See section 6 of Research_Notes/PowerShell-Language-Behavior.md. + process { - throw 'Please provide at least one parameter value for any of the following: To, CC, or BCC' - } - - # Convert body into properly formatted PSObject. - $Body = ConvertTo-ScriptMessageBodyObject -Body $Body + # Set the necessary configuration variables. The file's path comes with them, so a service can resolve a + # relative path in its settings from the file's folder. + $ScriptMessageConfig = Get-ScriptMessageConfig -ReturnConfigFilePath + + # Convert recipient types into properly formatted PSObject. + $From = ConvertTo-ScriptMessageRecipientObject -Recipient $From # Note that From is NOT an array. There should only be one. + [array]$ReplyTo = ConvertTo-ScriptMessageRecipientObject -Recipient $ReplyTo + [array]$To = ConvertTo-ScriptMessageRecipientObject -Recipient $To + [array]$CC = ConvertTo-ScriptMessageRecipientObject -Recipient $CC + [array]$BCC = ConvertTo-ScriptMessageRecipientObject -Recipient $BCC + + # Make sure that at least one To, CC, or BCC recipient has an address. + $RecipientsWithAddress = @(@($To) + @($CC) + @($BCC) | Where-Object {($null -ne $_) -and (-not [string]::IsNullOrWhiteSpace($_.AddressObj))}) + if ($RecipientsWithAddress.Count -eq 0) + { + throw 'Please provide at least one parameter value for any of the following: To, CC, or BCC' + } - # Convert attachments into properly formatted PSObject. - $Attachment = ConvertTo-ScriptMessageAttachmentObject -Attachment $Attachment + # Convert body into properly formatted PSObject. + $Body = ConvertTo-ScriptMessageBodyObject -Body $Body - if ($null -ne $Service) # If ServiceAndTypeSeparate - { - # Remove message service & message type duplicates. - $Service = $Service | Select-Object -Unique - $Type = $Type | Select-Object -Unique - - # Create the ServiceType class object\hash. - [MessageServiceType]$ServiceType = @{ - Service = $Service - Type = $Type - } - } + # Convert attachments into properly formatted PSObject. + $Attachment = ConvertTo-ScriptMessageAttachmentObject -Attachment $Attachment - # Prepare a send for every service before connecting to any of them, so a service that is not registered or - # not configured stops the call before a message goes out through another one. - $PreparedSends = foreach ($serviceTypeObj in $ServiceType) - { - # A MessageServiceType can name several services, and each one gets its own send. - foreach ($serviceItem in $serviceTypeObj.Service) + if ($null -ne $Service) # If ServiceAndTypeSeparate { - $Registration = Get-ScriptMessageServiceRegistration -Service ([string]$serviceItem) - - # Take this service's section out of the configuration read above. - $ServiceConfig = $ScriptMessageConfig.$($Registration.Name) - if ($null -eq $ServiceConfig) - { - throw "The ScriptMessage configuration file has no `'$($Registration.Name)`' section. Add one for that messaging service, or send from a service the file configures." + # Remove message service & message type duplicates. + $Service = $Service | Select-Object -Unique + $Type = $Type | Select-Object -Unique + + # Create the ServiceType class object\hash. + [MessageServiceType]$ServiceType = @{ + Service = $Service + Type = $Type } + } - # Set default values for anything the caller did not specify. These test the bound parameters rather - # than the values because truthiness cannot tell either parameter apart from an unsupplied one: - # 'OneOnOne' is ChatType enum value 0, and $false is a valid 'IncludeBCCInGroupChat' choice. The results - # go into separate variables because a parameter keeps its type constraint through later assignments, - # and [ChatType] cannot hold the $null of a configuration file that has no 'ChatType' setting. - if ($PSBoundParameters.ContainsKey('ChatType')) - { - $ResolvedChatType = $ChatType - } - else + # Prepare a send for every service before connecting to any of them, so a service that is not registered or + # not configured stops the call before a message goes out through another one. + $PreparedSends = foreach ($serviceTypeObj in $ServiceType) + { + # A MessageServiceType can name several services, and each one gets its own send. + foreach ($serviceItem in $serviceTypeObj.Service) { - $ResolvedChatType = $ServiceConfig.ChatType - } + $Registration = Get-ScriptMessageServiceRegistration -Service ([string]$serviceItem) - # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value", which is also what an - # unsupplied [Nullable[bool]] holds. The configured value is an opt-in flag, on only when it equals - # $true. Text equals $true only when it reads "true" in any letter case, so anything else, including - # the text "false", leaves BCC recipients out. Keep the setting on the left: a [bool] cast, or $true - # on the left, reads the text "false" as $true. - if ($null -ne $IncludeBCCInGroupChat) - { - [bool]$ResolvedIncludeBCCInGroupChat = $IncludeBCCInGroupChat - } - else - { - [bool]$ResolvedIncludeBCCInGroupChat = ($ServiceConfig.IncludeBCCInGroupChat -eq $true) - } + # Take this service's section out of the configuration read above. + $ServiceConfig = $ScriptMessageConfig.$($Registration.Name) + if ($null -eq $ServiceConfig) + { + throw "The ScriptMessage configuration file has no `'$($Registration.Name)`' section. Add one for that messaging service, or send from a service the file configures." + } - # 'MailType' is resolved only for a send that includes Mail, so a configuration value that only Mail - # would use cannot stop a Chat send. 'Group' applies when neither the parameter nor the configuration - # file sets one, including the empty string the configuration template uses for a setting it leaves - # unset, so a configuration file without the setting sends one message to every recipient. - $ResolvedMailType = $null - if ($serviceTypeObj.Type -contains [MessageType]::Mail) - { - $ConfiguredMailType = $ServiceConfig.MailType - if ($PSBoundParameters.ContainsKey('MailType')) + # Set default values for anything the caller did not specify. These test the bound parameters rather + # than the values because truthiness cannot tell either parameter apart from an unsupplied one: + # 'OneOnOne' is ChatType enum value 0, and $false is a valid 'IncludeBCCInGroupChat' choice. The + # results go into separate variables because a parameter keeps its type constraint through later + # assignments, and [ChatType] cannot hold the $null of a configuration file that has no 'ChatType' + # setting. + if ($PSBoundParameters.ContainsKey('ChatType')) { - $ResolvedMailType = $MailType + $ResolvedChatType = $ChatType } - elseif ([string]::IsNullOrWhiteSpace($ConfiguredMailType)) + else { - $ResolvedMailType = [MailType]::Group + $ResolvedChatType = $ServiceConfig.ChatType } - elseif ($ConfiguredMailType -in [Enum]::GetNames([MailType])) + + # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value", which is also what an + # unsupplied [Nullable[bool]] holds. The configured value is an opt-in flag, on only when it equals + # $true. Text equals $true only when it reads "true" in any letter case, so anything else, including + # the text "false", leaves BCC recipients out. Keep the setting on the left: a [bool] cast, or $true + # on the left, reads the text "false" as $true. + if ($null -ne $IncludeBCCInGroupChat) { - $ResolvedMailType = [MailType]$ConfiguredMailType + [bool]$ResolvedIncludeBCCInGroupChat = $IncludeBCCInGroupChat } else { - throw "The `'MailType`' setting in the ScriptMessage configuration file must be one of: $([Enum]::GetNames([MailType]) -join ', '). Found `'$ConfiguredMailType`'." + [bool]$ResolvedIncludeBCCInGroupChat = ($ServiceConfig.IncludeBCCInGroupChat -eq $true) } - } - $SendMessageParameters = [ordered]@{ - From = $From - ReplyTo = $ReplyTo - To = $To - CC = $CC - BCC = $BCC - SaveToSentItems = $SaveToSentItems - Subject = $Subject - Body = $Body - Attachment = $Attachment - SenderId = $SenderId - Type = $serviceTypeObj.Type - IncludeBCCInGroupChat = $ResolvedIncludeBCCInGroupChat - ServiceConfig = $ServiceConfig - } + # 'MailType' is resolved only for a send that includes Mail, so a configuration value that only Mail + # would use cannot stop a Chat send. 'Group' applies when neither the parameter nor the configuration + # file sets one, including the empty string the configuration template uses for a setting it leaves + # unset, so a configuration file without the setting sends one message to every recipient. + $ResolvedMailType = $null + if ($serviceTypeObj.Type -contains [MessageType]::Mail) + { + $ConfiguredMailType = $ServiceConfig.MailType + if ($PSBoundParameters.ContainsKey('MailType')) + { + $ResolvedMailType = $MailType + } + elseif ([string]::IsNullOrWhiteSpace($ConfiguredMailType)) + { + $ResolvedMailType = [MailType]::Group + } + elseif ($ConfiguredMailType -in [Enum]::GetNames([MailType])) + { + $ResolvedMailType = [MailType]$ConfiguredMailType + } + else + { + throw "The `'MailType`' setting in the ScriptMessage configuration file must be one of: $([Enum]::GetNames([MailType]) -join ', '). Found `'$ConfiguredMailType`'." + } + } - # [ChatType] cannot be bound to $null, nor to the empty string a configuration file uses for a setting - # it leaves unset, so pass it only when there is a real value. A Mail-only send does not need one, and - # the service reports a Chat message that has none. - if (-not [string]::IsNullOrWhiteSpace($ResolvedChatType)) - { - $SendMessageParameters['ChatType'] = $ResolvedChatType - } + $SendMessageParameters = [ordered]@{ + From = $From + ReplyTo = $ReplyTo + To = $To + CC = $CC + BCC = $BCC + SaveToSentItems = $SaveToSentItems + Subject = $Subject + Body = $Body + Attachment = $Attachment + SenderId = $SenderId + Type = $serviceTypeObj.Type + IncludeBCCInGroupChat = $ResolvedIncludeBCCInGroupChat + ServiceConfig = $ServiceConfig + } - if ($null -ne $ResolvedMailType) - { - $SendMessageParameters['MailType'] = $ResolvedMailType - } + # [ChatType] cannot be bound to $null, nor to the empty string a configuration file uses for a + # setting it leaves unset, so pass it only when there is a real value. A Mail-only send does not need + # one, and the service reports a Chat message that has none. + if (-not [string]::IsNullOrWhiteSpace($ResolvedChatType)) + { + $SendMessageParameters['ChatType'] = $ResolvedChatType + } + + if ($null -ne $ResolvedMailType) + { + $SendMessageParameters['MailType'] = $ResolvedMailType + } - [PSCustomObject]@{ - Registration = $Registration - ServiceConfig = $ServiceConfig - SendMessageParameters = $SendMessageParameters + [PSCustomObject]@{ + Registration = $Registration + ServiceConfig = $ServiceConfig + SendMessageParameters = $SendMessageParameters + } } } - } - # Connect and send one service at a time. A service reports a failure while sending in its results, but a failure - # to connect throws and stops the call. - # TODO: Before adding a second service, report a connect failure without stopping the call. As it is, the services - # after the one that failed are not tried, which defeats sending through several for redundancy, and a caller that - # assigns the output loses the results of the ones before it, even though their messages went out. - foreach ($preparedSend in $PreparedSends) - { - # Connect to the messaging service, if necessary (e.g., API service), through its registered handler, the - # same one Connect-ScriptMessage uses. - & $preparedSend.Registration.ConnectFunction -ServiceConfig $preparedSend.ServiceConfig -ConfigFilePath $ScriptMessageConfig.ConfigFilePath -ErrorAction Stop + # Connect and send one service at a time. A service reports a failure while sending in its results, but a + # failure to connect throws and stops the call. + # TODO: Before adding a second service, report a connect failure without stopping the call. As it is, the + # services after the one that failed are not tried, which defeats sending through several for redundancy, and + # a caller that assigns the output loses the results of the ones before it, even though their messages went + # out. + foreach ($preparedSend in $PreparedSends) + { + # Connect to the messaging service, if necessary (e.g., API service), through its registered handler, the + # same one Connect-ScriptMessage uses. + & $preparedSend.Registration.ConnectFunction -ServiceConfig $preparedSend.ServiceConfig -ConfigFilePath $ScriptMessageConfig.ConfigFilePath -ErrorAction Stop - $SendMessageParameters = $preparedSend.SendMessageParameters - & $preparedSend.Registration.SendFunction @SendMessageParameters + $SendMessageParameters = $preparedSend.SendMessageParameters + & $preparedSend.Registration.SendFunction @SendMessageParameters + } } } \ No newline at end of file diff --git a/Tests/ScriptMessage.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 index 2bdcd17..d839592 100644 --- a/Tests/ScriptMessage.Module.Tests.ps1 +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -88,3 +88,50 @@ Describe 'ScriptMessage module' { @($Findings | ForEach-Object { '{0}:{1} {2}' -f $_.ScriptName, $_.Line, $_.RuleName }) | Should -BeNullOrEmpty } } + +# Rules for the public parameters, so breaking one takes a deliberate change here. Section 6 of +# Research_Notes/PowerShell-Language-Behavior.md has the measurements behind them. +Describe 'ScriptMessage public parameters' { + BeforeAll { + Import-Module $ManifestPath -Force + $Common = [System.Management.Automation.PSCmdlet]::CommonParameters + [System.Management.Automation.PSCmdlet]::OptionalCommonParameters + $PublicParameters = foreach ($Command in (Get-Command -Module ScriptMessage)) + { + foreach ($Parameter in ($Command.Parameters.Values | Where-Object { $_.Name -notin $Common })) + { + foreach ($Attribute in $Parameter.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] }) + { + [pscustomobject]@{ + Name = "$($Command.Name) -$($Parameter.Name)" + IsSwitch = $Parameter.SwitchParameter + Pipeline = $Attribute.ValueFromPipeline -or $Attribute.ValueFromPipelineByPropertyName + Positional = $Command.Parameters[$Parameter.Name].ParameterSets.Values.Position -ne [int]::MinValue + } + } + } + } + } + + AfterAll { + Remove-Module ScriptMessage -Force -ErrorAction SilentlyContinue + } + + # A piped or positional $true or $false binds to a switch that allows it, so a switch never does. + It 'gives no switch pipeline input or a position' { + @($PublicParameters | Where-Object { $_.IsSwitch -and ($_.Pipeline -or $_.Positional) } | ForEach-Object Name) | + Should -BeNullOrEmpty + } + + # One piped object used to bind to several parameters at once. Taking pipeline input by property name, which + # can be added later, needs the process block to stop reusing values from one piped object for the next. + It 'gives Send-ScriptMessage no pipeline input' { + @($PublicParameters | Where-Object { $_.Name -like 'Send-ScriptMessage *' -and $_.Pipeline } | ForEach-Object Name) | + Should -BeNullOrEmpty + } + + # Named only, as in 1.1.1. A position would be new public input. + It 'gives Connect-ScriptMessage and Disconnect-ScriptMessage no positional parameters' { + @($PublicParameters | Where-Object { $_.Name -match '^(Connect|Disconnect)-ScriptMessage ' -and $_.Positional } | ForEach-Object Name) | + Should -BeNullOrEmpty + } +} diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index b01122c..f317772 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -109,6 +109,60 @@ Describe 'Send-ScriptMessage recipient check' { } } +Describe 'Send-ScriptMessage pipeline input' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } + Mock -ModuleName ScriptMessage Send-MgUserMail { $true } + + $MessageArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Mail' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + # No parameter takes pipeline input. When the binding error does not stop the call, only the process block keeps + # the body from running anyway and sending without the piped value, as it would an attachment meant for the + # message. When it does, the call throws before any of it runs. The error follows -ErrorAction or the global + # preference, never a caller's local one, so these tests pass -ErrorAction to stay independent of the runner's. + It 'reports and sends nothing' -ForEach @( + @{ Name = 'a piped file path'; Piped = 'C:\Reports\report.txt' } + @{ Name = 'a piped message object'; Piped = [pscustomobject]@{ To = 'other@example.org'; Subject = 'Other' } } + ) { + # -ErrorVariable does not collect this error, which comes from binding rather than from the command. + $Output = @($Piped | Send-ScriptMessage @MessageArguments -ErrorAction Continue 2>&1) + + @($Output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) | Should -BeNullOrEmpty + @($Output | Where-Object { $_.FullyQualifiedErrorId -like 'InputObjectNotBound*' }).Count | Should -Be 1 + Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 0 -Exactly + } + + It 'stops a caller whose error preference is Stop, sending nothing' { + { 'C:\Reports\report.txt' | Send-ScriptMessage @MessageArguments -ErrorAction Stop } | Should -Throw -ErrorId 'InputObjectNotBound*' + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 0 -Exactly + } + + It 'still sends once when nothing is piped' { + $null = Send-ScriptMessage @MessageArguments + + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } +} + Describe 'Send-ScriptMessage chat message' { BeforeAll { Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } From 1f857c723cc710e6aab3b816e1ec25782f9ad4d2 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:43:56 -0500 Subject: [PATCH 9/9] Print a plan before the test runs, and name the edition in the analyzer skip Invoke-Tests.ps1 now starts by listing the Pester version it will use and why, the editions in the order they run, and the test files each edition runs. The PSScriptAnalyzer test's skip under Windows PowerShell 5.1 now says which edition is running and that the PowerShell 7 run does the check, so the line makes sense read on its own in a CI log. --- Tests/Invoke-Tests.ps1 | 41 ++++++++++++++++++++++++---- Tests/ScriptMessage.Module.Tests.ps1 | 2 +- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Tests/Invoke-Tests.ps1 b/Tests/Invoke-Tests.ps1 index e0414fc..703d0ce 100644 --- a/Tests/Invoke-Tests.ps1 +++ b/Tests/Invoke-Tests.ps1 @@ -4,7 +4,8 @@ test fails. .DESCRIPTION - Each edition runs the tests in its own child process, so both import the module from scratch. Both editions + It first prints a plan: the Pester version, the editions in the order they run, and the test files. Each + edition runs the tests in its own child process, so both import the module from scratch. Both editions need Pester 5 or later. When either edition does not have it, or when -UsePinnedPester is given, both use a pinned Pester version, which is saved once to a temporary folder with Save-PSResource (this needs PowerShell 7). @@ -137,18 +138,45 @@ foreach ($Executable in $Editions.Values) # Use the installed Pester when every edition has version 5 or later. Otherwise use the pinned version for all of # them, so both editions always run the same Pester. -$NeedsPinnedPester = [bool]$UsePinnedPester -if (-not $NeedsPinnedPester) +$WithoutPester = @() +if (-not $UsePinnedPester) { - foreach ($Executable in $Editions.Values) + foreach ($Name in $Editions.Keys) { - Invoke-Stage -Executable $Executable -StageName 'CheckPester' + Invoke-Stage -Executable $Editions[$Name] -StageName 'CheckPester' if ($LASTEXITCODE -ne 0) { - $NeedsPinnedPester = $true + $WithoutPester += $Name } } } +$NeedsPinnedPester = $UsePinnedPester -or ($WithoutPester.Count -gt 0) + +if ($UsePinnedPester) +{ + $PesterPlan = "$PinnedPesterVersion, pinned by -UsePinnedPester" +} +elseif ($NeedsPinnedPester) +{ + $PesterPlan = "$PinnedPesterVersion, pinned because Pester 5 or later is not installed in $($WithoutPester -join ' and ')" +} +else +{ + $PesterPlan = 'the installed Pester 5 or later in each edition' +} + +# Pester finds these itself; they are listed only so the plan says what each edition will run. +$TestFiles = @(Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.Tests.ps1' -File -Recurse) + +Write-Host '===== Plan =====' +Write-Host "Pester: $PesterPlan" +Write-Host "Editions: $($Editions.Keys -join ', then '), each in its own process" +Write-Host "Tests: $($TestFiles.Count) files, all run under each edition" +foreach ($File in $TestFiles) +{ + Write-Host " $($File.Name)" +} +Write-Host 'A test that applies to one edition only is reported as skipped in the other, with the reason.' $PesterManifest = '' if ($NeedsPinnedPester) @@ -162,6 +190,7 @@ if ($NeedsPinnedPester) throw "Pester 5 or later is not installed, and PowerShell 7 is needed to save Pester $PinnedPesterVersion. Install either one." } + Write-Host '' Write-Host "Saving Pester $PinnedPesterVersion to $SaveRoot" Invoke-Stage -Executable 'pwsh' -StageName 'SavePester' -StagePesterPath $SaveRoot if (($LASTEXITCODE -ne 0) -or (-not (Test-Path -LiteralPath $PesterManifest))) diff --git a/Tests/ScriptMessage.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 index d839592..5a38dc3 100644 --- a/Tests/ScriptMessage.Module.Tests.ps1 +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -59,7 +59,7 @@ Describe 'ScriptMessage module' { It 'has no PSScriptAnalyzer errors' { if ($PSVersionTable.PSEdition -ne 'Core') { - Set-ItResult -Skipped -Because 'the analyzer check runs under PowerShell 7 only' + Set-ItResult -Skipped -Because 'this is Windows PowerShell 5.1, and the analyzer check runs only in the PowerShell 7 run' } $Installed = @(Get-Module -ListAvailable -Name PSScriptAnalyzer)