From 33ab368c5ce160132ef995de773bbace9aa2c484 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:02:47 -0500 Subject: [PATCH 01/40] Fix documentation errors in help, README, RELEASING, and CHANGELOG - Get-ScriptMessageConfig, Set-ScriptMessageConfigFilePath: the help's .PARAMETER entry named ConfigPath and Service instead of Path, so Get-Help showed no description for -Path. - Send-ScriptMessage help example 4: close the attachment array with ) instead of }, and end the example by calling Send-ScriptMessage. - README.md: remove the link to a Sample_Usage_Scripts folder that does not exist. - RELEASING.md: use a placeholder in the [Unreleased] compare link instead of a hard-coded 1.2.0. - CHANGELOG.md: point the 1.0.0 heading at this repository instead of SKYAPI, and add [Unreleased] with the help fixes. --- CHANGELOG.md | 10 +++++++++- README.md | 2 +- RELEASING.md | 5 +++-- ScriptMessage/Public/Get-ScriptMessageConfig.ps1 | 2 +- ScriptMessage/Public/Send-ScriptMessage.ps1 | 6 ++++-- .../Public/Set-ScriptMessageConfigFilePath.ps1 | 2 +- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60882dd..f17c4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog for ScriptMessage PowerShell Module +## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/1.1.1...develop) + +### Fixed + +- 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`. + +--- ## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 ### Changed @@ -144,7 +152,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- -## [1.0.0](https://github.com/Sekers/SKYAPI/tree/1.0.0) - (2024-05-20) +## [1.0.0](https://github.com/Sekers/ScriptMessage/tree/1.0.0) - (2024-05-20) ### Features diff --git a/README.md b/README.md index c1bab01..e06efbd 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ See [CHANGELOG.md](./CHANGELOG.md) for information on the latest updates, as wel ## Documentation -The ScriptMessage module documentation is hosted in the [ScriptMessage Wiki](https://github.com/Sekers/ScriptMessage/wiki). Examples are included in the [Sample Usage Scripts folder](./Sample_Usage_Scripts) as well as in the comment-based help for each function/cmdlet (e.g., Get-Help Connect-ScriptMessage). +The ScriptMessage module documentation is hosted in the [ScriptMessage Wiki](https://github.com/Sekers/ScriptMessage/wiki). Examples are included in the comment-based help for each function/cmdlet (e.g., Get-Help Connect-ScriptMessage -Examples). --- diff --git a/RELEASING.md b/RELEASING.md index 0c252a2..67c785d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -108,8 +108,9 @@ user-visible change adds its entry under `## [Unreleased]`, in one of these sect `[Unreleased]` exists only on `develop`, and only while it has entries. A release renames it to the version heading, so right after a release there is none. The next pull request that adds an entry also adds the section at the top, linked to a comparison from the last release: -`## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/1.2.0...develop)`. The Release workflow refuses to -publish a tag whose `CHANGELOG.md` still has an `[Unreleased]` section. +`## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/...develop)`, where `` is the +latest release tag. The Release workflow refuses to publish a tag whose `CHANGELOG.md` still has an `[Unreleased]` +section. ## Releasing a version diff --git a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 index 639b962..dc85ee2 100644 --- a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 @@ -10,7 +10,7 @@ function Get-ScriptMessageConfig .DESCRIPTION Get the configuration and secrets to connect to the messaging service(s). - .PARAMETER ConfigPath + .PARAMETER Path Optional. If not provided, the function will use the path used in the current session (if set). .PARAMETER Service Optional. Return only the info related to a specific service. diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index cd30b05..25ec3fc 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -117,7 +117,7 @@ function Send-ScriptMessage # $Content2 = Get-Content -AsByteStream -Raw -Path 'C:\Users\John\Downloads\AMovie.mp4' $Attachment = @( - @{ + @{ Name = 'MyPDF.pdf' Content = $Content1 }, @@ -125,7 +125,7 @@ function Send-ScriptMessage Name = 'funnyballgame.mp4' Content = $Content2 } - } + ) $MessageArguments = @{ From = @{ @@ -138,6 +138,8 @@ function Send-ScriptMessage } Attachment = $Attachment } + + Send-ScriptMessage -Service MicrosoftGraph @MessageArguments #> [CmdletBinding()] diff --git a/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 b/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 index 0333c40..061d1a4 100644 --- a/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 +++ b/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 @@ -11,7 +11,7 @@ function Set-ScriptMessageConfigFilePath Set the path to your ScriptMessage configuration file. The configuration holds general settings for the ScriptMessage module to use, as well as the connection information for the messaging service(s) you are using. - .PARAMETER Service + .PARAMETER Path Specify the path to where your configuration file is located. .EXAMPLE From 14daba82ae421bda5f873e7bb5c8aece9753b00c Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:30:54 -0500 Subject: [PATCH 02/40] Fix ChatType and IncludeBCCInGroupChat handling in Send-ScriptMessage - The defaults block tested the parameters for truthiness, so an explicit -ChatType OneOnOne or -IncludeBCCInGroupChat $false was replaced by the configuration file's value. OneOnOne is ChatType enum value 0 and $false is a valid choice, so neither can be told apart from an unsupplied value that way. Test $PSBoundParameters.ContainsKey() instead. - The same block cast the configuration value to [ChatType] unconditionally, so a configuration file with no ChatType setting made every send throw "Cannot convert null to type ChatType", including a Mail-only send that has no use for a chat type. - Resolve both settings into separate $Resolved variables. A parameter keeps its type constraint through later assignments, so [ChatType] cannot be reassigned the $null of a missing setting. This also stops the values leaking between services in a multi-service call, since each iteration now derives them from its own service configuration. - Add ChatType to the Send-ScriptMessage_MicrosoftGraph splat only when [string]::IsNullOrWhiteSpace says there is a real value. Neither $null nor the empty string a configuration file uses for an unset setting can bind to an enum parameter. - Report a Chat message that arrived without a chat type from Send-ScriptMessage_MicrosoftGraph, after its AllowableMessageTypes check, as a "Chat not sent." warning in the result's Error property. Raising it from Send-ScriptMessage instead preempts that check and tells a Mail-only configuration to add ChatType when what it actually needs is Chat in AllowableMessageTypes, and every other chat failure is already reported this way rather than thrown. - Add Pester coverage for both parameters against a configuration that disagrees, the configuration-default path, a Mail-only send with no ChatType setting, a Chat send with no chat type anywhere, a blank ChatType setting, and a Mail-only configuration asked to send Chat, which must still name the disallowed type. The existing chat tests now pass -ChatType rather than setting it in the mocked configuration. - Record the enum and type-constraint measurements behind the fix in Research_Notes/PowerShell-Language-Behavior.md section 10. --- CHANGELOG.md | 3 + .../PowerShell-Language-Behavior.md | 57 ++++- ScriptMessage/Public/Send-ScriptMessage.ps1 | 35 ++- ScriptMessage/Services/MicrosoftGraph.ps1 | 6 + Tests/Send-ScriptMessage.Tests.ps1 | 221 +++++++++++++++++- 5 files changed, 305 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f17c4c9..e7f0e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### 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. +- `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. +- `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - 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`. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 908c3a0..2ade7ae 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -39,8 +39,10 @@ passed". `$PSBoundParameters.ContainsKey()` is the test that tells them apart. A three states (unset, true, false) should be `[Nullable[bool]]`. **From source:** `Send-ScriptMessage` fills in `ChatType` and `IncludeBCCInGroupChat` from the configuration -file using exactly this `-not` test, which is why an explicit `-ChatType OneOnOne` or -`-IncludeBCCInGroupChat $false` is replaced by the configuration file's value. +file when the caller did not supply them, and tests `$PSBoundParameters.ContainsKey()` for exactly this reason. +The `-not` test would drop an explicit `-ChatType OneOnOne` or `-IncludeBCCInGroupChat $false` in favor of the +configuration file's value. Section 10 covers why the resolved values go into separate variables rather than +back into the parameters. ## 2. Measured: a `[PSCustomObject]` constraint on a variable does not convert a hashtable @@ -235,3 +237,54 @@ result in this section where the editions differ. tested. Truthiness tests such as `if ($One)` or `-not $One`, and other .NET methods taking a string argument, were not. - A script that sets `$OFS` was not tested. + +## 10. Measured: an enum cannot hold `$null`, and a parameter keeps its type constraint + +Measured on **2026-09-17**, same machine and editions as the header. Both editions agreed on every row. + +With `enum CT { OneOnOne; Group }`: + +| Code | Result | +| --- | --- | +| `[CT]$x = $null`, fresh variable, inside a function | throws "Cannot convert null to type" | +| `param([CT]$x)` then `$x = $null` later in the body | throws the same way | +| `param([CT]$x)` then `$x = 'Group'` later in the body | converts; `$x` is `[CT]` | +| `Test-Thing -ChatType $null` against a `[CT]` parameter | throws "Cannot process argument transformation" | +| Splatting `@{ ChatType = $null }` at a `[CT]` parameter | throws the same way | +| Splatting `@{ B = $null }` at a `[bool]` parameter | throws `Cannot convert value "" to type "System.Boolean"` | +| `[bool]$x = $null`, fresh variable, inside a function | converts; `$x` is `False` | +| `[bool]$x = $true` then `$x = $null` later, inside a function | converts; `$x` is `False` | +| Splatting `@{ ChatType = '' }` at a `[CT]` parameter | throws `The identifier name cannot be processed because it is either too similar or identical to the following enumerator names` | +| `[string]::IsNullOrWhiteSpace()` given a `[CT]` value | `False`; the enum converts to its member name first | +| `[bool]` of the string `'false'` | `True`; every non-empty string is truthy | + +Two consequences. A parameter's type constraint is not just an entry gate: it applies to every later assignment +to that variable in the body, so a parameter cannot be reused as a scratch variable for a value its type +cannot hold. And an enum has no representation for "no value", so a missing configuration setting cannot be +carried in an enum-typed variable or passed to an enum-typed parameter; the call has to omit the argument +instead. + +A `[bool]` behaves differently from an enum in the one case that matters: a `[bool]`-constrained variable +inside a function accepts `$null` and becomes `False`, while a `[bool]` parameter rejects it at binding. + +**From source:** `Send-ScriptMessage` resolves `ChatType` and `IncludeBCCInGroupChat` into separate +`$Resolved...` variables rather than assigning back to the parameters, because a parameter cannot be reassigned +a value its type cannot hold. It adds `ChatType` to the `Send-ScriptMessage_MicrosoftGraph` splat only when +`[string]::IsNullOrWhiteSpace` says there is a real value, because neither `$null` nor `''` can bind to the +service's `[ChatType]` parameter, and `Templates/config_scriptmessage.json` writes a setting it leaves unset as +an empty string. `Send-ScriptMessage_MicrosoftGraph` then reports a Chat message that arrived without one. +`IncludeBCCInGroupChat` needs no such guard: it is resolved through a `[bool]`-constrained variable inside a +function, so a configuration file with no `IncludeBCCInGroupChat` setting yields `False`. + +The last row is a trap this module has not addressed: a configuration file that quotes the value, as +`"IncludeBCCInGroupChat": "false"`, yields `True`, because `[bool]` of any non-empty string is `True`. JSON +`false` is unquoted and converts correctly, so only a hand-edited file with the quotes is affected. + +### What this does not establish + +- Why the same `[bool]` assignment that converts `$null` inside a function throws at script scope. Both + editions do it, and the module is unaffected because all of its code lives in functions, but the cause was + not traced. +- `[Nullable[bool]]` and `[CT?]` parameters were not tested here; section 1 covers an unassigned + `[Nullable[bool]]` variable only. +- Only `$null` was tested as the rejected value. Empty strings and other unconvertible values were not. diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index 25ec3fc..e18bcc5 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -293,14 +293,28 @@ function Send-ScriptMessage ServiceConfig = $ScriptMessageConfig.$($serviceTypeObj.Service) } - # Set default values if not specified by a parameter. - if (-not $ChatType) + # 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')) { - [ChatType]$ChatType = $ConnectionParameters.ServiceConfig.ChatType + $ResolvedChatType = $ChatType } - if (-not $IncludeBCCInGroupChat) + else { - [bool]$IncludeBCCInGroupChat = $ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat + $ResolvedChatType = $ConnectionParameters.ServiceConfig.ChatType + } + + # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value". + if ($PSBoundParameters.ContainsKey('IncludeBCCInGroupChat') -and ($null -ne $IncludeBCCInGroupChat)) + { + [bool]$ResolvedIncludeBCCInGroupChat = $IncludeBCCInGroupChat + } + else + { + [bool]$ResolvedIncludeBCCInGroupChat = $ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat } # Connect to the messaging service, if necessary (e.g., API service). @@ -321,8 +335,15 @@ function Send-ScriptMessage Attachment = $Attachment SenderId = $SenderId Type = $serviceTypeObj.Type - ChatType = $ChatType - IncludeBCCInGroupChat = $IncludeBCCInGroupChat + IncludeBCCInGroupChat = $ResolvedIncludeBCCInGroupChat + } + + # [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 } Send-ScriptMessage_MicrosoftGraph @SendMessageParameters diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 35abe37..9bbd3fd 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -693,6 +693,12 @@ function Send-ScriptMessage_MicrosoftGraph Write-Warning -Message $NewMessage $MgWarningMessages += "$NewMessage" } + elseif ($null -eq $ChatType) + { + $NewMessage = "Chat not sent. No chat type is set. Add the 'ChatType' setting to the '$ServiceId' section of the configuration file, or use the 'ChatType' parameter." + Write-Warning -Message $NewMessage + $MgWarningMessages += "$NewMessage" + } else { # Grab the latest MicrosoftGraph service context. diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index 7ec8701..2677b81 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -133,8 +133,6 @@ Describe 'Send-ScriptMessage chat message' { } } - # Each context sets ChatType in the configuration rather than passing -ChatType, because the configuration - # value currently wins over an explicitly passed parameter. Context 'A one-on-one chat with no attachments' { BeforeAll { Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { @@ -142,7 +140,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'OneOnOne' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -154,7 +151,7 @@ Describe 'Send-ScriptMessage chat message' { } It 'sends the message body and reports no error' { - $Result = Send-ScriptMessage @MessageArguments + $Result = Send-ScriptMessage @MessageArguments -ChatType OneOnOne $Result.Error | Should -BeNullOrEmpty $Result.Status.Id | Should -Be 'new-message-id' @@ -171,7 +168,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'Group' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -182,7 +178,7 @@ Describe 'Send-ScriptMessage chat message' { } It 'sends the message body and reports no error' { - $Result = Send-ScriptMessage @MessageArguments + $Result = Send-ScriptMessage @MessageArguments -ChatType Group $Result.Error | Should -BeNullOrEmpty $Result.Status.Id | Should -Be 'new-message-id' @@ -199,7 +195,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'OneOnOne' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -212,6 +207,7 @@ Describe 'Send-ScriptMessage chat message' { It 'sends both, and the chat carries its own message content' { $Arguments = $MessageArguments.Clone() $Arguments['Type'] = @('Mail', 'Chat') + $Arguments['ChatType'] = 'OneOnOne' $Result = @(Send-ScriptMessage @Arguments) @@ -233,7 +229,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'OneOnOne' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -253,6 +248,7 @@ Describe 'Send-ScriptMessage chat message' { It 'escapes the attachment filename for the upload path' { $Arguments = $MessageArguments.Clone() $Arguments['Attachment'] = @{ Name = 'My File+1.pdf'; Content = [byte[]]@(1, 2, 3) } + $Arguments['ChatType'] = 'OneOnOne' $Result = Send-ScriptMessage @Arguments @@ -305,3 +301,212 @@ Describe 'Send-ScriptMessage mail attachment' { } } } + +Describe 'Send-ScriptMessage configuration defaults' { + BeforeAll { + Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Get-ScriptMessageContext { + [pscustomobject]@{ + Account = 'sender@example.org' + Scopes = @('Chat.ReadWrite') + } + } + Mock -ModuleName ScriptMessage New-MgChat { [pscustomobject]@{ Id = 'new-chat-id' } } + Mock -ModuleName ScriptMessage New-MgChatMessage { [pscustomobject]@{ Id = 'new-message-id' } } + Mock -ModuleName ScriptMessage Get-MgChat { @() } + Mock -ModuleName ScriptMessage Send-MgUserMail { $true } + + $ChatArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Chat' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + Context 'A configuration that sets ChatType to Group' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MailType = 'Group' + ChatType = 'Group' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + # 'OneOnOne' is the first ChatType enum member, so its numeric value is 0. A truthiness test on the + # parameter cannot tell it apart from an unsupplied value. + It 'an explicit -ChatType OneOnOne wins over the configuration' { + $null = Send-ScriptMessage @ChatArguments -ChatType OneOnOne + + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 1 -Exactly -ParameterFilter { + $ChatType -eq 'OneOnOne' + } + # Only the Group branch looks for a chat to reuse. + Should -Invoke -ModuleName ScriptMessage Get-MgChat -Times 0 -Exactly + } + + It 'the configuration value is used when -ChatType is not supplied' { + $null = Send-ScriptMessage @ChatArguments + + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 1 -Exactly -ParameterFilter { + $ChatType -eq 'Group' + } + Should -Invoke -ModuleName ScriptMessage Get-MgChat -Times 1 -Exactly + } + } + + Context 'A configuration that sets IncludeBCCInGroupChat to true' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MailType = 'Group' + ChatType = 'Group' + IncludeBCCInGroupChat = $true + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'an explicit -IncludeBCCInGroupChat $false wins over the configuration' { + $Result = Send-ScriptMessage @ChatArguments -BCC 'blind@example.org' -IncludeBCCInGroupChat $false -WarningAction SilentlyContinue + + @($Result.Error | Where-Object { $_.Type -eq 'Warning' -and $_.Message -like '*not included in the group chat*' }).Count | + Should -Be 1 + } + + It 'the configuration value is used when -IncludeBCCInGroupChat is not supplied' { + $Result = Send-ScriptMessage @ChatArguments -BCC 'blind@example.org' + + $Result.Error | Should -BeNullOrEmpty + } + } + + Context 'A configuration written before the ChatType setting existed' { + BeforeAll { + # A configuration file from 1.0.7 or earlier has no ChatType setting at all. + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends Mail without needing a ChatType setting' { + $MailArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Mail' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + + # A missing 'ChatType' must not stop a Mail send, so reaching the assertions at all is half of + # what this checks. + $Result = Send-ScriptMessage @MailArguments -ErrorAction Stop + + # A Mail send has no chat type and must not be told about one. + $Result.Error | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } + + # The configuration allows Mail only, so that is what the caller has to change. A missing 'ChatType' is + # not the reason this send cannot happen, and saying so would send them to the wrong setting. + It 'reports the disallowed message type rather than the missing ChatType' { + $ChatOnly = @{ + Service = 'MicrosoftGraph' + Type = 'Chat' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + + $Result = Send-ScriptMessage @ChatOnly -WarningAction SilentlyContinue + + @($Result.Error | Where-Object { $_.Message -like '*does not allow sending messages of type*' }).Count | + Should -Be 1 + $Result.Error | Where-Object { $_.Message -like '*No chat type is set*' } | Should -BeNullOrEmpty + } + } + + Context 'A configuration that allows Chat but sets no ChatType' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'reports the missing chat type in the result instead of throwing' { + $Result = Send-ScriptMessage @ChatArguments -WarningAction SilentlyContinue + + @($Result.Error | Where-Object { $_.Message -like '*No chat type is set*' }).Count | Should -Be 1 + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 0 -Exactly + } + + It 'still sends Mail in the same call' { + $Arguments = $ChatArguments.Clone() + $Arguments['Type'] = @('Mail', 'Chat') + + $Result = @(Send-ScriptMessage @Arguments -WarningAction SilentlyContinue) + + @($Result | Where-Object { $_.MessageType -eq 'Mail' -and $null -eq $_.Error }).Count | Should -Be 1 + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } + } + + Context 'A configuration that leaves ChatType blank' { + BeforeAll { + # Templates/config_scriptmessage.json writes a setting it leaves unset as an empty string, so a + # blank 'ChatType' has to read the same as a missing one. + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + ChatType = '' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'reports the missing chat type rather than failing to convert an empty string' { + $Result = Send-ScriptMessage @ChatArguments -WarningAction SilentlyContinue + + @($Result.Error | Where-Object { $_.Message -like '*No chat type is set*' }).Count | Should -Be 1 + $Result.Error | Where-Object { $_.Message -like '*identifier name*' } | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 0 -Exactly + } + } +} From f6b1f7e41bc25900e3155ee1482ba4dfdb37cf25 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:39:04 -0500 Subject: [PATCH 03/40] Use RFC 2606 reserved domains for every placeholder - AGENTS.md and CONTRIBUTING.md required placeholder addresses on domain.com, which is a real registered domain. Require a domain reserved by RFC 2606 instead: example.com, example.net, or example.org. None of those can ever be registered, so a placeholder copied out of a help example and run unedited, or a test whose mock is missing, cannot reach a real recipient. The help examples were the weakest spot, since those are what a reader copies and runs. - Send-ScriptMessage help: 14 example addresses to example.com. - Research_Notes/PowerShell-Language-Behavior.md section 9 records observed output, so the expressions were re-run with the new address in both editions rather than edited by hand. - Tests: the one placeholder URL in the attachment mock. - The rule states example.com as the preference but all three as equally correct, so nobody renames the test suite's existing example.org addresses to match. --- AGENTS.md | 7 ++++-- CONTRIBUTING.md | 3 ++- .../PowerShell-Language-Behavior.md | 6 ++--- ScriptMessage/Public/Send-ScriptMessage.ps1 | 24 +++++++++---------- Tests/Send-ScriptMessage.Tests.ps1 | 2 +- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dc8a3e..1ca8ec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,11 @@ repository (`Sekers/ScriptMessage.wiki`), not part of this one. `Tests/` use whichever configuration their `Set-ScriptMessageConfigFilePath` line names; read that line, and the recipients, before running anything. - **Never name the production tenant in any file.** No organization name, abbreviation, or email domain in - code, comments, help examples, tests, or commit messages. Use placeholder addresses on `domain.com`, as the - existing help examples do. + code, comments, help examples, tests, or commit messages. Every placeholder address and URL uses a domain + reserved by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606): `example.com`, `example.net`, or + `example.org`. None of them can ever be registered, so a placeholder copied out of a help example and run + unedited, or a test whose mock is missing, cannot reach a real recipient. Prefer `example.com` for anything + new, but all three are equally correct: never rename existing placeholders to match that preference. - **Never commit a real configuration file.** Configuration files hold the tenant ID, client ID, certificate details, and encrypted secrets. `Tests/Config/` and `@Local Only/` are gitignored for that reason, and `Templates/config_scriptmessage.json` must only ever contain placeholder values. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9a6c5a..7af6c61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,8 @@ Thank you for helping improve ScriptMessage. This guide covers the everyday work ## Rules - **Never commit a real configuration file, secret, or certificate.** Never name a real organization, tenant, or - email domain in code, tests, examples, or commit messages; use `domain.com` addresses instead. + email domain in code, tests, examples, or commit messages; use an RFC 2606 reserved domain instead, such as + `example.com`. Those can never be registered, so a placeholder that gets run unedited reaches nobody. - **The automated tests never connect to a tenant.** If a change needs a live Microsoft Graph test, run it in your own test tenant and describe what you tested in the pull request. - **Text files are UTF-8 with LF line endings and no byte order mark, and PowerShell files contain only ASCII.** diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 2ade7ae..dd7e00d 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -178,12 +178,12 @@ opens the file with .NET, so a configuration file path containing brackets works ## 9. Measured: a `PSCustomObject` in a one-item array converts to an empty string Measured on **2026-09-16** with the same editions, in a standalone script with `$OFS` unset. With -`$Obj = [pscustomobject]@{ Name = 'A'; AddressObj = 'a@domain.com' }`, `$One = @($Obj)`, and +`$Obj = [pscustomobject]@{ Name = 'A'; AddressObj = 'a@example.com' }`, `$One = @($Obj)`, and `$Two = @($Obj, $Obj)`: | Expression | Result | | --- | --- | -| `[string]$Obj` or `"$Obj"` | `@{Name=A; AddressObj=a@domain.com}` | +| `[string]$Obj` or `"$Obj"` | `@{Name=A; AddressObj=a@example.com}` | | `$Obj.ToString()` | an empty string | | `[string]$One` or `"$One"` | an empty string | | `[string]$Two` | a single space | @@ -192,7 +192,7 @@ Measured on **2026-09-16** with the same editions, in a standalone script with ` | `[string]::IsNullOrEmpty($Two)` | `False` (`IsNullOrWhiteSpace` gives `True`) | | `[string]::IsNullOrEmpty($One.AddressObj)` | `False` | | `[string]@(@{ Name = 'x' })`, a hashtable in a one-item array | `System.Collections.Hashtable`, so `IsNullOrEmpty` gives `False` | -| `[string]@('a@domain.com')` | `a@domain.com` | +| `[string]@('a@example.com')` | `a@example.com` | | `[string]::IsNullOrEmpty(@())` | `True` | So `[string]::IsNullOrEmpty` cannot tell an empty array from one holding a single `PSCustomObject`, and for an diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index e18bcc5..bbcc090 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -58,8 +58,8 @@ function Send-ScriptMessage .EXAMPLE $MessageArguments = @{ - From = 'jdoe@domain.com' - To = 'bmayes@domain.com' + From = 'jdoe@example.com' + To = 'bmayes@example.com' CC = @() Subject = "Test Message" Body = @{ @@ -70,9 +70,9 @@ function Send-ScriptMessage Send-ScriptMessage -Service MicrosoftGraph -Type 'Mail' @MessageArguments .EXAMPLE $MessageArguments = @{ - From = 'jdoe@domain.com' - To = @('bmayes@domain.com') - CC = @( "hcoonly@domain.com", "plittle@domain.com") + From = 'jdoe@example.com' + To = @('bmayes@example.com') + CC = @( "hcoonly@example.com", "plittle@example.com") Subject = "Test Message" Body = @{ Content = "This is a test message.`n`nThank you!" @@ -84,14 +84,14 @@ function Send-ScriptMessage $MessageArguments = @{ From = @{ Name = 'John Doe' - Address = 'jdoe@domain.com' + Address = 'jdoe@example.com' } ReplyTo= @{ Name = "Lisa Maloney" - Address = "lmaloney@domain.com" + Address = "lmaloney@example.com" } - To = @('bmayes@domain.com') - CC = @( "hcoonly@domain.com", "plittle@domain.com") + To = @('bmayes@example.com') + CC = @( "hcoonly@example.com", "plittle@example.com") SaveToSentItems = $true Subject = "Test Message" Body = @{ @@ -99,7 +99,7 @@ function Send-ScriptMessage Content = "This is a test message.`n`nThank you!" } Attachment = @('C:\StuffToSend\', 'C:\Documents\AnotherFile.pdf') - SenderId = 'senderaccount@domain.com' + SenderId = 'senderaccount@example.com' } Send-ScriptMessage -Service MicrosoftGraph @MessageArguments @@ -129,9 +129,9 @@ function Send-ScriptMessage $MessageArguments = @{ From = @{ - Address = 'jdoe@domain.com' + Address = 'jdoe@example.com' } - To = @('bmayes@domain.com') + To = @('bmayes@example.com') Subject = "Test Message" Body = @{ Content = "This is a test message.`n`nThank you!" diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index 2677b81..2c0d9ab 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -240,7 +240,7 @@ Describe 'Send-ScriptMessage chat message' { Mock -ModuleName ScriptMessage Get-MgDriveItem { $null } # The chat files folder does not exist yet. Mock -ModuleName ScriptMessage Invoke-MgGraphRequest { # Graph returns the uploaded drive item as a hashtable, which the attachment converter reads by key. - @{ id = 'drive-item-id'; name = 'My File+1.pdf'; webUrl = 'https://www.domain.com/file' } + @{ id = 'drive-item-id'; name = 'My File+1.pdf'; webUrl = 'https://www.example.com/file' } } Mock -ModuleName ScriptMessage Invoke-MgInviteDriveItem { @{ id = 'invite-id' } } } From 9a31c0fad351c729cad36fe22705e668d6f33c75 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:25:57 -0500 Subject: [PATCH 04/40] Require boolean configuration settings and parameters to be booleans - [bool] of any non-empty string is $true, so a quoted "false" meant its opposite. IncludeBCCInGroupChat was read with a [bool] cast and MgDisconnectWhenDone with a truthiness test, so both inverted. The two MgDelegatedPermission_* settings are read as -eq $true, which compares as strings and is already correct. - Declare -IncludeBCCInGroupChat as [Nullable[bool]] instead of [Object] with [ValidateSet($null, $true, $false)]. The binder refuses a string outright, while the validation set accepted 'false' and left it a string for the later cast to turn into $true. Nullable keeps the three states the parameter needs, so $null still means "use the configured value", and an unsupplied parameter holds $null too, which retires the $PSBoundParameters.ContainsKey() test. - A configuration file value never passes through a binder, so add Get-ScriptMessageBooleanSetting to reject anything -isnot [bool] with an error naming the setting. Giving that helper a [Nullable[bool]] parameter would delegate the check to the binder but report the internal parameter name instead of the setting, which is no help to someone holding a JSON file. - Each layer checks the settings it owns, before anything is sent: Send-ScriptMessage for the generic IncludeBCCInGroupChat, and Send-ScriptMessage_MicrosoftGraph for MgDisconnectWhenDone. That means moving the Disconnect-MgGraph teardown into the service as well, so the layer that validates the setting is the one that consumes it. - Add Pester coverage for a string passed to the parameter, a quoted value for each setting, and that neither sends. The success cases assert Send-MgUserMail was invoked, because an earlier version of these tests built its config mocks with GetNewClosure(), which swallowed Pester's injected $Service and passed without sending anything. - Record the binder, cast, class property and ValidateSet differences in Research_Notes/PowerShell-Language-Behavior.md section 10. --- CHANGELOG.md | 2 + .../PowerShell-Language-Behavior.md | 28 ++++-- .../Get-ScriptMessageBooleanSetting.ps1 | 33 +++++++ ScriptMessage/Public/Send-ScriptMessage.ps1 | 17 ++-- ScriptMessage/Services/MicrosoftGraph.ps1 | 11 +++ Tests/Send-ScriptMessage.Tests.ps1 | 98 +++++++++++++++++++ 6 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f0e5e..8c53b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- `Send-ScriptMessage` now requires the `IncludeBCCInGroupChat` setting to be an unquoted `true` or `false`, not a quoted string. Quotes make the value text, and any non-empty text was read as `true`. So a configuration file holding `"IncludeBCCInGroupChat": "false"` added BCC recipients to a group chat and made their addresses visible to everyone in it, and passing the string `'false'` to `-IncludeBCCInGroupChat` did the same. Both now stop with an error naming the setting or the parameter; remove the quotes in the configuration file, and pass `$false` to the parameter. +- `Send-ScriptMessage` now requires the `MgDisconnectWhenDone` setting to be an unquoted `true` or `false`, not a quoted string. Quotes make the value text, which was read as `true`, so a configuration file holding `"MgDisconnectWhenDone": "false"` disconnected from Microsoft Graph after sending, the opposite of what the setting asks for. It now stops with an error naming the setting; remove the quotes. - `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. - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index dd7e00d..b7343f5 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -39,10 +39,11 @@ passed". `$PSBoundParameters.ContainsKey()` is the test that tells them apart. A three states (unset, true, false) should be `[Nullable[bool]]`. **From source:** `Send-ScriptMessage` fills in `ChatType` and `IncludeBCCInGroupChat` from the configuration -file when the caller did not supply them, and tests `$PSBoundParameters.ContainsKey()` for exactly this reason. -The `-not` test would drop an explicit `-ChatType OneOnOne` or `-IncludeBCCInGroupChat $false` in favor of the -configuration file's value. Section 10 covers why the resolved values go into separate variables rather than -back into the parameters. +file when the caller did not supply them. The `-not` test would drop an explicit `-ChatType OneOnOne` or +`-IncludeBCCInGroupChat $false` in favor of the configuration file's value, so `ChatType` tests +`$PSBoundParameters.ContainsKey()` and `IncludeBCCInGroupChat`, declared `[Nullable[bool]]` as the last line +above recommends, tests `$null -ne`. Section 10 covers why the resolved values go into separate variables +rather than back into the parameters, and why that declaration is what rejects a string. ## 2. Measured: a `[PSCustomObject]` constraint on a variable does not convert a hashtable @@ -257,6 +258,11 @@ With `enum CT { OneOnOne; Group }`: | Splatting `@{ ChatType = '' }` at a `[CT]` parameter | throws `The identifier name cannot be processed because it is either too similar or identical to the following enumerator names` | | `[string]::IsNullOrWhiteSpace()` given a `[CT]` value | `False`; the enum converts to its member name first | | `[bool]` of the string `'false'` | `True`; every non-empty string is truthy | +| `-b 'false'` at a `[bool]` or `[Nullable[bool]]` parameter | throws: "Boolean parameters accept only Boolean values and numbers, such as $True, $False, 1 or 0" | +| `-b 'false'` at an `[Object]` parameter carrying `[ValidateSet($null, $true, $false)]` | passes validation, and `$b` is still the string `'false'` | +| `-b 0` at that same `[ValidateSet]` parameter | throws; the set holds `''`, `True` and `False` as strings | +| `-b 1` at a `[bool]` or `[Nullable[bool]]` parameter | `True` | +| An unsupplied `[Nullable[bool]]` parameter, and `-b $null` | both `$null` | Two consequences. A parameter's type constraint is not just an entry gate: it applies to every later assignment to that variable in the body, so a parameter cannot be reused as a scratch variable for a value its type @@ -276,9 +282,17 @@ an empty string. `Send-ScriptMessage_MicrosoftGraph` then reports a Chat message `IncludeBCCInGroupChat` needs no such guard: it is resolved through a `[bool]`-constrained variable inside a function, so a configuration file with no `IncludeBCCInGroupChat` setting yields `False`. -The last row is a trap this module has not addressed: a configuration file that quotes the value, as -`"IncludeBCCInGroupChat": "false"`, yields `True`, because `[bool]` of any non-empty string is `True`. JSON -`false` is unquoted and converts correctly, so only a hand-edited file with the quotes is affected. +The parameter binder is stricter than a cast or a validation set, and that difference is load-bearing. A +`[bool]` or `[Nullable[bool]]` parameter refuses a string, while `[Object]` with `[ValidateSet($null, $true, +$false)]` accepts `'false'` and leaves it a string for a later `[bool]` cast to turn into `True`. A +configuration file value never passes through a binder at all, so it needs its own check. + +**From source:** `Send-ScriptMessage` declares `-IncludeBCCInGroupChat` as `[Nullable[bool]]`, which keeps the +three states it needs (unset, true, false) and makes the binder reject a string. A configuration file value +reaches no binder, so `Get-ScriptMessageBooleanSetting` rejects any value that is `-isnot [bool]`. Each layer +calls it for the settings it owns and before anything is sent: `Send-ScriptMessage` for the generic +`IncludeBCCInGroupChat`, and `Send-ScriptMessage_MicrosoftGraph` for its own `MgDisconnectWhenDone`, ahead of +that function's message type loop. Unquoted JSON `true` and `false` arrive as real booleans and are unaffected. ### What this does not establish diff --git a/ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 b/ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 new file mode 100644 index 0000000..d0db95b --- /dev/null +++ b/ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 @@ -0,0 +1,33 @@ +function Get-ScriptMessageBooleanSetting +{ + [CmdletBinding()] + param( + [Parameter( + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true)] + [AllowNull()] + [Object]$Value, + + [Parameter( + Mandatory = $true, + ValueFromPipelineByPropertyName = $true)] + [string]$Name + ) + + # An unset setting stays unset. The caller decides what that means for its own setting. + if ($null -eq $Value) + { + return $null + } + + # A JSON boolean arrives as a [bool]. A quoted "true" or "false" arrives as a string, and casting a string + # to [bool] makes every non-empty one $true, so a quoted "false" would mean its opposite. Reject anything + # that is not already a boolean rather than guessing at it. + if ($Value -isnot [bool]) + { + throw "The `'$Name`' setting in the ScriptMessage configuration file must be a boolean written as true or false without quotes. Found the $($Value.GetType().Name) value `'$Value`'." + } + + return $Value +} diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index bbcc090..7ee0bc2 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -246,8 +246,7 @@ function Send-ScriptMessage Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] - [ValidateSet($null, $true, $false)] - [Object]$IncludeBCCInGroupChat # Is an object so it can be set to $null + [Nullable[bool]]$IncludeBCCInGroupChat # Nullable so it can be set to $null, meaning "use the configured value" ) # Set the necessary configuration variables. @@ -307,14 +306,16 @@ function Send-ScriptMessage $ResolvedChatType = $ConnectionParameters.ServiceConfig.ChatType } - # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value". - if ($PSBoundParameters.ContainsKey('IncludeBCCInGroupChat') -and ($null -ne $IncludeBCCInGroupChat)) + # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value", which is also what an + # unsupplied [Nullable[bool]] holds. + if ($null -ne $IncludeBCCInGroupChat) { [bool]$ResolvedIncludeBCCInGroupChat = $IncludeBCCInGroupChat } else { - [bool]$ResolvedIncludeBCCInGroupChat = $ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat + [bool]$ResolvedIncludeBCCInGroupChat = Get-ScriptMessageBooleanSetting -Name 'IncludeBCCInGroupChat' ` + -Value $ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat } # Connect to the messaging service, if necessary (e.g., API service). @@ -347,12 +348,6 @@ function Send-ScriptMessage } Send-ScriptMessage_MicrosoftGraph @SendMessageParameters - - # Disconnect from Microsoft Graph API, if enabled in config. - if ($ConnectionParameters.ServiceConfig.MgDisconnectWhenDone) - { - $null = Disconnect-MgGraph -ErrorAction SilentlyContinue - } } Default {throw "Invalid `'Service`' value."} } diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 9bbd3fd..c0e8111 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -515,6 +515,11 @@ function Send-ScriptMessage_MicrosoftGraph # Get the Service Config. $ServiceConfig = Get-ScriptMessageConfig -Service $ServiceId + # This service's own boolean settings, checked before anything is sent so a value the module cannot read + # stops the send instead of surfacing after the message has already gone out. + $MgDisconnectWhenDone = Get-ScriptMessageBooleanSetting -Name 'MgDisconnectWhenDone' ` + -Value $ServiceConfig.MgDisconnectWhenDone + # Send the message on each supported service specified. foreach ($typeItem in $Type) { @@ -1045,4 +1050,10 @@ function Send-ScriptMessage_MicrosoftGraph } } } + + # Disconnect from the Microsoft Graph API, if enabled in the configuration file. + if ($MgDisconnectWhenDone) + { + $null = Disconnect-MgGraph -ErrorAction SilentlyContinue + } } \ No newline at end of file diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index 2c0d9ab..b33c5dc 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -483,6 +483,104 @@ Describe 'Send-ScriptMessage configuration defaults' { } } + Context 'Boolean settings and parameters reject strings' { + BeforeAll { + Mock -ModuleName ScriptMessage Disconnect-MgGraph { } + + $MailArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Mail' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + # Parameter binding fails before the configuration file is read, so this needs no configuration mock. + # 'false' is a non-empty string, and every non-empty string casts to $true, so accepting it would mean + # the opposite of what the caller asked for. + It 'rejects a string passed to -IncludeBCCInGroupChat' { + { Send-ScriptMessage @MailArguments -IncludeBCCInGroupChat 'false' } | + Should -Throw '*Boolean parameters accept only*' + } + + Context 'A configuration whose booleans are real booleans' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + IncludeBCCInGroupChat = $true + MgPermissionType = 'Application' + MgDisconnectWhenDone = $true + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'accepts a real $false for -IncludeBCCInGroupChat' { + { Send-ScriptMessage @MailArguments -IncludeBCCInGroupChat $false -ErrorAction Stop } | + Should -Not -Throw + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } + + It 'disconnects when MgDisconnectWhenDone is a real $true' { + $null = Send-ScriptMessage @MailArguments + + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + Should -Invoke -ModuleName ScriptMessage Disconnect-MgGraph -Times 1 -Exactly + } + } + + Context 'A configuration file that quotes IncludeBCCInGroupChat' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + IncludeBCCInGroupChat = 'false' + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'stops without sending' { + { Send-ScriptMessage @MailArguments } | + Should -Throw "*'IncludeBCCInGroupChat' setting*must be a boolean*" + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 0 -Exactly + } + } + + Context 'A configuration file that quotes MgDisconnectWhenDone' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + IncludeBCCInGroupChat = $false + MgPermissionType = 'Application' + MgDisconnectWhenDone = 'false' + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + # The Microsoft Graph service consumes this setting after its message type loop, so it checks the + # value before that loop to keep an unreadable one from surfacing after the message has gone out. + It 'stops without sending' { + { Send-ScriptMessage @MailArguments } | + Should -Throw "*'MgDisconnectWhenDone' setting*must be a boolean*" + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 0 -Exactly + } + } + } + Context 'A configuration that leaves ChatType blank' { BeforeAll { # Templates/config_scriptmessage.json writes a setting it leaves unset as an empty string, so a From 1cf19cf69f9f1f23f2c3c8eb4a4f5df9b6ec0221 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:36:55 -0500 Subject: [PATCH 05/40] Move Get-MgContext into the Microsoft Graph service - Get-ScriptMessageContext called Get-MgContext directly, so a core cmdlet depended on Microsoft.Graph.Authentication. Add Get-ScriptMessageContext_MicrosoftGraph alongside the existing Disconnect-ScriptMessage_MicrosoftGraph and call that instead. No cmdlet in ScriptMessage/Public/ calls a Microsoft Graph cmdlet now. - Lift the property-merging loop out of the switch arm so it runs on whatever any service returns, which also retires the "break # Terminate the switch statement" that only existed because the emptiness check sat inside the arm. Keep [string]::IsNullOrEmpty for that check so the test is unchanged from what shipped. - Add Pester coverage for this cmdlet, which had none: the service name is returned alongside the service's own properties, a service with no connection yields just the service name, and -ReturnCachedContext does not ask the service twice. One test mocks Get-MgContext rather than the service function, so it covers the whole chain. Output is unchanged, so there is no changelog entry. --- .../Public/Get-ScriptMessageContext.ps1 | 22 ++--- ScriptMessage/Services/MicrosoftGraph.ps1 | 7 ++ Tests/Get-ScriptMessageContext.Tests.ps1 | 81 +++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 Tests/Get-ScriptMessageContext.Tests.ps1 diff --git a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 index 3e15b11..e779201 100644 --- a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 @@ -58,19 +58,19 @@ function Get-ScriptMessageContext $ScriptMessageContext | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) } - # Retrieve connection information. + # Ask the service for its own connection information. switch ($Service) { - MicrosoftGraph { - $MgContext = Get-MgContext - if ([string]::IsNullOrEmpty($MgContext)) - { - break # Terminate the switch statement. - } - foreach ($infoItem in $($MgContext.PSObject.Properties)) - { - $ScriptMessageContext | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) - } + MicrosoftGraph {$ServiceConnectionInfo = Get-ScriptMessageContext_MicrosoftGraph} + } + + # Add in whatever the service returned. A service with no active connection returns nothing, which + # leaves the context object holding only the common information above. + if (-not [string]::IsNullOrEmpty($ServiceConnectionInfo)) + { + foreach ($infoItem in $($ServiceConnectionInfo.PSObject.Properties)) + { + $ScriptMessageContext | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) } } diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index c0e8111..ab0af10 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -422,6 +422,13 @@ function Disconnect-ScriptMessage_MicrosoftGraph return Disconnect-MgGraph } +function Get-ScriptMessageContext_MicrosoftGraph +{ + # Returns nothing when there is no active connection. The caller adds the properties of whatever comes back + # to the context object it returns, so the shape of this is Microsoft Graph's to decide. + return Get-MgContext +} + function Send-ScriptMessage_MicrosoftGraph { [CmdletBinding()] diff --git a/Tests/Get-ScriptMessageContext.Tests.ps1 b/Tests/Get-ScriptMessageContext.Tests.ps1 new file mode 100644 index 0000000..4b1223d --- /dev/null +++ b/Tests/Get-ScriptMessageContext.Tests.ps1 @@ -0,0 +1,81 @@ +# Pester 5 or later. Run from the repository root: +# Invoke-Pester -Path .\Tests +# Nothing here connects to a tenant, and the Microsoft Graph modules do not need to be installed. + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force + + # Pester can only mock a command that exists, so define a stand-in for the Microsoft Graph cmdlet inside the + # module before mocking it. It throws if a test reaches it without a mock. + InModuleScope ScriptMessage { + function script:Get-MgContext { throw 'Get-MgContext stand-in called without a mock.' } + } +} + +AfterAll { + Remove-Module ScriptMessage -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-ScriptMessageContext' { + Context 'A service with an active connection' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageContext_MicrosoftGraph { + [pscustomobject]@{ + Account = 'sender@example.org' + Scopes = @('Mail.Send', 'Chat.ReadWrite') + TenantId = 'tenant-id' + } + } + } + + It 'returns the service name alongside the properties the service supplied' { + $Context = Get-ScriptMessageContext -Service MicrosoftGraph + + $Context.Service | Should -Be 'MicrosoftGraph' + $Context.Account | Should -Be 'sender@example.org' + $Context.TenantId | Should -Be 'tenant-id' + $Context.Scopes | Should -Contain 'Chat.ReadWrite' + } + + It 'asks the service rather than calling Microsoft Graph itself' { + $null = Get-ScriptMessageContext -Service MicrosoftGraph + + Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageContext_MicrosoftGraph -Times 1 -Exactly + } + + It 'returns the cached context without asking the service again' { + # The first call has no cache to read, so it refreshes and populates one. + $null = Get-ScriptMessageContext -Service MicrosoftGraph + $Cached = Get-ScriptMessageContext -Service MicrosoftGraph -ReturnCachedContext + + $Cached.Account | Should -Be 'sender@example.org' + Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageContext_MicrosoftGraph -Times 1 -Exactly + } + } + + Context 'A service with no active connection' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageContext_MicrosoftGraph { } + } + + It 'still returns the service name and nothing else' { + $Context = Get-ScriptMessageContext -Service MicrosoftGraph + + $Context.Service | Should -Be 'MicrosoftGraph' + @($Context.PSObject.Properties).Count | Should -Be 1 + } + } + + Context 'The Microsoft Graph service function' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-MgContext { [pscustomobject]@{ Account = 'sender@example.org' } } + } + + It 'is the only place that calls Get-MgContext' { + $Context = Get-ScriptMessageContext -Service MicrosoftGraph + + $Context.Account | Should -Be 'sender@example.org' + Should -Invoke -ModuleName ScriptMessage Get-MgContext -Times 1 -Exactly + } + } +} From 4821cdc57547b25f6763af41ffcd47c38028b0ac Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:55:32 -0500 Subject: [PATCH 06/40] Clear the cached service context when disconnecting - Disconnect-ScriptMessage left ScriptMessage_Global_CachedServiceContext untouched, so Get-ScriptMessageContext -ReturnCachedContext kept returning the account and scopes of a session that had already ended. Remove the service's entry from the cache after the service disconnects, which makes the next cached read refresh instead of replaying it. Removing a name the cache does not hold is a no-op, so calling Disconnect-ScriptMessage before anything has asked for the context is unaffected. - Add Pester coverage for Disconnect-ScriptMessage, which had none: it asks the service to disconnect, returns the service name alongside the service's own disconnection information under -ReturnConnectionInfo, returns nothing without that switch, and clears the cache. Mocking the service functions rather than the Microsoft Graph cmdlets keeps the file free of stand-ins. --- CHANGELOG.md | 1 + .../Public/Disconnect-ScriptMessage.ps1 | 5 ++ Tests/Disconnect-ScriptMessage.Tests.ps1 | 50 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 Tests/Disconnect-ScriptMessage.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c53b12..e9adfeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - `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. - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. +- `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. - 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`. diff --git a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 index 4f29b3d..8163822 100644 --- a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 @@ -43,6 +43,11 @@ function Disconnect-ScriptMessage MicrosoftGraph {Disconnect-ScriptMessage_MicrosoftGraph} } + # Drop this service's cached context. 'Get-ScriptMessageContext -ReturnCachedContext' reads that cache, so + # leaving an entry behind would keep reporting the connection this call just ended. Removing a name the + # cache does not hold does nothing, which is the case when nothing has asked for the context yet. + $ScriptMessage_Global_CachedServiceContext.PSObject.Properties.Remove([string]$Service) + # Return the disconnection information, if requested. if ($ReturnConnectionInfo) { diff --git a/Tests/Disconnect-ScriptMessage.Tests.ps1 b/Tests/Disconnect-ScriptMessage.Tests.ps1 new file mode 100644 index 0000000..9091fb2 --- /dev/null +++ b/Tests/Disconnect-ScriptMessage.Tests.ps1 @@ -0,0 +1,50 @@ +# Pester 5 or later. Run from the repository root: +# Invoke-Pester -Path .\Tests +# Nothing here connects to a tenant, and the Microsoft Graph modules do not need to be installed. Mocking the +# service functions rather than the Microsoft Graph cmdlets they call keeps that true without stand-ins. + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force +} + +AfterAll { + Remove-Module ScriptMessage -Force -ErrorAction SilentlyContinue +} + +Describe 'Disconnect-ScriptMessage' { + BeforeAll { + Mock -ModuleName ScriptMessage Disconnect-ScriptMessage_MicrosoftGraph { + [pscustomobject]@{ Account = 'sender@example.org' } + } + Mock -ModuleName ScriptMessage Get-ScriptMessageContext_MicrosoftGraph { + [pscustomobject]@{ Account = 'sender@example.org' } + } + } + + It 'asks the service to disconnect' { + $null = Disconnect-ScriptMessage -Service MicrosoftGraph + + Should -Invoke -ModuleName ScriptMessage Disconnect-ScriptMessage_MicrosoftGraph -Times 1 -Exactly + } + + It 'returns the service name with the disconnection information when asked' { + $Info = Disconnect-ScriptMessage -Service MicrosoftGraph -ReturnConnectionInfo + + $Info.Service | Should -Be 'MicrosoftGraph' + $Info.Account | Should -Be 'sender@example.org' + } + + It 'returns nothing unless -ReturnConnectionInfo is given' { + Disconnect-ScriptMessage -Service MicrosoftGraph | Should -BeNullOrEmpty + } + + It 'clears the cached context, so -ReturnCachedContext stops reporting the old connection' { + # Populate the cache, disconnect, then read it back. A cleared cache forces the read to ask the service + # again, which is the only way a caller can tell that the cached connection is gone. + $null = Get-ScriptMessageContext -Service MicrosoftGraph + $null = Disconnect-ScriptMessage -Service MicrosoftGraph + $null = Get-ScriptMessageContext -Service MicrosoftGraph -ReturnCachedContext + + Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageContext_MicrosoftGraph -Times 2 -Exactly + } +} From 0f8f7d3041ee59dfb923eef9cfc2cc7bd2628d35 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:10:40 -0500 Subject: [PATCH 07/40] Dispatch to messaging services through a service table - Each public cmdlet named MicrosoftGraph in a switch block, five in all, so adding a service meant editing four files in the core. Add a module-scope service table that each file in Services/ registers itself into as it is dot-sourced, and replace every switch with a lookup plus a call. - Record handlers as function NAMES and resolve them with & at call time. Caching a CommandInfo or a script block at registration would bind to the real function and bypass Pester, which mocks by shadowing a command name. - Register-ScriptMessageService rejects a name that is not a MessagingService member, since every -Service parameter is typed to that enum and a service outside it could never be reached. Get-ScriptMessageServiceRegistration throws naming the registered services, replacing a Default arm that said only "Invalid 'Service' value." - Disconnect-ScriptMessage loses its second switch: the loop that merges the service's return information is service-agnostic, so it now runs on whatever comes back, which retires another "break # Terminate the switch statement". - Add Pester coverage: the table holds the Graph handlers, an unknown service names the registered ones, a non-enum name is refused, and all four public cmdlets reach stand-in handlers swapped into the table with no Microsoft Graph mocks loaded, which is what proves the core reaches a service only through the table. Output is unchanged, so there is no changelog entry. --- .../Get-ScriptMessageServiceRegistration.ps1 | 23 ++++ .../Private/Register-ScriptMessageService.ps1 | 41 ++++++ .../Public/Connect-ScriptMessage.ps1 | 6 +- .../Public/Disconnect-ScriptMessage.ps1 | 23 ++-- .../Public/Get-ScriptMessageContext.ps1 | 6 +- ScriptMessage/Public/Send-ScriptMessage.ps1 | 53 ++++---- ScriptMessage/ScriptMessage.psm1 | 5 + ScriptMessage/Services/MicrosoftGraph.ps1 | 9 +- Tests/ServiceDispatch.Tests.ps1 | 119 ++++++++++++++++++ 9 files changed, 233 insertions(+), 52 deletions(-) create mode 100644 ScriptMessage/Private/Get-ScriptMessageServiceRegistration.ps1 create mode 100644 ScriptMessage/Private/Register-ScriptMessageService.ps1 create mode 100644 Tests/ServiceDispatch.Tests.ps1 diff --git a/ScriptMessage/Private/Get-ScriptMessageServiceRegistration.ps1 b/ScriptMessage/Private/Get-ScriptMessageServiceRegistration.ps1 new file mode 100644 index 0000000..b251a40 --- /dev/null +++ b/ScriptMessage/Private/Get-ScriptMessageServiceRegistration.ps1 @@ -0,0 +1,23 @@ +function Get-ScriptMessageServiceRegistration +{ + <# + Returns the service table entry a public cmdlet needs in order to reach a service's handlers. Takes the + service as a string so that no caller has to hold the [MessagingService] type. + #> + + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Service + ) + + $Registration = $script:ScriptMessageServiceTable[$Service] + + if ($null -eq $Registration) + { + $Registered = $script:ScriptMessageServiceTable.Keys -join ', ' + throw "`'$Service`' is not a registered messaging service. Registered services: $Registered." + } + + return $Registration +} diff --git a/ScriptMessage/Private/Register-ScriptMessageService.ps1 b/ScriptMessage/Private/Register-ScriptMessageService.ps1 new file mode 100644 index 0000000..ca41bc3 --- /dev/null +++ b/ScriptMessage/Private/Register-ScriptMessageService.ps1 @@ -0,0 +1,41 @@ +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. + #> + + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$ConnectFunction, + + [Parameter(Mandatory = $true)] + [string]$DisconnectFunction, + + [Parameter(Mandatory = $true)] + [string]$SendFunction, + + [Parameter(Mandatory = $true)] + [string]$GetContextFunction + ) + + # Every public cmdlet types its '-Service' parameter as [MessagingService], so a service registered under a + # name outside that enum could never be reached. Fail at import rather than leaving it silently unusable. + if ($Name -notin [Enum]::GetNames([MessagingService])) + { + throw "Cannot register the messaging service `'$Name`': it is not a member of the MessagingService enum. Add it to the enum in ScriptMessage.psm1 first." + } + + $script:ScriptMessageServiceTable[$Name] = [PSCustomObject]@{ + Name = $Name + ConnectFunction = $ConnectFunction + DisconnectFunction = $DisconnectFunction + SendFunction = $SendFunction + GetContextFunction = $GetContextFunction + } +} diff --git a/ScriptMessage/Public/Connect-ScriptMessage.ps1 b/ScriptMessage/Public/Connect-ScriptMessage.ps1 index 746dcd8..59786cd 100644 --- a/ScriptMessage/Public/Connect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Connect-ScriptMessage.ps1 @@ -43,10 +43,8 @@ function Connect-ScriptMessage } # Connect to the proper service. Each service checks for the modules its allowed message types need. - switch ($Service) - { - MicrosoftGraph {Connect-ScriptMessage_MicrosoftGraph @ConnectionParameters} - } + $Registration = Get-ScriptMessageServiceRegistration -Service $Service + & $Registration.ConnectFunction @ConnectionParameters # Return the connection information, if requested. if ($ReturnConnectionInfo) diff --git a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 index 8163822..5d5afc5 100644 --- a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 @@ -38,10 +38,8 @@ function Disconnect-ScriptMessage ) # Disconnect from the proper service. - $ServiceDisconnectReturnInfo = switch ($Service) - { - MicrosoftGraph {Disconnect-ScriptMessage_MicrosoftGraph} - } + $Registration = Get-ScriptMessageServiceRegistration -Service $Service + $ServiceDisconnectReturnInfo = & $Registration.DisconnectFunction # Drop this service's cached context. 'Get-ScriptMessageContext -ReturnCachedContext' reads that cache, so # leaving an entry behind would keep reporting the connection this call just ended. Removing a name the @@ -63,18 +61,13 @@ function Disconnect-ScriptMessage $ScriptMessageDisconnectReturnInfo | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) } - # Add in disconnection information. - switch ($Service) + # Add in whatever the service returned. A service that reports nothing back leaves the object holding + # only the common information above. + if (-not [string]::IsNullOrEmpty($ServiceDisconnectReturnInfo)) { - MicrosoftGraph { - if ([string]::IsNullOrEmpty($ServiceDisconnectReturnInfo)) - { - break # Terminate the switch statement. - } - foreach ($infoItem in $($ServiceDisconnectReturnInfo.PSObject.Properties)) - { - $ScriptMessageDisconnectReturnInfo | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) - } + foreach ($infoItem in $($ServiceDisconnectReturnInfo.PSObject.Properties)) + { + $ScriptMessageDisconnectReturnInfo | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) } } } diff --git a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 index e779201..9a4d964 100644 --- a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 @@ -59,10 +59,8 @@ function Get-ScriptMessageContext } # Ask the service for its own connection information. - switch ($Service) - { - MicrosoftGraph {$ServiceConnectionInfo = Get-ScriptMessageContext_MicrosoftGraph} - } + $Registration = Get-ScriptMessageServiceRegistration -Service $Service + $ServiceConnectionInfo = & $Registration.GetContextFunction # Add in whatever the service returned. A service with no active connection returns nothing, which # leaves the context object holding only the common information above. diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index 7ee0bc2..be76fea 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -287,6 +287,9 @@ 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. { + # Look the service up first, so an unregistered one stops the call before any other work. + $Registration = Get-ScriptMessageServiceRegistration -Service ([string]$serviceTypeObj.Service) + # Set the connection parameters. $ConnectionParameters = @{ ServiceConfig = $ScriptMessageConfig.$($serviceTypeObj.Service) @@ -321,35 +324,29 @@ function Send-ScriptMessage # Connect to the messaging service, if necessary (e.g., API service). Connect-ScriptMessage -Service $($serviceTypeObj.Service) -ErrorAction Stop - switch ($($serviceTypeObj.Service)) + $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 + } + + # [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)) { - 'MicrosoftGraph' { - $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 - } - - # [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 - } - - Send-ScriptMessage_MicrosoftGraph @SendMessageParameters - } - Default {throw "Invalid `'Service`' value."} + $SendMessageParameters['ChatType'] = $ResolvedChatType } + + & $Registration.SendFunction @SendMessageParameters } } \ No newline at end of file diff --git a/ScriptMessage/ScriptMessage.psm1 b/ScriptMessage/ScriptMessage.psm1 index e8998a0..e0216f7 100644 --- a/ScriptMessage/ScriptMessage.psm1 +++ b/ScriptMessage/ScriptMessage.psm1 @@ -1,6 +1,11 @@ # Global Variables New-Variable -Name 'ScriptMessage_Global_CachedServiceContext' -Value ([PSCustomObject]@{}) -Scope Global -Force +# Module Variables +# Each file in Services/ registers itself here as it is dot-sourced below. Module scope rather than global: +# nothing outside the module reads it, and it is rebuilt from scratch on every import. +$script:ScriptMessageServiceTable = [ordered]@{} + # Aliases # Type Definitions diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index ab0af10..4a13302 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -1063,4 +1063,11 @@ function Send-ScriptMessage_MicrosoftGraph { $null = Disconnect-MgGraph -ErrorAction SilentlyContinue } -} \ No newline at end of file +} + +# Announce this service to the module. Keep this at the bottom of the file, after the functions it names. +Register-ScriptMessageService -Name 'MicrosoftGraph' ` + -ConnectFunction 'Connect-ScriptMessage_MicrosoftGraph' ` + -DisconnectFunction 'Disconnect-ScriptMessage_MicrosoftGraph' ` + -SendFunction 'Send-ScriptMessage_MicrosoftGraph' ` + -GetContextFunction 'Get-ScriptMessageContext_MicrosoftGraph' \ No newline at end of file diff --git a/Tests/ServiceDispatch.Tests.ps1 b/Tests/ServiceDispatch.Tests.ps1 new file mode 100644 index 0000000..b45cda5 --- /dev/null +++ b/Tests/ServiceDispatch.Tests.ps1 @@ -0,0 +1,119 @@ +# Pester 5 or later. Run from the repository root: +# Invoke-Pester -Path .\Tests +# Nothing here connects to a tenant, and the Microsoft Graph modules do not need to be installed. + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force +} + +AfterAll { + Remove-Module ScriptMessage -Force -ErrorAction SilentlyContinue +} + +Describe 'The service table' { + It 'holds the Microsoft Graph service with all four handlers' { + InModuleScope ScriptMessage { + $Registration = Get-ScriptMessageServiceRegistration -Service 'MicrosoftGraph' + + $Registration.ConnectFunction | Should -Be 'Connect-ScriptMessage_MicrosoftGraph' + $Registration.DisconnectFunction | Should -Be 'Disconnect-ScriptMessage_MicrosoftGraph' + $Registration.SendFunction | Should -Be 'Send-ScriptMessage_MicrosoftGraph' + $Registration.GetContextFunction | Should -Be 'Get-ScriptMessageContext_MicrosoftGraph' + } + } + + It 'names the registered services when asked for one it does not have' { + InModuleScope ScriptMessage { + { Get-ScriptMessageServiceRegistration -Service 'NotAService' } | + Should -Throw "*not a registered messaging service*MicrosoftGraph*" + } + } + + It 'refuses to register a service that is not a MessagingService member' { + InModuleScope ScriptMessage { + { Register-ScriptMessageService -Name 'Carrier Pigeon' ` + -ConnectFunction 'c' -DisconnectFunction 'd' -SendFunction 's' -GetContextFunction 'g' } | + Should -Throw '*not a member of the MessagingService enum*' + } + } +} + +Describe 'The public cmdlets dispatch through the service table' { + # Re-point the Microsoft Graph entry at stand-in handlers and drive the public cmdlets with no Microsoft + # Graph mocks loaded at all. If they still work, the core reaches a service only through the table. A fake + # service cannot be registered under a new name while -Service is the MessagingService enum, which is why + # this replaces an existing entry rather than adding one. + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + IncludeBCCInGroupChat = $false + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + + InModuleScope ScriptMessage { + function script:Connect-StandInService { param($ServiceConfig) $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 } } + + $script:OriginalRegistration = $script:ScriptMessageServiceTable['MicrosoftGraph'] + $script:ScriptMessageServiceTable['MicrosoftGraph'] = [PSCustomObject]@{ + Name = 'MicrosoftGraph' + ConnectFunction = 'Connect-StandInService' + DisconnectFunction = 'Disconnect-StandInService' + SendFunction = 'Send-StandInService' + GetContextFunction = 'Get-StandInServiceContext' + } + } + } + + AfterAll { + # Leaving a stand-in in the table would break every later test file that expects the real service. + InModuleScope ScriptMessage { + $script:ScriptMessageServiceTable['MicrosoftGraph'] = $script:OriginalRegistration + } + } + + BeforeEach { + InModuleScope ScriptMessage { $script:StandInCalls = @() } + } + + It 'Connect-ScriptMessage reaches the registered connect handler' { + $null = Connect-ScriptMessage -Service MicrosoftGraph + + InModuleScope ScriptMessage { $script:StandInCalls | Should -Contain 'Connect' } + } + + It 'Disconnect-ScriptMessage reaches the registered disconnect handler' { + $Info = Disconnect-ScriptMessage -Service MicrosoftGraph -ReturnConnectionInfo + + $Info.Service | Should -Be 'MicrosoftGraph' + $Info.StandIn | Should -BeTrue + InModuleScope ScriptMessage { $script:StandInCalls | Should -Contain 'Disconnect' } + } + + It 'Get-ScriptMessageContext reaches the registered context handler' { + $Context = Get-ScriptMessageContext -Service MicrosoftGraph + + $Context.Service | Should -Be 'MicrosoftGraph' + $Context.StandIn | Should -BeTrue + InModuleScope ScriptMessage { $script:StandInCalls | Should -Contain 'GetContext' } + } + + It 'Send-ScriptMessage reaches the registered send handler, connecting on the way' { + $Result = Send-ScriptMessage -Service MicrosoftGraph -Type Mail -From 'sender@example.org' ` + -To 'recipient@example.org' -Subject 'Test subject' -Body 'Test body' + + $Result.StandIn | Should -BeTrue + InModuleScope ScriptMessage { + $script:StandInCalls | Should -Contain 'Connect' + $script:StandInCalls | Should -Contain 'Send' + } + } +} From 5e2b5a27b8054823a0cd2322a7a2bfe77cdb13c1 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:20:03 -0500 Subject: [PATCH 08/40] Read the configuration file once per send - Send-ScriptMessage, Connect-ScriptMessage and Send-ScriptMessage_MicrosoftGraph each read the file, so one send read it three times, and five times for two services. Each read reopens the file, redoes the encoding fallback and reparses the JSON, so a send had no single consistent view of its own settings: editing the file mid-send could hand the connection different credentials than the checks that followed it. - Send-ScriptMessage now reads once and passes the service's section to Connect-ScriptMessage and to the service through a new optional -ServiceConfig parameter. Both still read the file when nobody passes it, so calling either on its own is unchanged. - Resolve the section with the registered service name rather than the -Service parameter's value, because looking a property up by a name held in an array comes back empty. A configuration file with no section for the service now says so instead of carrying $null into the connection. - Add Pester coverage asserting one read across a Mail and Chat send, and that Connect-ScriptMessage on its own still reads the file. No changelog entry: passing a configuration section was already possible through the service functions, and -ServiceConfig is plumbing rather than a feature. The wiki will cover it. --- .../Public/Connect-ScriptMessage.ps1 | 21 +++++++++-- ScriptMessage/Public/Send-ScriptMessage.ps1 | 13 ++++++- ScriptMessage/Services/MicrosoftGraph.ps1 | 16 ++++++-- Tests/Send-ScriptMessage.Tests.ps1 | 37 +++++++++++++++++++ 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/ScriptMessage/Public/Connect-ScriptMessage.ps1 b/ScriptMessage/Public/Connect-ScriptMessage.ps1 index 59786cd..1da54f8 100644 --- a/ScriptMessage/Public/Connect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Connect-ScriptMessage.ps1 @@ -12,6 +12,9 @@ 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. .PARAMETER ReturnConnectionInfo Returns connection information after performing function. @@ -29,6 +32,11 @@ function Connect-ScriptMessage ValueFromPipelineByPropertyName = $true)] [MessagingService]$Service, + [Parameter( + Mandatory = $false, + ValueFromPipelineByPropertyName = $true)] + [pscustomobject]$ServiceConfig, + [parameter( Position=1, Mandatory=$false, @@ -36,12 +44,17 @@ function Connect-ScriptMessage ValueFromPipelineByPropertyName=$true)] [switch]$ReturnConnectionInfo ) - - # Set the connection parameters. + + # 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. + if (-not $PSBoundParameters.ContainsKey('ServiceConfig')) + { + $ServiceConfig = Get-ScriptMessageConfig -Service $Service + } $ConnectionParameters = @{ - ServiceConfig = Get-ScriptMessageConfig -Service $Service + ServiceConfig = $ServiceConfig } - + # Connect to the proper service. Each service checks for the modules its allowed message types need. $Registration = Get-ScriptMessageServiceRegistration -Service $Service & $Registration.ConnectFunction @ConnectionParameters diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index be76fea..ed88f86 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -290,9 +290,17 @@ function Send-ScriptMessage # 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) + { + 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 = $ScriptMessageConfig.$($serviceTypeObj.Service) + ServiceConfig = $ServiceConfig } # Set default values for anything the caller did not specify. These test the bound parameters rather than @@ -322,7 +330,7 @@ function Send-ScriptMessage } # Connect to the messaging service, if necessary (e.g., API service). - Connect-ScriptMessage -Service $($serviceTypeObj.Service) -ErrorAction Stop + Connect-ScriptMessage -Service $($serviceTypeObj.Service) -ServiceConfig $ServiceConfig -ErrorAction Stop $SendMessageParameters = [ordered]@{ From = $From @@ -337,6 +345,7 @@ function Send-ScriptMessage SenderId = $SenderId Type = $serviceTypeObj.Type IncludeBCCInGroupChat = $ResolvedIncludeBCCInGroupChat + ServiceConfig = $ServiceConfig } # [ChatType] cannot be bound to $null, nor to the empty string a configuration file uses for a setting diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 4a13302..729640d 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -512,15 +512,23 @@ function Send-ScriptMessage_MicrosoftGraph Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] - [bool]$IncludeBCCInGroupChat + [bool]$IncludeBCCInGroupChat, + + [Parameter( + Mandatory = $false, + ValueFromPipelineByPropertyName = $true)] + [pscustomobject]$ServiceConfig ) # Set the Service ID. - # Keep this as a STRING and not the ENUM type since it's returned to the caller (functions will convert to [MessagingService] type as needed). + # Keep this as a STRING and not the ENUM type since it's returned to the caller (functions will convert to [MessagingService] type as needed). [string]$ServiceId = 'MicrosoftGraph' - # Get the Service Config. - $ServiceConfig = Get-ScriptMessageConfig -Service $ServiceId + # Get the Service Config, unless the caller already read it and passed this service's section. + if (-not $PSBoundParameters.ContainsKey('ServiceConfig')) + { + $ServiceConfig = Get-ScriptMessageConfig -Service $ServiceId + } # This service's own boolean settings, checked before anything is sent so a value the module cannot read # stops the send instead of surfacing after the message has already gone out. diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index b33c5dc..071a79d 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -581,6 +581,43 @@ Describe 'Send-ScriptMessage configuration defaults' { } } + Context 'The configuration file is read once per send' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + ChatType = 'OneOnOne' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + # 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 + # settings, because the file can change between them. + It 'reads the configuration file once for a Mail and Chat send' { + $Arguments = $ChatArguments.Clone() + $Arguments['Type'] = @('Mail', 'Chat') + + $null = Send-ScriptMessage @Arguments -WarningAction SilentlyContinue + + Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageConfig -Times 1 -Exactly + } + + 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 + } + } + Context 'A configuration that leaves ChatType blank' { BeforeAll { # Templates/config_scriptmessage.json writes a setting it leaves unset as an empty string, so a From 07615b25b3b7461e81c19cbb1e8f65b8de4de2b8 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:40:09 -0500 Subject: [PATCH 09/40] Declare which generic settings each messaging service supports - -MailType was accepted and then ignored: the core never forwarded it, no service acts on it, and the Mail result reads an undefined variable into its MailType property. Services are meant to be interchangeable, so a setting a service cannot act on is an ordinary case, but dropping it without a word is not. - Register-ScriptMessageService now requires -SupportedSetting, the generic unprefixed settings a service honors. Mandatory so that adding a service forces an answer. Microsoft Graph declares ChatType and IncludeBCCInGroupChat, and not MailType. - Send-ScriptMessage warns when a caller supplies a generic setting the service does not declare, then sends anyway. A warning rather than an error because a single call can go to several services and only some may honor it. A $null is skipped, since that means "use the configured value". - The generic defaults stay in the core rather than moving into the service, as the original plan had it. ChatType and IncludeBCCInGroupChat are generic vocabulary, so resolving them per service would duplicate the same merge rule in every service. - Fix the .PARAMETER MailType help directive, whose trailing "# TODO:" comment stopped Get-Help matching it to the parameter, so Get-Help fell back to the code comment above the declaration and showed an internal note. --- CHANGELOG.md | 2 + .../Private/Register-ScriptMessageService.ps1 | 9 +++ ScriptMessage/Public/Send-ScriptMessage.ps1 | 24 +++++++- ScriptMessage/Services/MicrosoftGraph.ps1 | 2 + Tests/ServiceDispatch.Tests.ps1 | 60 ++++++++++++++++++- 5 files changed, 95 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9adfeb..acce9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. +- `Send-ScriptMessage` now reports that `-MailType` was ignored instead of accepting it in silence. No messaging service acts on that parameter, so a call passing it behaved as though it had not, and the returned result's `MailType` property is empty for the same reason. The value is still ignored, but the call now warns which service does not support it and carries on sending. - Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank. +- Minor: `Get-Help Send-ScriptMessage -Parameter MailType` now describes the parameter. It returned an internal note reading "TODO: Implement MailType (similar to ChatType so we can do 1:1 emailing)". - 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`. --- diff --git a/ScriptMessage/Private/Register-ScriptMessageService.ps1 b/ScriptMessage/Private/Register-ScriptMessageService.ps1 index ca41bc3..b35dfc5 100644 --- a/ScriptMessage/Private/Register-ScriptMessageService.ps1 +++ b/ScriptMessage/Private/Register-ScriptMessageService.ps1 @@ -11,6 +11,14 @@ function Register-ScriptMessageService [Parameter(Mandatory = $true)] [string]$Name, + # The generic, unprefixed configuration settings and parameters this service honors. Mandatory so that + # every service has to state its answer: a caller who supplies one a service does not honor is told, + # rather than having the value dropped without a word. A service's own prefixed settings are its + # business and are not listed here. + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$SupportedSetting, + [Parameter(Mandatory = $true)] [string]$ConnectFunction, @@ -33,6 +41,7 @@ function Register-ScriptMessageService $script:ScriptMessageServiceTable[$Name] = [PSCustomObject]@{ Name = $Name + SupportedSetting = $SupportedSetting ConnectFunction = $ConnectFunction DisconnectFunction = $DisconnectFunction SendFunction = $SendFunction diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index ed88f86..5794b78 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -49,8 +49,9 @@ function Send-ScriptMessage .PARAMETER SenderId Specify the account used to send the message request. This might be different than the 'From' parameter in the case of "Send As', "Send on Behalf", delegated mailboxes, etc. If not specified, defaults to the address inside of the 'From' parameter. - .PARAMETER MailType # TODO: Implement OneOnOne emailing. + .PARAMETER MailType Override the default 'MailType' specified in the configuration file for the messaging service being used. Options are 'OneOnOne' or 'Group'. + No messaging service supports this parameter yet, so a value given here is reported as unsupported and ignored. .PARAMETER ChatType Override the default 'ChatType' specified in the configuration file for the messaging service being used. Options are 'OneOnOne' or 'Group'. .PARAMETER IncludeBCCInGroupChat @@ -298,6 +299,27 @@ function Send-ScriptMessage throw "The ScriptMessage configuration file has no `'$($Registration.Name)`' section. Add one for that messaging service, or send from a service the file configures." } + # Tell the caller about any generic setting this service does not honor. Services are meant to be + # interchangeable, so one that cannot act on a setting is an ordinary case rather than an error: the + # rest of the send goes ahead, which matters most when a single call goes to several services and only + # some of them honor it. + foreach ($settingName in @('MailType', 'ChatType', 'IncludeBCCInGroupChat')) + { + if (-not $PSBoundParameters.ContainsKey($settingName)) + { + continue + } + # A $null means "use the configured value", so the caller has expressed no preference to drop. + if ($null -eq $PSBoundParameters[$settingName]) + { + continue + } + if ($Registration.SupportedSetting -notcontains $settingName) + { + Write-Warning -Message "The `'$($Registration.Name)`' messaging service does not support the `'$settingName`' parameter, so it was ignored." + } + } + # Set the connection parameters. $ConnectionParameters = @{ ServiceConfig = $ServiceConfig diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 729640d..9bcff88 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -1074,7 +1074,9 @@ function Send-ScriptMessage_MicrosoftGraph } # Announce this service to the module. Keep this at the bottom of the file, after the functions it names. +# 'MailType' is absent from SupportedSetting on purpose: nothing here acts on it. Register-ScriptMessageService -Name 'MicrosoftGraph' ` + -SupportedSetting 'ChatType', 'IncludeBCCInGroupChat' ` -ConnectFunction 'Connect-ScriptMessage_MicrosoftGraph' ` -DisconnectFunction 'Disconnect-ScriptMessage_MicrosoftGraph' ` -SendFunction 'Send-ScriptMessage_MicrosoftGraph' ` diff --git a/Tests/ServiceDispatch.Tests.ps1 b/Tests/ServiceDispatch.Tests.ps1 index b45cda5..53ddf29 100644 --- a/Tests/ServiceDispatch.Tests.ps1 +++ b/Tests/ServiceDispatch.Tests.ps1 @@ -31,11 +31,69 @@ Describe 'The service table' { It 'refuses to register a service that is not a MessagingService member' { InModuleScope ScriptMessage { - { Register-ScriptMessageService -Name 'Carrier Pigeon' ` + { Register-ScriptMessageService -Name 'Carrier Pigeon' -SupportedSetting @() ` -ConnectFunction 'c' -DisconnectFunction 'd' -SendFunction 's' -GetContextFunction 'g' } | Should -Throw '*not a member of the MessagingService enum*' } } + + It 'records which generic settings Microsoft Graph honors, and which it does not' { + InModuleScope ScriptMessage { + $Supported = (Get-ScriptMessageServiceRegistration -Service 'MicrosoftGraph').SupportedSetting + + $Supported | Should -Contain 'ChatType' + $Supported | Should -Contain 'IncludeBCCInGroupChat' + $Supported | Should -Not -Contain 'MailType' + } + } +} + +Describe 'A generic setting the service does not support' { + BeforeAll { + # Pester can only mock a command that exists, so the stand-in has to come before the mock. + InModuleScope ScriptMessage { + function script:Send-MgUserMail { param($UserId, $BodyParameter, [switch]$PassThru) throw 'Send-MgUserMail stand-in called without a mock.' } + } + + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + ChatType = 'Group' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Send-MgUserMail { $true } + + $MailArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Mail' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + It 'reports -MailType as unsupported and still sends' { + $Warnings = @() + $null = Send-ScriptMessage @MailArguments -MailType Group -WarningVariable Warnings -WarningAction SilentlyContinue + + ($Warnings | Out-String) | Should -BeLike "*does not support the 'MailType' parameter*" + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } + + It 'says nothing about a setting the service does support' { + $Warnings = @() + $null = Send-ScriptMessage @MailArguments -ChatType Group -WarningVariable Warnings -WarningAction SilentlyContinue + + ($Warnings | Out-String) | Should -Not -BeLike '*does not support*' + } } Describe 'The public cmdlets dispatch through the service table' { From 6c6455ba3c0dcbdc969eb7d57d078e8697c15c22 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:30:19 -0500 Subject: [PATCH 10/40] Implement MailType and read boolean settings as opt-in flags - -MailType and the configuration file's MailType setting were accepted and ignored. OneOnOne now sends each unique To, CC, and BCC address its own email with only that address in To; Group, the default, sends one email. Send-ScriptMessage resolves it like ChatType: the parameter beats the configuration file, a missing or blank setting means Group, and an unrecognized value throws before connecting. It is resolved only for a send that includes Mail, so it cannot stop a Chat send. - The Graph service sends one request per recipient, deduplicating addresses without regard to case. A failure for one recipient goes into the result's Error property and the rest are still sent; -ErrorAction Stop makes the catch fire whether or not Send-MgUserMail's error is terminating (Research_Notes/Microsoft-Graph-Behavior.md section 3). The result's MailType property now holds the mail type used. Microsoft Graph declares MailType in -SupportedSetting, and ServiceDispatch.Tests.ps1 narrows the registration to keep exercising the unsupported-setting warning. - Remove Get-ScriptMessageBooleanSetting. The 1.1.0 bug was two reads that turn a quoted "false" into true: a [bool] cast for IncludeBCCInGroupChat and a truthiness test for MgDisconnectWhenDone. Every boolean setting is now an opt-in flag read as -eq $true with the setting on the left, the form the MgDelegatedPermission_* settings already used, so any value other than true means off, the safe state for all four. The rule is in AGENTS.md and the measurements in Research_Notes/PowerShell-Language-Behavior.md section 10. - Add Tests/FileEncoding.Tests.ps1, which enforces the encoding rules in AGENTS.md (no BOM except a manifest that needs one, LF only, ASCII-only PowerShell files) over tracked and untracked, unignored files, and replace the manual check snippet in AGENTS.md with a pointer to it. - Add a Testing section to AGENTS.md: running Invoke-Tests.ps1, how the Pester files avoid a tenant, and that the scratch scripts in Tests/ send real messages. - RELEASING.md: develop allows squash merges for feature branches, but main must be merged back into develop with a merge commit so develop keeps the tagged release commit. - Changelog: add -MailType under Added, drop the entry for the -MailType warning (which never shipped) and the help-text entry, drop the specific quoted-boolean entries, and add a general Minor entry about validation. --- AGENTS.md | 47 ++-- CHANGELOG.md | 11 +- CONTRIBUTING.md | 3 +- RELEASING.md | 18 +- Research_Notes/Microsoft-Graph-Behavior.md | 11 + .../PowerShell-Language-Behavior.md | 37 ++- .../Get-ScriptMessageBooleanSetting.ps1 | 33 --- ScriptMessage/Public/Send-ScriptMessage.ps1 | 43 ++- ScriptMessage/Services/MicrosoftGraph.ps1 | 86 +++++- Tests/FileEncoding.Tests.ps1 | 88 +++++++ Tests/Send-ScriptMessage.Tests.ps1 | 247 ++++++++++++++++-- Tests/ServiceDispatch.Tests.ps1 | 22 +- 12 files changed, 536 insertions(+), 110 deletions(-) delete mode 100644 ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 create mode 100644 Tests/FileEncoding.Tests.ps1 diff --git a/AGENTS.md b/AGENTS.md index 1ca8ec5..75927d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,28 +181,15 @@ without going to look. it and corrupts it. Git will not show you a line-ending mistake: it normalizes before diffing, so a wrong file produces no diff -and leaves `git status` clean. Use this instead, which wants `w/lf` on every row: +and leaves `git status` clean. **`Tests/FileEncoding.Tests.ps1` enforces all of these rules** as part of the +test suite: no byte order mark (a module manifest that holds a non-ASCII character is the one exception), LF line +endings, and only ASCII in PowerShell files. It checks untracked files that are not ignored as well as tracked +ones, so it catches a new file before it is committed, and it skips any file containing a NUL byte. A failure +names each offending file. -```powershell -git ls-files --eol -- '*.ps1' '*.psm1' '*.psd1' '*.md' '*.json' '*.yml' -``` - -Nothing enforces the BOM and ASCII rules automatically yet, so check them before committing. Run this from the -repository root; it prints nothing when every tracked file passes. A module manifest saved with a BOM on purpose -is reported, and is the one expected exception: - -```powershell -git ls-files | ForEach-Object { - $Path = Join-Path $PWD.ProviderPath $_ # .NET does not follow PowerShell's current location - $Bytes = [System.IO.File]::ReadAllBytes($Path) - if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { "BOM: $_" } - if (($_ -match '\.(ps1|psm1|psd1|ps1xml)$') -and ([System.IO.File]::ReadAllText($Path) -match '[^\x00-\x7F]')) { "Non-ASCII: $_" } -} -``` - -New files can pick up CRLF on Windows (VS Code's default line ending follows the operating system), so check -a file you created with the `git ls-files --eol` command above once it is tracked. Never change the line -endings of a file you are not otherwise editing. To fix one that is wrong: +New files can pick up CRLF on Windows (VS Code's default line ending follows the operating system), so run the +tests after creating a file, not only after changing code. Never change the line endings of a file you are not +otherwise editing. To fix one that is wrong: ```powershell $Path = (Resolve-Path -LiteralPath '').ProviderPath # .NET needs a full path @@ -218,6 +205,24 @@ $Text = [System.IO.File]::ReadAllText($Path) `AliasesToExport`. - **Comments describe the code as it is now**, never what it used to do or what a fix changed. Change history belongs in the commit message and the changelog. +- **A boolean configuration setting is an opt-in flag: on only when it equals `$true`.** Read it as + `$ServiceConfig. -eq $true`, with the setting on the left, so missing, `false`, quoted text, and typos + all mean off. Never read one with a `[bool]` cast, a truthiness test, or `$true -eq`: each of those reads the + text `"false"` as true. Name a new boolean setting so that `true` opts in and off is the safe state. + +## Testing + +- **Run `.\Tests\Invoke-Tests.ps1` from the repository root before proposing a commit.** It runs every + `Tests/*.Tests.ps1` file under both Windows PowerShell 5.1 and PowerShell 7, each in a fresh process, and the + CI workflow runs the same script. `-Edition Core` runs PowerShell 7 alone for a quicker check; finish with both, + because the editions behave differently in places. +- **The Pester files never connect to a tenant** and need no Microsoft Graph modules. Mock the service functions + such as `Send-ScriptMessage_MicrosoftGraph`, or the Microsoft Graph cmdlets they call. Pester can only mock a + command that exists, so a test that mocks a Graph cmdlet first defines a stand-in for it inside the module that + throws if reached, as the existing files do. +- **The other scripts in `Tests/` are not tests.** `ScriptMessage_Testing.ps1` and + `ScriptMessage_Testing (Installed Module).ps1` are scratch scripts that load the real configuration from + `Tests/Config/`, so anything run from them sends real messages; the safety rules above apply. ## Research notes diff --git a/CHANGELOG.md b/CHANGELOG.md index acce9a1..bee4a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,20 @@ ## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/1.1.1...develop) +### 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. + - `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. + ### Fixed -- `Send-ScriptMessage` now requires the `IncludeBCCInGroupChat` setting to be an unquoted `true` or `false`, not a quoted string. Quotes make the value text, and any non-empty text was read as `true`. So a configuration file holding `"IncludeBCCInGroupChat": "false"` added BCC recipients to a group chat and made their addresses visible to everyone in it, and passing the string `'false'` to `-IncludeBCCInGroupChat` did the same. Both now stop with an error naming the setting or the parameter; remove the quotes in the configuration file, and pass `$false` to the parameter. -- `Send-ScriptMessage` now requires the `MgDisconnectWhenDone` setting to be an unquoted `true` or `false`, not a quoted string. Quotes make the value text, which was read as `true`, so a configuration file holding `"MgDisconnectWhenDone": "false"` disconnected from Microsoft Graph after sending, the opposite of what the setting asks for. It now stops with an error naming the setting; remove the quotes. - `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. - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. -- `Send-ScriptMessage` now reports that `-MailType` was ignored instead of accepting it in silence. No messaging service acts on that parameter, so a call passing it behaved as though it had not, and the returned result's `MailType` property is empty for the same reason. The value is still ignored, but the call now warns which service does not support it and carries on sending. +- Minor: Improved how `Send-ScriptMessage` validates configuration file settings, so a value in a form a setting does not support is no longer misread. - Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank. -- Minor: `Get-Help Send-ScriptMessage -Parameter MailType` now describes the parameter. It returned an internal note reading "TODO: Implement MailType (similar to ChatType so we can do 1:1 emailing)". - 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`. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7af6c61..fdcc96f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,8 @@ Thank you for helping improve ScriptMessage. This guide covers the everyday work your own test tenant and describe what you tested in the pull request. - **Text files are UTF-8 with LF line endings and no byte order mark, and PowerShell files contain only ASCII.** Windows PowerShell 5.1 misreads non-ASCII characters in PowerShell files saved without a byte order mark. - `.gitattributes` handles line endings, and [AGENTS.md](./AGENTS.md) explains the rules and how to check them. + `.gitattributes` handles line endings, the test suite checks all three rules, and [AGENTS.md](./AGENTS.md) + explains them. - **Rebase only your own branch, and only before it merges.** Never force-push `develop` or `main`. AI assistants working in this repository follow [AGENTS.md](./AGENTS.md), which also holds the detailed rules diff --git a/RELEASING.md b/RELEASING.md index 67c785d..0f56b27 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -95,7 +95,12 @@ Rules for the shared branches: exist on `develop`, so every later release pull request would conflict with it. - **Never rebase or force-push `main` or `develop`.** Release tags cannot move, and rewriting shared history breaks every contributor's clone. Rebase only your own feature branch, and only before it merges. -- **After every release or hotfix, merge `main` back into `develop`**, so `develop` contains the release commit. +- **After every release or hotfix, merge `main` back into `develop` with a merge commit, never a squash or a + rebase**, so `develop` contains the tagged release commit. A squash or a rebase copies `main`'s changes into + new commits instead, so `develop` never contains the tagged commit, and a later release pull request can + conflict with a hotfix that `develop` already holds under a different commit. `develop` also allows squash + merges, for consolidating a feature branch, and GitHub's merge button offers the method used last, so check it + before merging `main` back. - **Stable release tags go only on `main`.** Prerelease tags may go on `develop`. ## Changelog @@ -139,7 +144,7 @@ section. 3. Builds the package once and publishes it to the PowerShell Gallery, in the `psgallery` environment. 4. Creates the GitHub release from the version's changelog section, attaches the same package, and publishes the release. -6. **Merge `main` back into `develop`.** +6. **Merge `main` back into `develop` with a merge commit**, not a squash ([Branches](#branches) explains why). The GitHub release is created last so that a failed check or publish never leaves behind a public release, which immutable releases would lock, for a version that did not ship. @@ -158,8 +163,8 @@ immutable releases would lock, for a version that did not ship. 2. Fix the problem, add tests, bump the patch version, and add a dated changelog section for the hotfix. 3. Open a pull request into `main`, merge it with a merge commit, and tag and push as in [Releasing a version](#releasing-a-version), steps 4 and 5. -4. Merge `main` into `develop`. In `CHANGELOG.md`, keep the `[Unreleased]` section from `develop` at the top, - with the hotfix's section below it. +4. Merge `main` into `develop` with a merge commit. In `CHANGELOG.md`, keep the `[Unreleased]` section from + `develop` at the top, with the hotfix's section below it. ## Prereleases @@ -174,7 +179,8 @@ A repository admin configures these once in the GitHub repository settings. They enforceable: - **Branch rulesets for `main` and `develop`:** require a pull request, require the `Workflow lint` and `Pester` - checks to pass, and block force pushes and deletion. For `main`, allow only merge commits. + checks to pass, and block force pushes and deletion. For `main`, allow only merge commits. For `develop`, also + allow squash merges, for consolidating a feature branch. - **A tag ruleset for release tags:** target tags that look like versions, and block updates and deletion, with bypass only for admins. - **Immutable releases:** enabled, so a published release's tag and assets cannot change. @@ -194,6 +200,6 @@ enforceable: - [ ] The tag is annotated, has no `v` prefix, and is on the merge commit. - [ ] Every job in the Release workflow passed. - [ ] The version appears on the PowerShell Gallery, and the GitHub release has the right notes and package. -- [ ] `main` was merged back into `develop`. +- [ ] `main` was merged back into `develop` with a merge commit. - [ ] Anything that needs a live Microsoft Graph test was tested separately in a test tenant. The automated tests never connect to a tenant. diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index 7d87b49..f8d345b 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -89,6 +89,17 @@ the module has no way to report a later delivery failure. **Unverified:** that `Send-MgUserMail -PassThru` returns only `$true`. A comment in the module says so; it was not checked. +**From source:** with `MailType` `Group`, `Send-ScriptMessage_MicrosoftGraph` calls `Send-MgUserMail` once, with +no `-ErrorAction`, inside a `try` whose `catch` puts the error in the result's `Error` property. With `OneOnOne` +it calls `Send-MgUserMail` once per unique recipient with `-ErrorAction Stop`, so each failure reaches that +recipient's own `catch` and the remaining recipients are still sent. + +**Unverified:** whether a failed `Send-MgUserMail` raises a terminating error. The `Group` send depends on it: if +the error is non-terminating, it goes to the caller's error stream instead of `Error`, and `Status` is empty. + +**Unverified:** how Exchange Online throttles many `sendMail` requests from one mailbox in quick succession, +which a `OneOnOne` send to a long recipient list makes. The sendMail page quoted above gives no figures. + ## 4. From documentation: creating one-on-one and group chats The Create chat page: "Only one one-on-one chat can exist between two members. If a one-on-one chat already diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index b7343f5..92891b7 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -282,17 +282,33 @@ an empty string. `Send-ScriptMessage_MicrosoftGraph` then reports a Chat message `IncludeBCCInGroupChat` needs no such guard: it is resolved through a `[bool]`-constrained variable inside a function, so a configuration file with no `IncludeBCCInGroupChat` setting yields `False`. -The parameter binder is stricter than a cast or a validation set, and that difference is load-bearing. A -`[bool]` or `[Nullable[bool]]` parameter refuses a string, while `[Object]` with `[ValidateSet($null, $true, -$false)]` accepts `'false'` and leaves it a string for a later `[bool]` cast to turn into `True`. A -configuration file value never passes through a binder at all, so it needs its own check. +The parameter binder is stricter than a cast or a validation set. A `[bool]` or `[Nullable[bool]]` parameter +refuses a string, while `[Object]` with `[ValidateSet($null, $true, $false)]` accepts `'false'` and leaves it a +string for a later `[bool]` cast to turn into `True`. A configuration file value never passes through a binder +at all. + +Comparing with `-eq` depends on which side the value is on. Measured on **2026-09-21**, same editions, both +agreeing: + +| Value | `$value -eq $true` | `$true -eq $value` | +| --- | --- | --- | +| `'true'` | `True` | `True` | +| `'false'` | `False` | `True` | +| `'yes'` | `False` | `True` | +| `$null` | `False` | `False` | +| `$false` / `$true` | `False` / `True` | `False` / `True` | + +With the string on the left, `-eq` converts `$true` to the text `True` and compares without regard to case, so +only text reading "true" matches. With `$true` on the left, it converts the string to `[bool]`, which makes every +non-empty string `True`, exactly as a cast does. **From source:** `Send-ScriptMessage` declares `-IncludeBCCInGroupChat` as `[Nullable[bool]]`, which keeps the -three states it needs (unset, true, false) and makes the binder reject a string. A configuration file value -reaches no binder, so `Get-ScriptMessageBooleanSetting` rejects any value that is `-isnot [bool]`. Each layer -calls it for the settings it owns and before anything is sent: `Send-ScriptMessage` for the generic -`IncludeBCCInGroupChat`, and `Send-ScriptMessage_MicrosoftGraph` for its own `MgDisconnectWhenDone`, ahead of -that function's message type loop. Unquoted JSON `true` and `false` arrive as real booleans and are unaffected. +three states it needs (unset, true, false) and makes the binder reject a string. Every boolean configuration +setting is read as an opt-in flag, ` -eq $true` with the setting on the left: `IncludeBCCInGroupChat` in +`Send-ScriptMessage`, `MgDisconnectWhenDone` in `Send-ScriptMessage_MicrosoftGraph`, and +`MgDelegatedPermission_RequestChatReadPermission` and `MgDelegatedPermission_RequestFilesReadWritePermission` in +`Connect-ScriptMessage_MicrosoftGraph`. A missing setting, `false`, quoted text, and a typo all read as off, which +is the safe state of all four. Unquoted JSON `true` and `false` arrive as real booleans. ### What this does not establish @@ -302,3 +318,6 @@ that function's message type loop. Unquoted JSON `true` and `false` arrive as re - `[Nullable[bool]]` and `[CT?]` parameters were not tested here; section 1 covers an unassigned `[Nullable[bool]]` variable only. - Only `$null` was tested as the rejected value. Empty strings and other unconvertible values were not. +- Numbers were measured only with the value on the left, the same day: `1 -eq $true` is `True` and + `0 -eq $true` is `False`. A JSON array or object in a boolean setting was not tested; an array on the left of + `-eq` filters rather than compares. diff --git a/ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 b/ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 deleted file mode 100644 index d0db95b..0000000 --- a/ScriptMessage/Private/Get-ScriptMessageBooleanSetting.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -function Get-ScriptMessageBooleanSetting -{ - [CmdletBinding()] - param( - [Parameter( - Mandatory = $true, - ValueFromPipeline = $true, - ValueFromPipelineByPropertyName = $true)] - [AllowNull()] - [Object]$Value, - - [Parameter( - Mandatory = $true, - ValueFromPipelineByPropertyName = $true)] - [string]$Name - ) - - # An unset setting stays unset. The caller decides what that means for its own setting. - if ($null -eq $Value) - { - return $null - } - - # A JSON boolean arrives as a [bool]. A quoted "true" or "false" arrives as a string, and casting a string - # to [bool] makes every non-empty one $true, so a quoted "false" would mean its opposite. Reject anything - # that is not already a boolean rather than guessing at it. - if ($Value -isnot [bool]) - { - throw "The `'$Name`' setting in the ScriptMessage configuration file must be a boolean written as true or false without quotes. Found the $($Value.GetType().Name) value `'$Value`'." - } - - return $Value -} diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index 5794b78..fa28a77 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -51,7 +51,9 @@ function Send-ScriptMessage If not specified, defaults to the address inside of the 'From' parameter. .PARAMETER MailType Override the default 'MailType' specified in the configuration file for the messaging service being used. Options are 'OneOnOne' or 'Group'. - No messaging service supports this parameter yet, so a value given here is reported as unsupported and ignored. + 'Group' sends one message to all of the To, CC, and BCC recipients. 'OneOnOne' sends each recipient a separate message with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once, even across To, CC, and BCC, receives one message. + Each message counts separately against the messaging service's sending limits. + If neither this parameter nor the configuration file sets a mail type, 'Group' is used. .PARAMETER ChatType Override the default 'ChatType' specified in the configuration file for the messaging service being used. Options are 'OneOnOne' or 'Group'. .PARAMETER IncludeBCCInGroupChat @@ -230,7 +232,6 @@ function Send-ScriptMessage ValueFromPipelineByPropertyName = $true)] [string]$SenderId, - # TODO: Implement MailType (similar to ChatType so we can do 1:1 emailing) [Parameter( Mandatory = $false, ValueFromPipeline = $true, @@ -340,15 +341,42 @@ function Send-ScriptMessage } # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value", which is also what an - # unsupplied [Nullable[bool]] holds. + # unsupplied [Nullable[bool]] holds. The configured value is an opt-in flag, on only when it equals + # $true, so anything else, including quoted text, 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 = Get-ScriptMessageBooleanSetting -Name 'IncludeBCCInGroupChat' ` - -Value $ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat + [bool]$ResolvedIncludeBCCInGroupChat = ($ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat -eq $true) + } + + # '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')) + { + $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`'." + } } # Connect to the messaging service, if necessary (e.g., API service). @@ -378,6 +406,11 @@ function Send-ScriptMessage $SendMessageParameters['ChatType'] = $ResolvedChatType } + if ($null -ne $ResolvedMailType) + { + $SendMessageParameters['MailType'] = $ResolvedMailType + } + & $Registration.SendFunction @SendMessageParameters } } \ No newline at end of file diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 9bcff88..18324f5 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -502,6 +502,12 @@ function Send-ScriptMessage_MicrosoftGraph ValueFromPipelineByPropertyName = $true)] [string]$SenderId, + [Parameter( + Mandatory = $false, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true)] + [MailType]$MailType = 'Group', + [Parameter( Mandatory = $false, ValueFromPipeline = $true, @@ -530,11 +536,6 @@ function Send-ScriptMessage_MicrosoftGraph $ServiceConfig = Get-ScriptMessageConfig -Service $ServiceId } - # This service's own boolean settings, checked before anything is sent so a value the module cannot read - # stops the send instead of surfacing after the message has already gone out. - $MgDisconnectWhenDone = Get-ScriptMessageBooleanSetting -Name 'MgDisconnectWhenDone' ` - -Value $ServiceConfig.MgDisconnectWhenDone - # Send the message on each supported service specified. foreach ($typeItem in $Type) { @@ -597,9 +598,70 @@ function Send-ScriptMessage_MicrosoftGraph { $SenderId = $Message.From.emailAddress.Address # Note: This is correct as 'xxxx.Address' (not 'AddressObj'). It is converted Microsoft's to IMicrosoftGraphRecipient. } - + # Send Email. - $SendEmailMessageResult = Send-MgUserMail -UserId $SenderId -BodyParameter $EmailParams -PassThru + switch ($MailType) + { + Group + { + $SendEmailMessageResult = Send-MgUserMail -UserId $SenderId -BodyParameter $EmailParams -PassThru + } + OneOnOne + { + # Each recipient gets a message with only them in To, so nobody sees who else was sent + # one. An address listed more than once, even across To, CC, and BCC, gets one message; + # hashtable keys compare without case, as email addresses do. + $IndividualRecipients = [System.Collections.Generic.List[Object]]::new() + $SeenAddresses = @{} + foreach ($recipient in @($Message.To) + @($Message.CC) + @($Message.BCC)) + { + $RecipientAddress = $recipient.EmailAddress.Address + if ([string]::IsNullOrWhiteSpace($RecipientAddress) -or $SeenAddresses.ContainsKey($RecipientAddress)) + { + continue + } + $SeenAddresses[$RecipientAddress] = $true + $IndividualRecipients.Add($recipient) + } + + # One recipient's failure is reported and the rest are still sent, so Status is $true only + # when every message went out. -ErrorAction Stop makes a failure reach the catch whether or + # not the cmdlet reports it as a terminating error. + $SentCount = 0 + foreach ($recipient in $IndividualRecipients) + { + # A new request for each recipient rather than one edited in place, so no two + # requests share an object. + $IndividualMessage = [ordered]@{} + foreach ($field in $EmailParams.Message.Keys) + { + $IndividualMessage[$field] = $EmailParams.Message[$field] + } + $IndividualMessage['ToRecipients'] = @($recipient) + $IndividualMessage['CcRecipients'] = $null + $IndividualMessage['BccRecipients'] = $null + $IndividualEmailParams = [ordered]@{ + SaveToSentItems = $EmailParams.SaveToSentItems + Message = $IndividualMessage + } + + try + { + $null = Send-MgUserMail -UserId $SenderId -BodyParameter $IndividualEmailParams -PassThru -ErrorAction Stop + $SentCount++ + } + catch + { + $MgErrorMessages += "Mail not sent to `'$($recipient.EmailAddress.Address)`': $_" + } + } + + if ($SentCount -eq $IndividualRecipients.Count) + { + $SendEmailMessageResult = $true + } + } + } } } catch @@ -684,7 +746,7 @@ function Send-ScriptMessage_MicrosoftGraph $SendScriptMessageResult = [PSCustomObject]@{ MessageService = $ServiceId MessageType = $typeItem - MailType = $MailType # TODO: MAILTYPE + MailType = $MailType Status = $SendEmailMessageResult # The SDK only returns $true and nothing else (and only that because of the 'PassThru') Error = $SendScriptMessageResult_Error SentFrom = $SendScriptMessageResult_SentFrom @@ -1066,17 +1128,17 @@ function Send-ScriptMessage_MicrosoftGraph } } - # Disconnect from the Microsoft Graph API, if enabled in the configuration file. - if ($MgDisconnectWhenDone) + # Disconnect from the Microsoft Graph API, if enabled in the configuration file. The setting is an opt-in flag, + # on only when it equals $true; keep it on the left, because a truthiness test reads the text "false" as $true. + if ($ServiceConfig.MgDisconnectWhenDone -eq $true) { $null = Disconnect-MgGraph -ErrorAction SilentlyContinue } } # Announce this service to the module. Keep this at the bottom of the file, after the functions it names. -# 'MailType' is absent from SupportedSetting on purpose: nothing here acts on it. Register-ScriptMessageService -Name 'MicrosoftGraph' ` - -SupportedSetting 'ChatType', 'IncludeBCCInGroupChat' ` + -SupportedSetting 'MailType', 'ChatType', 'IncludeBCCInGroupChat' ` -ConnectFunction 'Connect-ScriptMessage_MicrosoftGraph' ` -DisconnectFunction 'Disconnect-ScriptMessage_MicrosoftGraph' ` -SendFunction 'Send-ScriptMessage_MicrosoftGraph' ` diff --git a/Tests/FileEncoding.Tests.ps1 b/Tests/FileEncoding.Tests.ps1 new file mode 100644 index 0000000..590e724 --- /dev/null +++ b/Tests/FileEncoding.Tests.ps1 @@ -0,0 +1,88 @@ +# Pester 5 or later. Run .\Tests\Invoke-Tests.ps1 to test under both PowerShell editions, or +# Invoke-Pester -Path .\Tests for the current one. Nothing here imports the module or connects to a tenant: it +# reads the repository's files from disk and checks them against the file encoding rules in AGENTS.md. +# +# Git will not report these problems itself. It normalizes line endings before diffing, so a file whose endings +# are wrong produces no diff, and it says nothing about a byte order mark or a non-ASCII character. + +BeforeAll { + $RepoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).ProviderPath + + # Tracked files plus untracked files that are not ignored, so a new file is checked before it is committed + # while .gitignore (which keeps real configuration files out) is still honored. -z stops Git quoting paths. + $Listed = @((& git -C $RepoRoot ls-files -z --cached --others --exclude-standard) -join '' -split "`0" | + Where-Object { $_ } | Sort-Object -Unique) + if (($LASTEXITCODE -ne 0) -or ($Listed.Count -eq 0)) + { + throw "git ls-files listed nothing under '$RepoRoot' (exit code $LASTEXITCODE). These tests need Git and a clone of the repository." + } + + # ISO-8859-1 maps each byte to the character with the same number, so a regex over the result sees the raw + # bytes, and a byte above 0x7F is a non-ASCII character in any encoding this repository could hold. + $Latin1 = [System.Text.Encoding]::GetEncoding(28591) + + $Checked = [System.Collections.Generic.List[string]]::new() + $WithBom = [System.Collections.Generic.List[string]]::new() + $WithCR = [System.Collections.Generic.List[string]]::new() + $NonAsciiPowerShell = [System.Collections.Generic.List[string]]::new() + + foreach ($Relative in $Listed) + { + $Path = Join-Path $RepoRoot $Relative + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) + { + continue # Tracked, but deleted in the working tree. + } + + $Bytes = [System.IO.File]::ReadAllBytes($Path) + + # A NUL byte is Git's own test for a binary file. Git applies no line-ending rule to one, and none of + # these rules apply either. + if ([Array]::IndexOf($Bytes, [byte]0) -ge 0) + { + continue + } + $Checked.Add($Relative) + + $HasBom = ($Bytes.Length -ge 3) -and ($Bytes[0] -eq 0xEF) -and ($Bytes[1] -eq 0xBB) -and ($Bytes[2] -eq 0xBF) + $ContentStart = if ($HasBom) { 3 } else { 0 } + $HasNonAscii = $Latin1.GetString($Bytes, $ContentStart, $Bytes.Length - $ContentStart) -match '[^\x00-\x7F]' + + # The one exception to both rules: a module manifest cannot write a non-ASCII character as an escape, so + # one that needs such a character is saved with a BOM, which both editions read correctly. + $IsManifestNeedingBom = ($Relative -like '*.psd1') -and $HasBom -and $HasNonAscii + + if ($HasBom -and -not $IsManifestNeedingBom) + { + $WithBom.Add($Relative) + } + if ([Array]::IndexOf($Bytes, [byte]13) -ge 0) + { + $WithCR.Add($Relative) + } + if (($Relative -match '\.(ps1|psm1|psd1|ps1xml)$') -and $HasNonAscii -and -not $IsManifestNeedingBom) + { + $NonAsciiPowerShell.Add($Relative) + } + } +} + +Describe 'Repository file encoding' { + # A check that silently reads nothing would pass every test below. + It 'reads the repository''s text files' { + $Checked | Should -Contain 'ScriptMessage/ScriptMessage.psm1' + $Checked | Should -Contain 'CHANGELOG.md' + } + + It 'no text file starts with a byte order mark' { + $WithBom | Should -BeNullOrEmpty -Because 'text files are UTF-8 without a BOM, except a module manifest that holds a non-ASCII character' + } + + It 'every text file uses LF line endings' { + $WithCR | Should -BeNullOrEmpty -Because '.gitattributes pins every text file to eol=lf; AGENTS.md shows how to convert one' + } + + It 'PowerShell files contain only ASCII' { + $NonAsciiPowerShell | Should -BeNullOrEmpty -Because 'Windows PowerShell 5.1 misreads a non-ASCII character in a file without a BOM; write it as an escape such as [char]0x00E9' + } +} diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index 071a79d..bc94774 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -302,6 +302,219 @@ Describe 'Send-ScriptMessage mail attachment' { } } +Describe 'Send-ScriptMessage mail type' { + BeforeAll { + Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Send-MgUserMail { $true } + + $MailArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Mail' + From = 'sender@example.org' + To = @('a@example.org', 'b@example.org') + CC = 'c@example.org' + BCC = 'd@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + Context 'A configuration that sets MailType to Group' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MailType = 'Group' + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends one message to every recipient' { + $Result = Send-ScriptMessage @MailArguments + + $Result.MailType | Should -Be 'Group' + $Result.Status | Should -BeTrue + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly -ParameterFilter { + (@($BodyParameter.Message.ToRecipients | ForEach-Object { $_.EmailAddress.Address }) -join ',') -eq 'a@example.org,b@example.org' -and + (@($BodyParameter.Message.CcRecipients | ForEach-Object { $_.EmailAddress.Address }) -join ',') -eq 'c@example.org' -and + (@($BodyParameter.Message.BccRecipients | ForEach-Object { $_.EmailAddress.Address }) -join ',') -eq 'd@example.org' + } + } + + # 'OneOnOne' is the first MailType enum member, so its numeric value is 0. A truthiness test on the + # parameter cannot tell it apart from an unsupplied value. + It 'an explicit -MailType OneOnOne wins over the configuration' { + $Result = Send-ScriptMessage @MailArguments -MailType OneOnOne + + $Result.MailType | Should -Be 'OneOnOne' + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 4 -Exactly + } + } + + Context 'A configuration that sets MailType to OneOnOne' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MailType = 'OneOnOne' + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends each recipient a message with only them in To' { + $Result = Send-ScriptMessage @MailArguments + + $Result.MailType | Should -Be 'OneOnOne' + $Result.Status | Should -BeTrue + $Result.Error | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 4 -Exactly + foreach ($Address in @('a@example.org', 'b@example.org', 'c@example.org', 'd@example.org')) + { + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly -ParameterFilter { + (@($BodyParameter.Message.ToRecipients | ForEach-Object { $_.EmailAddress.Address }) -join ',') -eq $Address -and + ($null -eq $BodyParameter.Message.CcRecipients) -and + ($null -eq $BodyParameter.Message.BccRecipients) -and + ($BodyParameter.Message.Subject -eq 'Test subject') + } + } + } + + It 'sends an address listed more than once, in any case, one message' { + $Arguments = $MailArguments.Clone() + $Arguments['To'] = @('a@example.org', 'A@Example.org', 'b@example.org') + $Arguments['CC'] = 'a@example.org' + $Arguments.Remove('BCC') + + $null = Send-ScriptMessage @Arguments + + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 2 -Exactly + } + + It 'an explicit -MailType Group wins over the configuration' { + $Result = Send-ScriptMessage @MailArguments -MailType Group + + $Result.MailType | Should -Be 'Group' + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } + + It 'still sends to the other recipients when one fails, and names the one that failed' { + Mock -ModuleName ScriptMessage Send-MgUserMail { throw 'Mailbox unavailable' } -ParameterFilter { + $BodyParameter.Message.ToRecipients[0].EmailAddress.Address -eq 'b@example.org' + } + + $Result = Send-ScriptMessage @MailArguments + + $Result.Status | Should -Not -BeTrue + @($Result.Error).Count | Should -Be 1 + $Result.Error[0].Type | Should -Be 'Error' + $Result.Error[0].Message | Should -BeLike "*'b@example.org'*Mailbox unavailable*" + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 4 -Exactly + } + } + + Context 'A configuration with no MailType setting' { + BeforeAll { + # A configuration file can leave MailType out entirely. + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends one message to every recipient' { + $Result = Send-ScriptMessage @MailArguments + + $Result.MailType | Should -Be 'Group' + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } + } + + Context 'A configuration that leaves MailType blank' { + BeforeAll { + # Templates/config_scriptmessage.json writes a setting it leaves unset as an empty string. + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MailType = '' + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends one message to every recipient' { + $Result = Send-ScriptMessage @MailArguments + + $Result.MailType | Should -Be 'Group' + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + } + } + + Context 'A configuration with an unrecognized MailType' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MailType = 'Individual' + ChatType = 'OneOnOne' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + Mock -ModuleName ScriptMessage Get-ScriptMessageContext { + [pscustomobject]@{ + Account = 'sender@example.org' + Scopes = @('Chat.ReadWrite') + } + } + Mock -ModuleName ScriptMessage New-MgChat { [pscustomobject]@{ Id = 'new-chat-id' } } + Mock -ModuleName ScriptMessage New-MgChatMessage { [pscustomobject]@{ Id = 'new-message-id' } } + } + + 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 Send-MgUserMail -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() + $Arguments['Type'] = 'Chat' + + $Result = Send-ScriptMessage @Arguments -ErrorAction Stop + + $Result.Error | Should -BeNullOrEmpty + # A one-on-one chat goes to every recipient, BCC included. + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 4 -Exactly + } + } +} + Describe 'Send-ScriptMessage configuration defaults' { BeforeAll { Mock -ModuleName ScriptMessage Connect-ScriptMessage { } @@ -483,7 +696,7 @@ Describe 'Send-ScriptMessage configuration defaults' { } } - Context 'Boolean settings and parameters reject strings' { + Context 'Boolean settings and parameters' { BeforeAll { Mock -ModuleName ScriptMessage Disconnect-MgGraph { } @@ -534,14 +747,17 @@ Describe 'Send-ScriptMessage configuration defaults' { } } - Context 'A configuration file that quotes IncludeBCCInGroupChat' { + # A boolean setting is an opt-in flag, on only when it equals $true. Quoted text is not $true, so a + # quoted "false" must read as off; a [bool] cast would read it as on. + Context 'A configuration file that quotes IncludeBCCInGroupChat as "false"' { BeforeAll { Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { $Config = [pscustomobject]@{ MicrosoftGraph = [pscustomobject]@{ - AllowableMessageTypes = @('Mail') + AllowableMessageTypes = @('Mail', 'Chat') + ChatType = 'Group' IncludeBCCInGroupChat = 'false' - MgPermissionType = 'Application' + MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false } } @@ -549,14 +765,16 @@ Describe 'Send-ScriptMessage configuration defaults' { } } - It 'stops without sending' { - { Send-ScriptMessage @MailArguments } | - Should -Throw "*'IncludeBCCInGroupChat' setting*must be a boolean*" - Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 0 -Exactly + It 'leaves BCC recipients out of the group chat' { + $Result = Send-ScriptMessage @ChatArguments -BCC 'blind@example.org' -WarningAction SilentlyContinue + + @($Result.Error | Where-Object { $_.Type -eq 'Warning' -and $_.Message -like '*not included in the group chat*' }).Count | + Should -Be 1 + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly } } - Context 'A configuration file that quotes MgDisconnectWhenDone' { + Context 'A configuration file that quotes MgDisconnectWhenDone as "false"' { BeforeAll { Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { $Config = [pscustomobject]@{ @@ -571,12 +789,11 @@ Describe 'Send-ScriptMessage configuration defaults' { } } - # The Microsoft Graph service consumes this setting after its message type loop, so it checks the - # value before that loop to keep an unreadable one from surfacing after the message has gone out. - It 'stops without sending' { - { Send-ScriptMessage @MailArguments } | - Should -Throw "*'MgDisconnectWhenDone' setting*must be a boolean*" - Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 0 -Exactly + It 'sends and stays connected' { + $null = Send-ScriptMessage @MailArguments + + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly + Should -Invoke -ModuleName ScriptMessage Disconnect-MgGraph -Times 0 -Exactly } } } diff --git a/Tests/ServiceDispatch.Tests.ps1 b/Tests/ServiceDispatch.Tests.ps1 index 53ddf29..6606cda 100644 --- a/Tests/ServiceDispatch.Tests.ps1 +++ b/Tests/ServiceDispatch.Tests.ps1 @@ -37,13 +37,11 @@ Describe 'The service table' { } } - It 'records which generic settings Microsoft Graph honors, and which it does not' { + It 'records that Microsoft Graph honors every generic setting' { InModuleScope ScriptMessage { $Supported = (Get-ScriptMessageServiceRegistration -Service 'MicrosoftGraph').SupportedSetting - $Supported | Should -Contain 'ChatType' - $Supported | Should -Contain 'IncludeBCCInGroupChat' - $Supported | Should -Not -Contain 'MailType' + ($Supported | Sort-Object) -join ', ' | Should -Be 'ChatType, IncludeBCCInGroupChat, MailType' } } } @@ -55,6 +53,15 @@ Describe 'A generic setting the service does not support' { function script:Send-MgUserMail { param($UserId, $BodyParameter, [switch]$PassThru) throw 'Send-MgUserMail stand-in called without a mock.' } } + # Microsoft Graph honors every generic setting, so narrow its entry to leave one out. A service cannot + # be registered under a new name while -Service is the MessagingService enum. + InModuleScope ScriptMessage { + $script:OriginalRegistration = $script:ScriptMessageServiceTable['MicrosoftGraph'] + $Narrowed = $script:OriginalRegistration.PSObject.Copy() + $Narrowed.SupportedSetting = @('ChatType', 'IncludeBCCInGroupChat') + $script:ScriptMessageServiceTable['MicrosoftGraph'] = $Narrowed + } + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { $Config = [pscustomobject]@{ MicrosoftGraph = [pscustomobject]@{ @@ -80,6 +87,13 @@ Describe 'A generic setting the service does not support' { } } + AfterAll { + # Leaving the narrowed entry in the table would break every later test that expects the real service. + InModuleScope ScriptMessage { + $script:ScriptMessageServiceTable['MicrosoftGraph'] = $script:OriginalRegistration + } + } + It 'reports -MailType as unsupported and still sends' { $Warnings = @() $null = Send-ScriptMessage @MailArguments -MailType Group -WarningVariable Warnings -WarningAction SilentlyContinue From 2e1e5bd4d25bf6d14422b2f07d8a0226eae036d7 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:40:18 -0500 Subject: [PATCH 11/40] Remove the SupportedSetting declaration from the service table - Register-ScriptMessageService's -SupportedSetting listed the generic settings a service honors, and Send-ScriptMessage warned when a caller passed one the service did not list. With MailType implemented, Microsoft Graph lists all three, so no real call could reach the warning. - It controlled nothing: Send-ScriptMessage passes every message parameter to every service regardless, so a service's send function already has to declare each one. It also worked per service rather than per message type, covered 3 of about 15 parameters, and wrote only to the warning stream, where scripts that inspect results never see it. - Replace it with a rule in AGENTS.md: a service that cannot honor a setting it receives adds a Warning to that message's Error property and sends what it can, as the Graph chat path already does for sending on behalf of others. - Remove the tests that covered it and the narrowed registration they needed. No changelog entry: the declaration and its warning never shipped. --- AGENTS.md | 5 ++ .../Private/Register-ScriptMessageService.ps1 | 9 --- ScriptMessage/Public/Send-ScriptMessage.ps1 | 21 ------ ScriptMessage/Services/MicrosoftGraph.ps1 | 1 - Tests/ServiceDispatch.Tests.ps1 | 74 +------------------ 5 files changed, 6 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 75927d7..e5e66a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,11 @@ $Text = [System.IO.File]::ReadAllText($Path) `$ServiceConfig. -eq $true`, with the setting on the left, so missing, `false`, quoted text, and typos all mean off. Never read one with a `[bool]` cast, a truthiness test, or `$true -eq`: each of those reads the text `"false"` as true. Name a new boolean setting so that `true` opts in and off is the safe state. +- **A service that cannot honor a setting or parameter it receives says so in its result and sends what it + can.** Add a `Warning` entry to that message's `Error` property, as the Microsoft Graph service does for a + chat sent on behalf of someone else, rather than failing the whole call or dropping the value silently. + `Send-ScriptMessage` passes every message parameter to every service, so each service's send function must + accept all of them. ## Testing diff --git a/ScriptMessage/Private/Register-ScriptMessageService.ps1 b/ScriptMessage/Private/Register-ScriptMessageService.ps1 index b35dfc5..ca41bc3 100644 --- a/ScriptMessage/Private/Register-ScriptMessageService.ps1 +++ b/ScriptMessage/Private/Register-ScriptMessageService.ps1 @@ -11,14 +11,6 @@ function Register-ScriptMessageService [Parameter(Mandatory = $true)] [string]$Name, - # The generic, unprefixed configuration settings and parameters this service honors. Mandatory so that - # every service has to state its answer: a caller who supplies one a service does not honor is told, - # rather than having the value dropped without a word. A service's own prefixed settings are its - # business and are not listed here. - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [string[]]$SupportedSetting, - [Parameter(Mandatory = $true)] [string]$ConnectFunction, @@ -41,7 +33,6 @@ function Register-ScriptMessageService $script:ScriptMessageServiceTable[$Name] = [PSCustomObject]@{ Name = $Name - SupportedSetting = $SupportedSetting ConnectFunction = $ConnectFunction DisconnectFunction = $DisconnectFunction SendFunction = $SendFunction diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index fa28a77..c48096f 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -300,27 +300,6 @@ function Send-ScriptMessage throw "The ScriptMessage configuration file has no `'$($Registration.Name)`' section. Add one for that messaging service, or send from a service the file configures." } - # Tell the caller about any generic setting this service does not honor. Services are meant to be - # interchangeable, so one that cannot act on a setting is an ordinary case rather than an error: the - # rest of the send goes ahead, which matters most when a single call goes to several services and only - # some of them honor it. - foreach ($settingName in @('MailType', 'ChatType', 'IncludeBCCInGroupChat')) - { - if (-not $PSBoundParameters.ContainsKey($settingName)) - { - continue - } - # A $null means "use the configured value", so the caller has expressed no preference to drop. - if ($null -eq $PSBoundParameters[$settingName]) - { - continue - } - if ($Registration.SupportedSetting -notcontains $settingName) - { - Write-Warning -Message "The `'$($Registration.Name)`' messaging service does not support the `'$settingName`' parameter, so it was ignored." - } - } - # Set the connection parameters. $ConnectionParameters = @{ ServiceConfig = $ServiceConfig diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 18324f5..00908ea 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -1138,7 +1138,6 @@ function Send-ScriptMessage_MicrosoftGraph # Announce this service to the module. Keep this at the bottom of the file, after the functions it names. Register-ScriptMessageService -Name 'MicrosoftGraph' ` - -SupportedSetting 'MailType', 'ChatType', 'IncludeBCCInGroupChat' ` -ConnectFunction 'Connect-ScriptMessage_MicrosoftGraph' ` -DisconnectFunction 'Disconnect-ScriptMessage_MicrosoftGraph' ` -SendFunction 'Send-ScriptMessage_MicrosoftGraph' ` diff --git a/Tests/ServiceDispatch.Tests.ps1 b/Tests/ServiceDispatch.Tests.ps1 index 6606cda..b45cda5 100644 --- a/Tests/ServiceDispatch.Tests.ps1 +++ b/Tests/ServiceDispatch.Tests.ps1 @@ -31,83 +31,11 @@ Describe 'The service table' { It 'refuses to register a service that is not a MessagingService member' { InModuleScope ScriptMessage { - { Register-ScriptMessageService -Name 'Carrier Pigeon' -SupportedSetting @() ` + { Register-ScriptMessageService -Name 'Carrier Pigeon' ` -ConnectFunction 'c' -DisconnectFunction 'd' -SendFunction 's' -GetContextFunction 'g' } | Should -Throw '*not a member of the MessagingService enum*' } } - - It 'records that Microsoft Graph honors every generic setting' { - InModuleScope ScriptMessage { - $Supported = (Get-ScriptMessageServiceRegistration -Service 'MicrosoftGraph').SupportedSetting - - ($Supported | Sort-Object) -join ', ' | Should -Be 'ChatType, IncludeBCCInGroupChat, MailType' - } - } -} - -Describe 'A generic setting the service does not support' { - BeforeAll { - # Pester can only mock a command that exists, so the stand-in has to come before the mock. - InModuleScope ScriptMessage { - function script:Send-MgUserMail { param($UserId, $BodyParameter, [switch]$PassThru) throw 'Send-MgUserMail stand-in called without a mock.' } - } - - # Microsoft Graph honors every generic setting, so narrow its entry to leave one out. A service cannot - # be registered under a new name while -Service is the MessagingService enum. - InModuleScope ScriptMessage { - $script:OriginalRegistration = $script:ScriptMessageServiceTable['MicrosoftGraph'] - $Narrowed = $script:OriginalRegistration.PSObject.Copy() - $Narrowed.SupportedSetting = @('ChatType', 'IncludeBCCInGroupChat') - $script:ScriptMessageServiceTable['MicrosoftGraph'] = $Narrowed - } - - Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { - $Config = [pscustomobject]@{ - MicrosoftGraph = [pscustomobject]@{ - AllowableMessageTypes = @('Mail') - ChatType = 'Group' - IncludeBCCInGroupChat = $false - MgPermissionType = 'Application' - MgDisconnectWhenDone = $false - } - } - if ($null -ne $Service) { $Config.$Service } else { $Config } - } - Mock -ModuleName ScriptMessage Connect-ScriptMessage { } - Mock -ModuleName ScriptMessage Send-MgUserMail { $true } - - $MailArguments = @{ - Service = 'MicrosoftGraph' - Type = 'Mail' - From = 'sender@example.org' - To = 'recipient@example.org' - Subject = 'Test subject' - Body = 'Test body' - } - } - - AfterAll { - # Leaving the narrowed entry in the table would break every later test that expects the real service. - InModuleScope ScriptMessage { - $script:ScriptMessageServiceTable['MicrosoftGraph'] = $script:OriginalRegistration - } - } - - It 'reports -MailType as unsupported and still sends' { - $Warnings = @() - $null = Send-ScriptMessage @MailArguments -MailType Group -WarningVariable Warnings -WarningAction SilentlyContinue - - ($Warnings | Out-String) | Should -BeLike "*does not support the 'MailType' parameter*" - Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly - } - - It 'says nothing about a setting the service does support' { - $Warnings = @() - $null = Send-ScriptMessage @MailArguments -ChatType Group -WarningVariable Warnings -WarningAction SilentlyContinue - - ($Warnings | Out-String) | Should -Not -BeLike '*does not support*' - } } Describe 'The public cmdlets dispatch through the service table' { From 76a3057abc8464ebec2ccef5f9075c5b66987a76 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:26:42 -0500 Subject: [PATCH 12/40] Correct the wording about quoted boolean settings - Four places said quoted text in a boolean setting reads as off, but the text "true" in any letter case compares equal to $true, so it reads as on: the rule in AGENTS.md, the From source paragraph in Research_Notes/PowerShell-Language-Behavior.md section 10, the IncludeBCCInGroupChat comment in Send-ScriptMessage, and the comment above the quoted-"false" tests. The code already behaved this way; only the wording changes. - Changelog: the Minor entry about validating configuration file settings overstated it. Nothing is validated, and only IncludeBCCInGroupChat and MgDisconnectWhenDone read differently from 1.1.1; the MgDelegatedPermission_* settings already used -eq $true. Reword it as reading every true/false setting the same way. - Fix the installed-module scratch script, which imported Script-Message instead of ScriptMessage. - Delete Tests/MgGraphDynamicParams.ps1, an unused DynamicParam snippet that nothing in the module or tests references. --- AGENTS.md | 4 +- CHANGELOG.md | 2 +- .../PowerShell-Language-Behavior.md | 5 +- ScriptMessage/Public/Send-ScriptMessage.ps1 | 5 +- Tests/MgGraphDynamicParams.ps1 | 52 ------------------- ...riptMessage_Testing (Installed Module).ps1 | 2 +- Tests/Send-ScriptMessage.Tests.ps1 | 5 +- 7 files changed, 13 insertions(+), 62 deletions(-) delete mode 100644 Tests/MgGraphDynamicParams.ps1 diff --git a/AGENTS.md b/AGENTS.md index e5e66a0..61f030d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,8 +206,8 @@ $Text = [System.IO.File]::ReadAllText($Path) - **Comments describe the code as it is now**, never what it used to do or what a fix changed. Change history belongs in the commit message and the changelog. - **A boolean configuration setting is an opt-in flag: on only when it equals `$true`.** Read it as - `$ServiceConfig. -eq $true`, with the setting on the left, so missing, `false`, quoted text, and typos - all mean off. Never read one with a `[bool]` cast, a truthiness test, or `$true -eq`: each of those reads the + `$ServiceConfig. -eq $true`, with the setting on the left, so missing, `false`, typos, and any quoted + text except `"true"` (in any letter case, which counts as on) all mean off. Never read one with a `[bool]` cast, a truthiness test, or `$true -eq`: each of those reads the text `"false"` as true. Name a new boolean setting so that `true` opts in and off is the safe state. - **A service that cannot honor a setting or parameter it receives says so in its result and sends what it can.** Add a `Warning` entry to that message's `Error` property, as the Microsoft Graph service does for a diff --git a/CHANGELOG.md b/CHANGELOG.md index bee4a67..46f6f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. -- Minor: Improved how `Send-ScriptMessage` validates configuration file settings, so a value in a form a setting does not support is no longer misread. +- Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - 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`. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 92891b7..ae80169 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -307,8 +307,9 @@ three states it needs (unset, true, false) and makes the binder reject a string. setting is read as an opt-in flag, ` -eq $true` with the setting on the left: `IncludeBCCInGroupChat` in `Send-ScriptMessage`, `MgDisconnectWhenDone` in `Send-ScriptMessage_MicrosoftGraph`, and `MgDelegatedPermission_RequestChatReadPermission` and `MgDelegatedPermission_RequestFilesReadWritePermission` in -`Connect-ScriptMessage_MicrosoftGraph`. A missing setting, `false`, quoted text, and a typo all read as off, which -is the safe state of all four. Unquoted JSON `true` and `false` arrive as real booleans. +`Connect-ScriptMessage_MicrosoftGraph`. A missing setting, `false`, a typo, and quoted text all read as off, which +is the safe state of all four settings, except that quoted `"true"` in any letter case reads as on, as the table +above shows. Unquoted JSON `true` and `false` arrive as real booleans. ### What this does not establish diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index c48096f..4b11f7b 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -321,8 +321,9 @@ function Send-ScriptMessage # '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, so anything else, including quoted text, leaves BCC recipients out. Keep the setting on the - # left: a [bool] cast, or $true on the left, reads the text "false" as $true. + # $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 diff --git a/Tests/MgGraphDynamicParams.ps1 b/Tests/MgGraphDynamicParams.ps1 deleted file mode 100644 index 2b634ad..0000000 --- a/Tests/MgGraphDynamicParams.ps1 +++ /dev/null @@ -1,52 +0,0 @@ -DynamicParam -{ - # Initialize Parameter Dictionary - $ParameterDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() - - # Make Mg* parameters appear only if messaging 'Service' is 'MicrosoftGraph'. - if ($Service -eq 'MicrosoftGraph') - { - $ParameterAttributes = [System.Management.Automation.ParameterAttribute]@{ - ParameterSetName = "MicrosoftGraph" - Mandatory = $true - ValueFromPipeline = $true - ValueFromPipelineByPropertyName = $true - } - - $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() - $AttributeCollection.Add($ParameterAttributes) - - $DynamicParameter1 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgPermissionType', [string], $AttributeCollection) # Delegated or Application. See: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#permission-types and https://docs.microsoft.com/en-us/graph/auth/auth-concepts#delegated-and-application-permissions. - - $DynamicParameter2 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgDisconnectWhenDone', [bool], $AttributeCollection) - - $DynamicParameter3 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgTenantID', [string], $AttributeCollection) - - $DynamicParameter4 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgClientID', [string], $AttributeCollection) - - $ParameterDictionary.Add('MgPermissionType', $DynamicParameter1) - $ParameterDictionary.Add('MgDisconnectWhenDone', $DynamicParameter2) - $ParameterDictionary.Add('MgTenantID', $DynamicParameter3) - $ParameterDictionary.Add('MgClientID', $DynamicParameter4) - } - - return $ParameterDictionary -} - -begin -{ - # Set Variables From Dynamic Parameters - switch ($Service) - { - 'MicrosoftGraph' { - $MgPermissionType = $PSBoundParameters['MgPermissionType'] - $MgDisconnectWhenDone = $PSBoundParameters['MgDisconnectWhenDone'] - $MgTenantID = $PSBoundParameters['MgTenantID'] - $MgClientID = $PSBoundParameters['MgClientID'] - } - } -} diff --git a/Tests/ScriptMessage_Testing (Installed Module).ps1 b/Tests/ScriptMessage_Testing (Installed Module).ps1 index da82efd..2f65614 100644 --- a/Tests/ScriptMessage_Testing (Installed Module).ps1 +++ b/Tests/ScriptMessage_Testing (Installed Module).ps1 @@ -19,7 +19,7 @@ $ErrorActionPreference = 'Stop' ############################## # Import ScriptMessage Module -Import-Module Script-Message +Import-Module ScriptMessage # Set ScriptMessage MessagingConfiguration Path Set-ScriptMessageConfigFilePath -Path "$PSScriptRoot\Config\config_scriptmessage.json" diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index bc94774..7a8b0bb 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -747,8 +747,9 @@ Describe 'Send-ScriptMessage configuration defaults' { } } - # A boolean setting is an opt-in flag, on only when it equals $true. Quoted text is not $true, so a - # quoted "false" must read as off; a [bool] cast would read it as on. + # A boolean setting is an opt-in flag, on only when it equals $true. Text equals $true only when it + # reads "true" in any letter case, so a quoted "false" must read as off; a [bool] cast would read it + # as on. Context 'A configuration file that quotes IncludeBCCInGroupChat as "false"' { BeforeAll { Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { From 25ab5b4e9099bcc0f236aa93537fcc512f999210 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:48:11 -0500 Subject: [PATCH 13/40] Report why the configuration file could not be used Get-ScriptMessageConfig reported every failure as "Can't find the JSON configuration file" and told the caller to create one with Set-ScriptMessageConfigFilePath, which only stores the path. A missing file, a file that cannot be read, and a file that is not valid JSON now get separate messages that include the reason. Windows PowerShell 5.1's ConvertFrom-Json ends many of its messages with the entire input, which here is the configuration file and its secrets, so the parse error keeps only the reason in front of it. Measured in Research_Notes/PowerShell-Language-Behavior.md section 11. --- CHANGELOG.md | 1 + .../PowerShell-Language-Behavior.md | 50 +++++++++++++++++++ .../Public/Get-ScriptMessageConfig.ps1 | 39 +++++++++++---- Tests/Get-ScriptMessageConfig.Tests.ps1 | 20 ++++++++ 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f6f20..dc5ba4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - 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. --- ## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index ae80169..c11ed19 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -322,3 +322,53 @@ above shows. Unquoted JSON `true` and `false` arrive as real booleans. - Numbers were measured only with the value on the left, the same day: `1 -eq $true` is `True` and `0 -eq $true` is `False`. A JSON array or object in a boolean setting was not tested; an array on the left of `-eq` filters rather than compares. + +## 11. Measured: catching file errors, and `ConvertFrom-Json` messages that repeat their input + +Measured on **2026-09-21**, same machine and editions as the header, in a standalone script with the module not +imported. The editions differ on `ConvertFrom-Json`, as the second table shows. + +A .NET method called from PowerShell throws its exception wrapped in a `MethodInvocationException`. A typed +`catch []` still matches the exception inside, and there `$_.Exception` is that inner exception. A plain +`catch` receives the wrapper, and `$_.Exception.GetBaseException()` returns the inner one. Each path below went +through `$PSCmdlet.GetUnresolvedProviderPathFromPSPath()` and then `[System.IO.StreamReader]::new()`: + +| Path | Exception | Matched by a typed `catch` for it | +| --- | --- | --- | +| a file that does not exist | `System.IO.FileNotFoundException`: "Could not find file '...'" with the full path | yes | +| a file in a folder that does not exist | `System.IO.DirectoryNotFoundException`: "Could not find a part of the path '...'" | yes | +| a drive that does not exist, `Q:\missing.json` | `System.Management.Automation.DriveNotFoundException`, from the path resolution | yes | +| a folder | `System.UnauthorizedAccessException`: "Access to the path '...' is denied." | not tried; a plain `catch` got it | + +`ConvertFrom-Json -ErrorAction Stop` throws `System.ArgumentException` in both editions. Windows PowerShell 5.1 +ends many of its messages with a position in parentheses, such as `(45):`, followed by the entire text it was +given: + +| Input | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| a missing colon, `"MgClientID" "client-id"` | `Invalid object passed in, ':' or '}' expected. (45):` then the input | `Invalid character after parsing property name. Expected ':' but got: ". Path 'MgTenantID', line 1, position 44.` | +| a missing closing `}` | `Invalid object passed in, ':' or '}' expected. (83):` then the input | `Unexpected end when deserializing object. Path 'Secret', line 1, position 83.` | +| a single backslash, `"C:\Certs\a.pfx"` | `Unrecognized escape sequence. (44):` then the input | `Bad JSON escape sequence: \C. Path 'Path', line 1, position 44.` | +| a missing closing `]` | `Invalid array passed in, ',' expected. (32):` then the input | no error | +| `not json` | `Invalid JSON primitive: not.` | `Unexpected character encountered while parsing value: n. Path '', line 0, position 0.` | +| a missing value, `"Bad": ,`, or a trailing comma | `Invalid JSON primitive: .` | no error | +| an empty string | no error; the result is `$null` | the same | + +Every PowerShell 7 message starts with `Conversion from JSON failed with error:`, omitted above. A 5,076-character +input with a missing colon near its start gave a 5,129-character message on 5.1 ending with the input's last +line, so the input is not truncated. + +**From source:** `Get-ScriptMessageConfig` catches the three not-found exceptions above by type to report a +missing file, and reports anything else from reading the file as unreadable, both with +`GetBaseException().Message`. It removes the `():` position and everything after it from a `ConvertFrom-Json` message, so +the configuration file, which holds the tenant ID, client ID, and encrypted secrets, never reaches the error. An +empty configuration file reads as `$null` without an error in both editions. + +### What this does not establish + +- Only the inputs above were tried. That every 5.1 message repeating the input uses the `():` form is + inferred from them, not read from the source of the serializer 5.1 uses. +- A message without that form can still quote one token: `Invalid JSON primitive` names the unquoted text it + stopped at, so an unquoted secret would appear in it. +- What PowerShell 7 builds from the input it accepts but 5.1 rejects was not examined. +- A file the account has no permission to read was not tested, only a folder given as the path. diff --git a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 index dc85ee2..752ee16 100644 --- a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 @@ -45,7 +45,7 @@ function Get-ScriptMessageConfig throw "`'`$ScriptMessage_Global_ConfigFilePath`' is not specified. Don't forget to first use the `'Set-ScriptMessageConfigFilePath`' cmdlet!" } - # Get Config and Secrets + # Read the configuration file. try { # .NET resolves a relative path against the process directory, not PowerShell's current location. @@ -67,19 +67,36 @@ function Get-ScriptMessageConfig { $Reader.Dispose() } + } + catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException], [System.Management.Automation.DriveNotFoundException] + { + throw "Can't find the JSON configuration file. $($_.Exception.GetBaseException().Message)" + } + catch + { + throw "Can't read the JSON configuration file. $($_.Exception.GetBaseException().Message)" + } - $ScriptMessageConfig = $ConfigText | ConvertFrom-Json - if (-not ($null -eq $Service)) - { - return $ScriptMessageConfig.$Service - } - else - { - return $ScriptMessageConfig - } + # Parse it. + try + { + $ScriptMessageConfig = $ConfigText | ConvertFrom-Json -ErrorAction Stop } catch { - throw "Can't find the JSON configuration file. Use 'Set-ScriptMessageConfigFilePath' to create one." + # Windows PowerShell 5.1 ends many of these messages with the entire text it was parsing, which here is the + # configuration file and its secrets, so keep only the reason in front of it. See section 11 of + # Research_Notes/PowerShell-Language-Behavior.md. + $Reason = $_.Exception.Message -replace '(?s) \(\d+\): .*$', '' + throw "The configuration file '$FullPath' is not valid JSON. $Reason" + } + + if (-not ($null -eq $Service)) + { + return $ScriptMessageConfig.$Service + } + else + { + return $ScriptMessageConfig } } \ No newline at end of file diff --git a/Tests/Get-ScriptMessageConfig.Tests.ps1 b/Tests/Get-ScriptMessageConfig.Tests.ps1 index 44da781..e6cea73 100644 --- a/Tests/Get-ScriptMessageConfig.Tests.ps1 +++ b/Tests/Get-ScriptMessageConfig.Tests.ps1 @@ -104,3 +104,23 @@ Describe 'Get-ScriptMessageConfig paths' { { Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'missing.json') } | Should -Throw -ExpectedMessage "Can't find the JSON configuration file*" } } + +Describe 'Get-ScriptMessageConfig errors' { + It 'reports a folder that does not exist' { + { Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'No Such Folder\config.json') } | + Should -Throw -ExpectedMessage "Can't find the JSON configuration file*No Such Folder*" + } + + It 'reports a path it cannot read as a file' { + { Get-ScriptMessageConfig -Path $TestDrive } | Should -Throw -ExpectedMessage "Can't read the JSON configuration file*" + } + + # Windows PowerShell 5.1 ends its message for this mistake with the whole file, secrets included. + It 'reports a file that is not valid JSON, without repeating its contents' { + $Path = Join-Path $TestDrive 'invalid.json' + [System.IO.File]::WriteAllText($Path, '{ "MicrosoftGraph": { "MgClientID" "not-a-real-secret" } }') + + $ErrorRecord = { Get-ScriptMessageConfig -Path $Path } | Should -Throw -ExpectedMessage "The configuration file '*invalid.json' is not valid JSON.*" -PassThru + $ErrorRecord.Exception.Message | Should -Not -BeLike '*not-a-real-secret*' + } +} From df62c97b6f931fc0c08effddcfcffc8e29e4e0ca Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:09:41 -0500 Subject: [PATCH 14/40] Stop MgApp_CertificatePath from running code in the configuration file Connect-ScriptMessage_MicrosoftGraph passed the setting through ExpandString, so a $(...) subexpression in the configuration file ran as the sending account on PowerShell 7.4 and later. It now expands only $env:NAME and ${env:NAME} and uses the rest of the path as written, which also stops a literal '$name' folder or a backtick in the path from being mangled. Nothing published documented the expansion, and $PSScriptRoot in the setting always meant the module's Services folder, so environment variables are the only use kept. Measurements are in Research_Notes/PowerShell-Language-Behavior.md section 4. --- CHANGELOG.md | 4 ++ .../PowerShell-Language-Behavior.md | 38 ++++++++++++++++- ScriptMessage/Services/MicrosoftGraph.ps1 | 5 ++- Tests/Connect-ScriptMessage.Tests.ps1 | 41 +++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5ba4b..ac1b678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ - 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. +### 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. + --- ## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index c11ed19..44b223e 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -87,8 +87,42 @@ without renaming the property. - `[Environment]::ExpandEnvironmentVariables('%SystemRoot%\x')` returned `C:\Windows\x`. It only substitutes environment variables. -**From source:** `Connect-ScriptMessage_MicrosoftGraph` passes the `MgApp_CertificatePath` configuration value -through `ExpandString`, so that setting can execute code written into the configuration file. +What `ExpandString` can reach when a module function calls it was measured on **2026-09-21** with PowerShell +**7.6.6** only, using a stand-in module laid out like this one: a `.psm1` that dot-sources a function from a +`Services\` file, and that function passing its argument to `ExpandString`. The calling script lived in a +different folder, set a script variable, and set a global one. + +| Text expanded | Calling script run with `pwsh -File` | Calling script run with `&` | +| --- | --- | --- | +| `$PSScriptRoot\cert.pfx` | the module's `Services` folder | the same | +| a variable the calling script set | its value | empty | +| a global variable | its value | its value | +| `$env:SystemRoot\cert.pfx`, `${env:ProgramFiles(x86)}\cert.pfx` | expanded | expanded | +| `$HOME\cert.pfx` | expanded | expanded | +| `C:\certs\$app\cert.pfx` | `C:\certs\\cert.pfx` | the same | +| ``C:\certs\a`b.pfx`` | `C:\certs\a.pfx` | the same | +| `\\server\c$\certs\cert.pfx` | unchanged | the same | + +`$PSScriptRoot` inside a function is the folder of the file that defines the function, never the caller's. The +same day, the real `Connect-ScriptMessage_MicrosoftGraph`, with `Get-PfxCertificate` and `Connect-MgGraph` +replaced by stand-ins inside the module, passed `...\ScriptMessage\Services\Config\PrivateKeyCertificate.pfx` to +`Get-PfxCertificate` for the setting `$PSScriptRoot\Config\PrivateKeyCertificate.pfx`. + +**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 uses the rest as written. A +scriptblock replacement needs PowerShell 6 or later, which is safe there because certificate file +authentication throws before PowerShell 7.4. + +### 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. +- **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. ## 5. Measured: looking up a property by a name held in an array diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 00908ea..aaeeae4 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -372,7 +372,10 @@ function Connect-ScriptMessage_MicrosoftGraph throw $NewMessage } - $MgApp_CertificatePath = $ExecutionContext.InvokeCommand.ExpandString($ServiceConfig.MgApp_CertificatePath) + # Expand only environment variables, so the configuration file cannot run code; the rest is used as written. + $MgApp_CertificatePath = $ServiceConfig.MgApp_CertificatePath -replace '\$\{env:([^}]+)\}|\$env:(\w+)', { + [Environment]::GetEnvironmentVariable($_.Groups[1].Value + $_.Groups[2].Value) + } # Try accessing private key certificate without password using current process credentials. [X509Certificate]$MgApp_Certificate = $null diff --git a/Tests/Connect-ScriptMessage.Tests.ps1 b/Tests/Connect-ScriptMessage.Tests.ps1 index ed608af..64e7d5a 100644 --- a/Tests/Connect-ScriptMessage.Tests.ps1 +++ b/Tests/Connect-ScriptMessage.Tests.ps1 @@ -68,3 +68,44 @@ 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') { + 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 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 } + } + } + + AfterAll { + Remove-Item -Path 'Env:\SCRIPTMESSAGE_TEST_CERTS' -ErrorAction SilentlyContinue + } + + It 'opens when the setting is ' -ForEach @( + @{ Setting = '$env:SCRIPTMESSAGE_TEST_CERTS\app.pfx'; Expected = 'C:\Certs\app.pfx' } + @{ Setting = '${env:SCRIPTMESSAGE_TEST_CERTS}\app.pfx'; Expected = 'C:\Certs\app.pfx' } + @{ Setting = 'C:\Certs\$([string]::Concat(''a'', ''b'')).pfx'; Expected = 'C:\Certs\$([string]::Concat(''a'', ''b'')).pfx' } + @{ Setting = 'C:\Certs\$app\app.pfx'; Expected = 'C:\Certs\$app\app.pfx' } + ) { + $ServiceConfig = [pscustomobject]@{ + AllowableMessageTypes = @('Chat') + MgPermissionType = 'Application' + MgTenantID = 'tenant-id' + MgClientID = 'client-id' + MgApp_AuthenticationType = 'CertificateFile' + MgApp_CertificatePath = $Setting + } + + Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop + Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { $FilePath -eq $Expected } + } +} From f3f771e74a45a01b02cb692db9c9cafaf10f1c36 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:13:56 -0500 Subject: [PATCH 15/40] Record the Microsoft documentation the wiki relies on - Research_Notes/Microsoft-Graph-Behavior.md section 8 records what Microsoft's pages say about the redirect URIs a delegated sign-in with your own app needs, admin consent for the permissions the module requests, certificate and client secret credentials, the certificate stores Connect-MgGraph searches (the SDK source settles a disagreement between two pages), Exchange Online RBAC for Applications, and DPAPI encrypted strings. The wiki's Microsoft 365 page and several Home page entries rely on it. - RELEASING.md's release checklist gains a line for the wiki. It is a separate repository that goes live when pushed, so its update is pushed after the Release workflow passes. --- RELEASING.md | 2 + Research_Notes/Microsoft-Graph-Behavior.md | 88 +++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 0f56b27..df79607 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -200,6 +200,8 @@ enforceable: - [ ] The tag is annotated, has no `v` prefix, and is on the merge commit. - [ ] Every job in the Release workflow passed. - [ ] The version appears on the PowerShell Gallery, and the GitHub release has the right notes and package. +- [ ] The wiki (`Sekers/ScriptMessage.wiki`) describes this release, and its update was pushed after the Release + workflow passed. - [ ] `main` was merged back into `develop` with a merge commit. - [ ] Anything that needs a live Microsoft Graph test was tested separately in a test tenant. The automated tests never connect to a tenant. diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index f8d345b..ce9b3ae 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -6,7 +6,8 @@ persists, and what disconnecting after a send changes. Each claim is tied to the **Only section 7 was measured against a tenant**, on **2026-09-16**; that section gives the environment. Documentation was read on **2026-09-15** from the pages listed under Sources, and the SDK source on -**2026-09-16**. Microsoft revises these pages, so re-read a page before relying on a limit or a permission. +**2026-09-16**, except for section 8, whose pages and SDK source were read on **2026-09-21**. Microsoft revises +these pages, so re-read a page before relying on a limit or a permission. **Claims are labelled with their evidence.** @@ -15,8 +16,8 @@ Documentation was read on **2026-09-15** from the pages listed under Sources, an - **From source** means read out of `ScriptMessage/Services/MicrosoftGraph.ps1` or `ScriptMessage/Public/Send-ScriptMessage.ps1`; it describes what this module does, which is not the same as what Graph does. -- **From SDK source** means read out of the Microsoft Graph PowerShell SDK source at the release tag listed under - Sources; a later SDK release can behave differently. +- **From SDK source** means read out of the Microsoft Graph PowerShell SDK source at the release tag or branch + listed under Sources; a later SDK release can behave differently. - **Inference** means reasoned from documentation or source without a test. - **Unverified** means nobody has tested it or found it documented. Several of these are assumptions the module makes today. @@ -36,6 +37,19 @@ Documentation was read on **2026-09-15** from the pages listed under Sources, an | [Upload small files](https://learn.microsoft.com/en-us/graph/api/driveitem-put-content?view=graph-rest-1.0) | 2026-08-11 | | [Use Microsoft Graph PowerShell authentication commands](https://learn.microsoft.com/en-us/powershell/microsoftgraph/authentication-commands?view=graph-powershell-1.0) | 2026-05-06 | | [Microsoft Graph PowerShell SDK authentication source, tag `2.25.0`](https://github.com/microsoftgraph/msgraph-sdk-powershell/tree/2.25.0/src/Authentication) | none (release tag) | +| [Connect-MgGraph](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.authentication/connect-mggraph?view=graph-powershell-1.0) | 2026-03-02 | +| [Install the Microsoft Graph PowerShell SDK](https://learn.microsoft.com/en-us/powershell/microsoftgraph/installation?view=graph-powershell-1.0) | 2025-07-23 | +| [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) | 2026-09-15 | +| [Scopes and permissions in the Microsoft identity platform](https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc) | 2026-06-25 | +| [Configure how users consent to applications](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent) | 2026-08-04 | +| [How to register an app in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) | 2026-06-15 | +| [How to add a redirect URI to your application](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-redirect-uri) | 2026-06-15 | +| [Add and manage app credentials in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials) | 2026-06-15 | +| [Microsoft Entra authentication and authorization error codes](https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes) | 2026-06-15 | +| [Role Based Access Control for Applications in Exchange Online](https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac) | 2026-08-21 | +| [ConvertFrom-SecureString](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/convertfrom-securestring) | 2026-08-10 | +| [about_Signing](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_signing) | 2026-09-21 | +| [Microsoft Graph PowerShell SDK authentication source, branch `main`](https://github.com/microsoftgraph/msgraph-sdk-powershell/tree/main/src/Authentication) | none (branch, read 2026-09-21) | ## 1. From documentation: permissions for each operation the module performs @@ -234,3 +248,71 @@ authentication type gets `Process` scope. - **Unverified:** runspaces sharing a process, such as `ForEach-Object -Parallel` or thread jobs. The static session suggests they share one connection; this was not tested. - Nothing in this section was measured with delegated permissions or with an SDK release other than 2.25.0. + +## 8. From documentation: app registration, consent, credentials, and limiting mailbox access + +These pages back the setup steps on the wiki's Microsoft 365 page and several setting entries on its Home page. +Nothing in this section was tested against a tenant. + +**From documentation:** + +- **Redirect URIs for delegated sign-in with your own app.** The authentication commands page's steps for a + custom application register `http://localhost` as a **Public client/native** redirect URI, and add: "An + additional Redirect URI is required for Windows Authentication Manager (WAM) broker-based sign-in", entered + under **Mobile and desktop applications** as `ms-appx-web://Microsoft.AAD.BrokerPlugin/`. + The same page turns WAM off with `Set-MgGraphOption -DisableLoginByWAM $true`. The error code page describes + `AADSTS50011` as "The reply address is missing, misconfigured, or doesn't match reply addresses configured for + the app." +- **Admin consent.** The permissions reference lists admin consent as not required for the delegated `Mail.Send`, + `Chat.Create`, `ChatMessage.Send`, `Chat.Read`, `Chat.ReadBasic`, and `Files.ReadWrite`, and as required for the + application `Mail.Send`. The user consent page: "By default, all users are allowed to consent to applications + for permissions that don't require administrator consent", and "Applications that require users to be assigned + to the application must have their permissions consented by an administrator". +- **Certificates and client secrets.** The credentials page: "Microsoft recommends that you use a certificate + instead of a client secret before moving the application to a production environment"; an uploaded certificate + must be a `.cer`, `.pem`, or `.crt` file; "Client secret lifetime is limited to two years (24 months) or less", + and "Microsoft recommends that you set an expiration value of less than 12 months"; the secret's **Value** "is + *never displayed again* after you leave this page". +- **Certificate stores.** The authentication commands page says `-CertificateThumbprint` and `-CertificateName` + load the certificate "from either `Cert:\CurrentUser\My\` or `Cert:\LocalMachine\My\`". The `Connect-MgGraph` + reference page says instead that the certificate "will be retrieved from the current user's certificate store". +- **Limiting mailbox access.** The RBAC for Applications page says the feature "replaces Application Access + Policies", and that "The permissions assigned using Application RBAC act in addition to grants you make in + Microsoft Entra ID", so an unscoped `Mail.Send` grant in Microsoft Entra has to be removed before a scope limits + anything. Assigning the roles needs the Organization Management role group, or the Exchange Administrator role + in Microsoft Entra ID. Changes "are subject to cache maintenance that varies between 30 minutes and 2 hours". + `New-ServicePrincipal` takes the IDs from the Enterprise applications page, not from App registrations. +- **Encrypted standard strings.** The `ConvertFrom-SecureString` page: without a key, "the Windows Data + Protection API (DPAPI) is used", and "The contents of a SecureString aren't encrypted on non-Windows systems". +- **Graph sub-modules.** The installation page: `Microsoft.Graph.Authentication` "is installed by default when you + opt to install the sub modules individually"; installing in one version of PowerShell "doesn't install it for + the other"; Windows PowerShell needs .NET Framework 4.7.2 or later. +- **Blocked downloads.** `about_Signing`: under the RemoteSigned policy, a downloaded unsigned script fails with + `The file cannot be loaded. The file is not digitally signed.`, and `Unblock-File` lets + it run. + +**From SDK source:** at tag `2.25.0` and on `main`, the certificate lookup by thumbprint and by subject name +searches `StoreLocation.CurrentUser` and then `StoreLocation.LocalMachine`. That agrees with the authentication +commands page, not the reference page. At `2.25.0`, WAM is used only when `EnableWAMForMSGraph` is set and the +platform is Windows; on `main`, when the authentication context's `WamEnabled` is true on Windows. + +**From source:** `Connect-ScriptMessage_MicrosoftGraph` calls `Connect-MgGraph` with `-ClientId` and `-TenantId` +for delegated sign-in, so the app registration's redirect URIs apply to it; with `-CertificateThumbprint`, +`-CertificateName`, or `-Certificate` for the three certificate options; and with a credential built from +`MgApp_EncryptedSecret` for `ClientSecret`. It decrypts `MgApp_EncryptedSecret` and +`MgApp_EncryptedCertificatePassword` with `ConvertTo-SecureString`, so both have to be encrypted by the account +that runs the script, on the computer that runs it. + +### What this does not establish + +- **Unverified:** which SDK release made WAM the default on Windows, and whether a delegated sign-in without the + broker redirect URI fails or falls back to the browser. +- **Unverified:** that `openid`, `profile`, `email`, and `offline_access` need no admin consent. The permissions + reference did not return them. The scopes page describes them as standard OpenID Connect scopes shown on the + user consent page, so treating them as user-consentable is **Inference**. +- **Unverified:** that an `Application Mail.Send` assignment in RBAC for Applications works with the + `Send-MgUserMail` call the module makes. The page lists Microsoft Graph as a supported protocol for that role; + no send was tested. +- **Unverified:** the message `ConvertTo-SecureString` gives for a string encrypted by another account or on + another computer. The wiki's troubleshooting entry uses "Key not valid for use in specified state.", the + Windows message usually reported for it; it was not reproduced here. From 4862c7439d0c9521c13b42941527159f7f4ac2a3 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:25:33 -0500 Subject: [PATCH 16/40] Point the research note at the wiki's Microsoft Graph page The wiki's setup page for the MicrosoftGraph service is named after the service, matching -Service and the configuration file's section, rather than Microsoft 365. --- Research_Notes/Microsoft-Graph-Behavior.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index ce9b3ae..a07d24b 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -251,7 +251,7 @@ authentication type gets `Process` scope. ## 8. From documentation: app registration, consent, credentials, and limiting mailbox access -These pages back the setup steps on the wiki's Microsoft 365 page and several setting entries on its Home page. +These pages back the setup steps on the wiki's Microsoft Graph page and several setting entries on its Home page. Nothing in this section was tested against a tenant. **From documentation:** From dba8cd7018b4487b45ad64581512fa619b151acc Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:50:25 -0500 Subject: [PATCH 17/40] Make the PSScriptAnalyzer pin hold, and catch syntax errors The Tests workflow installed PSScriptAnalyzer 1.25.0 so that a new rule could not fail a build that passed before, but nothing made the test use that copy. The windows-latest image installs its own, the newest when the image is built (1.25.0 in 20260907.229.1), and Windows PowerShell 5.1 cannot see the copy pwsh installs, so the 5.1 run always used the image's version. An image update would have changed the rules without any change here. - Tests.yml sets the version once, in PSSCRIPTANALYZER_VERSION, and installs that version. - The test imports exactly that version when the variable is set, and the newest installed copy otherwise. PowerShell itself would load the copy in the first PSModulePath folder that has one, whatever its version. With CI=true, a missing analyzer or pinned version fails the test instead of skipping it. - The test runs under PowerShell 7 only, and names the reason when it skips. 1.25.0 has the same 75 rules in both editions, and syntax only PowerShell 7 accepts already fails every 5.1 test file that imports the module. - Analyze with -Severity Error, ParseError. A syntax error has its own ParseError severity, so filtering on Error alone reported nothing for a planted missing parenthesis. - Research_Notes/File-Encoding-And-Line-Endings.md section 1 now measures a module manifest as well as a script, which is what the AGENTS.md rule for saving a non-ASCII manifest with a BOM cites. Checked: with a fake newer PSScriptAnalyzer first on PSModulePath, the pinned run loads 1.25.0 and passes; a pinned version that is not installed skips locally and fails with CI=true; a planted syntax error fails the test naming the file, line and rule. --- .github/workflows/Tests.yml | 9 ++-- .../File-Encoding-And-Line-Endings.md | 20 +++++++-- Tests/ScriptMessage.Module.Tests.ps1 | 42 +++++++++++++++++-- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 280109c..b431f6f 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -34,14 +34,17 @@ jobs: name: Pester # Windows, because Windows PowerShell 5.1 exists nowhere else. Invoke-Tests.ps1 runs both editions itself. runs-on: windows-latest + env: + # Pinned so a new analyzer rule cannot fail a build that passed before. The runner image ships its own copy, + # so the module tests read this to use exactly this version rather than whichever copy PowerShell finds first. + PSSCRIPTANALYZER_VERSION: '1.25.0' steps: - uses: actions/checkout@v7 - # The module tests run PSScriptAnalyzer when it is installed. Pinned so a new analyzer rule cannot fail a - # build that passed before. + # The module tests run PSScriptAnalyzer under PowerShell 7, and fail in CI rather than skipping without it. - name: Install PSScriptAnalyzer - run: Install-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -TrustRepository + run: Install-PSResource -Name PSScriptAnalyzer -Version $env:PSSCRIPTANALYZER_VERSION -TrustRepository shell: pwsh - name: Run tests diff --git a/Research_Notes/File-Encoding-And-Line-Endings.md b/Research_Notes/File-Encoding-And-Line-Endings.md index 08332d2..ece809d 100644 --- a/Research_Notes/File-Encoding-And-Line-Endings.md +++ b/Research_Notes/File-Encoding-And-Line-Endings.md @@ -6,7 +6,8 @@ happened when this repository was normalized to LF. The rules themselves live in Sections 1 to 4 were measured on **2026-09-15** on Windows 11 (10.0.26200), with Windows PowerShell **5.1.26100.9444**, PowerShell **7.6.6**, and Git **2.54.0.windows.1** with `core.autocrlf=true` set in the -system-wide Git configuration. Section 5 gives its own date. +system-wide Git configuration. Section 5, and the module manifest measurement in section 1, give their own +dates. **Claims are labelled with their evidence.** @@ -30,8 +31,21 @@ A two-line script that assigns one em dash (U+2014) to a string and prints the s Windows PowerShell 5.1 read the em dash's three UTF-8 bytes as three characters, with no error or warning. PowerShell 7 read both files correctly. This is why PowerShell files in this repository are ASCII only rather than UTF-8 with a BOM: with no non-ASCII characters, whether a file has a BOM makes no difference to either -edition. The row saved with a BOM is the fallback for a module manifest that genuinely needs a non-ASCII -character, since a `.psd1` cannot contain an escape such as `[char]0x00E9`. +edition. + +**Measured on 2026-09-23**, on the same machine and PowerShell versions, with ANSI code page 1252: the same +question for a module manifest, the one PowerShell file that may need a BOM, since a `.psd1` cannot contain an +escape such as `[char]0x00E9`. A minimal manifest whose `Author` was `Caf` followed by U+00E9 was saved as UTF-8 +and read with `Import-PowerShellDataFile`, `Test-ModuleManifest`, and `Import-Module`: + +| Saved as | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| UTF-8 without BOM | 5 characters, `Caf` then U+00C3 U+00A9 | 4 characters, `Caf` then U+00E9 | +| UTF-8 with BOM | 4 characters, `Caf` then U+00E9 | 4 characters, `Caf` then U+00E9 | + +All three commands gave the same result in every case, and none raised an error or warning. So a manifest that +genuinely needs a non-ASCII character is saved as UTF-8 with a BOM, which both editions read correctly. Only the +`Author` value and code page 1252 were tested. Section 5 measures the same question for the JSON configuration file. diff --git a/Tests/ScriptMessage.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 index 364e491..04c63e8 100644 --- a/Tests/ScriptMessage.Module.Tests.ps1 +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -18,10 +18,44 @@ Describe 'ScriptMessage module' { ($ExportedFunctions -join ', ') | Should -Be ($PublicFunctions -join ', ') } - # PSScriptAnalyzer is optional locally. The CI workflow installs it for PowerShell 7, so the check always runs - # there. - It 'has no PSScriptAnalyzer errors' -Skip:(-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { - $Findings = @(Invoke-ScriptAnalyzer -Path $ModuleRoot -Recurse -Severity Error) + # PSScriptAnalyzer is optional locally. The check runs under PowerShell 7 only, because Windows PowerShell 5.1 + # cannot see the copy the CI workflow installs; the analyzer has the same rules in both editions, and syntax + # that only PowerShell 7 accepts already fails the 5.1 run when the module imports. + # + # PSSCRIPTANALYZER_VERSION, when set, names the exact version to use. The workflow sets it to the version it + # installs, because the runner image ships its own copy and PowerShell would otherwise load whichever copy it + # finds first. Without it, the newest installed version is used. In CI, where GitHub sets CI=true, a missing + # analyzer fails rather than skips, so the check cannot quietly disappear if the install step breaks. + # + # ParseError is its own severity, so without it a syntax error would report nothing. + It 'has no PSScriptAnalyzer errors' { + if ($PSVersionTable.PSEdition -ne 'Core') + { + Set-ItResult -Skipped -Because 'the analyzer check runs under PowerShell 7 only' + } + + $Installed = @(Get-Module -ListAvailable -Name PSScriptAnalyzer) + if ($env:PSSCRIPTANALYZER_VERSION) + { + $Analyzer = $Installed | Where-Object { $_.Version -eq [version]$env:PSSCRIPTANALYZER_VERSION } | Select-Object -First 1 + $Missing = "PSScriptAnalyzer $env:PSSCRIPTANALYZER_VERSION is not installed" + } + else + { + $Analyzer = $Installed | Sort-Object -Property Version -Descending | Select-Object -First 1 + $Missing = 'PSScriptAnalyzer is not installed' + } + if (-not $Analyzer) + { + if ($env:CI -eq 'true') + { + throw "$Missing, and CI must run this check." + } + Set-ItResult -Skipped -Because $Missing + } + + Import-Module -Name PSScriptAnalyzer -RequiredVersion $Analyzer.Version + $Findings = @(Invoke-ScriptAnalyzer -Path $ModuleRoot -Recurse -Severity Error, ParseError) @($Findings | ForEach-Object { '{0}:{1} {2}' -f $_.ScriptName, $_.Line, $_.RuleName }) | Should -BeNullOrEmpty } From 37ccac387cf2ae88c86ea4c6f8d90b91447e10d7 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:59:16 -0500 Subject: [PATCH 18/40] Record the measurements behind the PSScriptAnalyzer test The analyzer test and the Tests workflow rest on tool behavior that was measured but only written down in a commit message, and several of those facts change with the analyzer version or the runner image. Add Research_Notes/Test-Tooling-Behavior.md: syntax errors appear only under the ParseError severity; the analyzer's rules match across editions but its parsing does not; PowerShell loads the first copy of a module on PSModulePath, not the newest, and a 5.1 process started from pwsh cannot see pwsh's folders; and the runner image installs whatever analyzer version is newest when it is built. The note says what to repeat when PSSCRIPTANALYZER_VERSION changes, and the test and the workflow point at it. --- .github/workflows/Tests.yml | 1 + Research_Notes/Test-Tooling-Behavior.md | 125 ++++++++++++++++++++++++ Tests/ScriptMessage.Module.Tests.ps1 | 2 + 3 files changed, 128 insertions(+) create mode 100644 Research_Notes/Test-Tooling-Behavior.md diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index b431f6f..1cf9408 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -37,6 +37,7 @@ jobs: env: # Pinned so a new analyzer rule cannot fail a build that passed before. The runner image ships its own copy, # so the module tests read this to use exactly this version rather than whichever copy PowerShell finds first. + # Research_Notes/Test-Tooling-Behavior.md says what to recheck when changing it. PSSCRIPTANALYZER_VERSION: '1.25.0' steps: diff --git a/Research_Notes/Test-Tooling-Behavior.md b/Research_Notes/Test-Tooling-Behavior.md new file mode 100644 index 0000000..7bec16e --- /dev/null +++ b/Research_Notes/Test-Tooling-Behavior.md @@ -0,0 +1,125 @@ +# Test tooling behavior + +How PSScriptAnalyzer, PowerShell's module loading, and the GitHub Actions runner image behave where this +repository's tests depend on them. None of this is module behavior; it constrains how the test suite and the Tests +workflow are written, chiefly the `has no PSScriptAnalyzer errors` test in `Tests/ScriptMessage.Module.Tests.ps1` +and the `pester` job in `.github/workflows/Tests.yml`. + +Measured on **2026-09-23** on Windows 11 (10.0.26200), with Windows PowerShell **5.1.26100.9444**, PowerShell +**7.6.6**, PSScriptAnalyzer **1.25.0**, and Pester **6.1.0**. Nothing connected to a tenant. + +**Claims are labelled with their evidence.** + +- **Measured** means observed on the date given. +- **From source** means read from a file in this repository. +- **From documentation** means read from the linked page on the date given. +- **Unverified** means nobody has tested it. + +> **The one-line summary:** PSScriptAnalyzer reports a syntax error only when asked for `ParseError`, it parses +> with the edition running it, and PowerShell loads the first copy of a module it finds rather than the newest. +> Name the analyzer version explicitly, because the runner image ships its own copy. + +**When changing `PSSCRIPTANALYZER_VERSION`** in `.github/workflows/Tests.yml`, repeat sections 1 and 2 with the new +version. + +## 1. Measured: syntax errors are reported only under the `ParseError` severity + +`Invoke-ScriptAnalyzer -ScriptDefinition 'function f { if ($true { } }'` gave the same result in both editions: + +| `-Severity` | Findings | +| --- | --- | +| `Error` | none | +| `Error, ParseError` | `UnexpectedToken` and `MissingEndParenthesisAfterStatement`, both with severity `ParseError` | +| not given | the same two | + +Under PowerShell 7, with a missing `)` planted in an `if` statement in +`ScriptMessage/Private/ConvertTo-ScriptMessageBodyObject.ps1`, `Invoke-ScriptAnalyzer -Path ScriptMessage -Recurse` +reported nothing with `-Severity Error` and `MissingEndParenthesisAfterStatement` on that line with +`-Severity Error, ParseError`. Without the plant, the module had no finding of either severity. + +This is why the analyzer test passes `-Severity Error, ParseError`. + +## 2. Measured: the analyzer's rules are the same in both editions, but its parsing is not + +- `Get-ScriptAnalyzerRule` listed 75 rules in each edition, with identical names. +- `Invoke-ScriptAnalyzer -ScriptDefinition '$a = $true ? 1 : 2' -Severity Error, ParseError` returned nothing under + PowerShell 7 and `UnexpectedToken` under Windows PowerShell 5.1. The analyzer accepts whatever syntax the edition + running it accepts (**Unverified:** that it uses that edition's parser; its source was not read). + +So an analyzer run under PowerShell 7 alone cannot flag syntax that only PowerShell 7 accepts. The rest of the +suite covers that gap: with the ternary `return $true ? $null : $null` planted in +`ConvertTo-ScriptMessageBodyObject`, `.\Tests\Invoke-Tests.ps1 -Edition Desktop` failed 79 tests and passed 7, and +six of the eight test files failed as a whole. + +This is one of the two reasons the analyzer test runs under PowerShell 7 only; section 3 gives the other. + +## 3. Measured: which copy of a module PowerShell loads + +Two folders were put at the front of `PSModulePath` in a fresh process of each edition. The first held a module +`FakeMod` at version 1.0.0 and the second held `FakeMod` 2.0.0, each exporting one function that returned its +version. + +| Load | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| Calling the function (command autoloading) | 1.0.0 | 1.0.0 | +| `Import-Module -Name FakeMod` | 1.0.0 | 1.0.0 | + +PowerShell loaded the copy in the first `PSModulePath` folder that held the module, not the newest version. + +The folders each edition searched on the test machine, in order: + +- PowerShell 7: the user's `Documents\PowerShell\Modules`, `C:\Program Files\PowerShell\Modules`, + `$PSHOME\Modules`, `C:\Program Files\WindowsPowerShell\Modules`, and + `C:\Windows\system32\WindowsPowerShell\v1.0\Modules`. +- Windows PowerShell 5.1, started from PowerShell 7 the way `Tests/Invoke-Tests.ps1` starts it: the user's + `Documents\WindowsPowerShell\Modules`, `C:\Program Files\WindowsPowerShell\Modules`, and + `C:\Windows\system32\WindowsPowerShell\v1.0\Modules`. None of PowerShell 7's own folders. + +**From documentation** ([Install-PSResource](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.psresourceget/install-psresource), +read 2026-09-23): the default scope is `CurrentUser`, which installs to a folder such as +`$home\Documents\PowerShell\Modules`, and `AllUsers` installs to a folder such as +`$env:ProgramFiles\PowerShell\Modules`. A bare version such as `1.25.0` is treated as the required version, not +as a minimum. + +So the copy the Tests workflow installs with `Install-PSResource` lands in one of PowerShell 7's own folders, where +a Windows PowerShell 5.1 test run cannot load it. That is the other reason the analyzer test runs under PowerShell +7 only. It imports the version named in `PSSCRIPTANALYZER_VERSION` with `Import-Module -RequiredVersion` rather +than relying on which copy PowerShell finds first. With a fake PSScriptAnalyzer 9.9.9 placed first on +`PSModulePath`, the test loaded the fake when the variable was not set (it then takes the newest installed copy) +and 1.25.0 when the variable was `1.25.0`. + +## 4. The runner image's own copy + +**From documentation**, read 2026-09-23: + +- The [actions/runner-images README](https://github.com/actions/runner-images) maps `windows-latest` to the Windows + Server 2025 with Visual Studio 2026 image, described in + [Windows2025-VS2026-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md). + For image version 20260907.229.1, that readme lists `PSScriptAnalyzer: 1.25.0` and `Pester: 3.4.0, 5.9.0`. +- The image's + [toolset-2025-vs2026.json](https://github.com/actions/runner-images/blob/main/images/windows/toolsets/toolset-2025-vs2026.json) + lists PSScriptAnalyzer with no version, and + [Install-PowerShellModules.ps1](https://github.com/actions/runner-images/blob/main/images/windows/scripts/build/Install-PowerShellModules.ps1) + installs a module listed that way with `Install-Module -Scope AllUsers` and no version. So each image build takes + whatever PSScriptAnalyzer version is newest on the PowerShell Gallery at the time. + +**Measured** from the logs of two Tests workflow runs, on 2026-09-22 at commit 453ce45 and on 2026-09-23 at +dba8cd7: the `Set up job` step printed `Image: windows-2025-vs2026` and `Version: 20260907.229.1`. In the +2026-09-22 run, before the analyzer test chose its own version, the test passed under both editions. Since Windows +PowerShell 5.1 cannot see the copy the workflow installs (section 3), that 5.1 run must have loaded the image's +copy. + +So the analyzer version the image provides can change with any image update, with no change in this repository. +`PSSCRIPTANALYZER_VERSION` is what holds the version the test uses. + +## What this does not establish + +- Only PSScriptAnalyzer 1.25.0 was tested. Another version may have different rules, or may report syntax errors + differently; that is why sections 1 and 2 need repeating when the version changes. +- Section 2 tested one construct that only PowerShell 7 accepts (a ternary), planted in one private helper file. +- Section 3 tested loading by name with no version parameter, in processes that had no copy loaded yet. +- The runner's own `PSModulePath` was not measured, and the image may add folders to it. So, before the analyzer + test chose its version, whether the PowerShell 7 run in CI loaded the workflow's copy or the image's is + **Unverified**. So is whether the install step installed a second copy of 1.25.0 or skipped: it printed nothing. +- Which folder the image's copy is in was not established; the 2026-09-22 run shows only that Windows PowerShell + 5.1 could load it. diff --git a/Tests/ScriptMessage.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 index 04c63e8..ab77fc5 100644 --- a/Tests/ScriptMessage.Module.Tests.ps1 +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -28,6 +28,8 @@ Describe 'ScriptMessage module' { # analyzer fails rather than skips, so the check cannot quietly disappear if the install step breaks. # # ParseError is its own severity, so without it a syntax error would report nothing. + # + # Research_Notes/Test-Tooling-Behavior.md records the measurements behind these choices. It 'has no PSScriptAnalyzer errors' { if ($PSVersionTable.PSEdition -ne 'Core') { From dc922a4a61b85e0c4f7d64f94e16af288a44ad68 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:58:47 -0500 Subject: [PATCH 19/40] Exclude unintended side effects from the public contract An unintended side effect of how the module is built, such as state it kept in a global variable where any script could read it, is not functionality the module ever offered. Correcting one is not a breaking change and needs no deprecation, so say that in the contract's definition and in Deciding hard cases. AGENTS.md now points at that definition for when a changelog entry is a BREAKING CHANGE. --- AGENTS.md | 5 +++-- RELEASING.md | 14 +++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 61f030d..ce30a4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,8 +145,9 @@ covers when `[Unreleased]` becomes a version and how that version is chosen. - Prefix an entry with `Minor:` inside **Fixed** when a user probably never noticed it: help text wording, a message, an edge case needing unusual conditions to hit, or a cost the module absorbed itself such as an extra Microsoft Graph request. List the `Minor:` entries after the rest of the Fixed entries. -- Prefix an entry with `BREAKING CHANGE:` when it can break an existing script or configuration file, and say - what the user has to change. Such an entry usually belongs under **Changed** or **Removed**. +- Prefix an entry with `BREAKING CHANGE:` when it breaks the public contract as [RELEASING.md](./RELEASING.md) + defines it, so an existing script or configuration file can stop working, and say what the user has to + change. Such an entry usually belongs under **Changed** or **Removed**. - A new public function goes under **Added** and reads `- New Function: > `. - **No em dashes.** Use a semicolon, colon, parentheses, comma, or a new sentence. This applies to every file in the repo, not just the changelog. diff --git a/RELEASING.md b/RELEASING.md index df79607..ea940ca 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,7 +22,7 @@ uses the stricter Semantic Versioning rule: new functionality, including a new p ### The public contract -The public contract is anything a caller can observe or depend on: +The public contract is the behavior ScriptMessage offers its callers: - Exported functions and aliases, their parameters and parameter sets, and each parameter's requirements, defaults, validation, and meaning. @@ -35,11 +35,23 @@ The public contract is anything a caller can observe or depend on: - The external modules the module requires, such as the Microsoft Graph sub-modules each message type needs. - The permissions the module requests or needs, such as Microsoft Graph scopes. +A side effect of how the module happens to be built is not part of it, even when a caller can observe it. See +[Deciding hard cases](#deciding-hard-cases). + ### Deciding hard cases - **A fix that changes observable behavior** is a patch when the old behavior could not have worked for anyone, such as rejecting recipients that have no address, which never sent anything. It is a breaking change when a reasonable script could have depended on the old behavior. Either way, the changelog entry says what changed. +- **Correcting an unintended side effect is not a breaking change.** Behavior that exists only because of how the + module was built, and that no documentation ever offered (the help, the README, the wiki, the sample + configuration file, or the changelog), is outside the public contract even though a caller can observe or use + it. Examples: a variable the module kept in the global scope, where any script could read or change it, and a + true/false setting written as quoted text. Changing or removing such behavior needs no deprecation, and the + "reasonable script" test above does not apply. Naming something in an error message does not document it. + Behavior the module intends callers to use stays in the contract even where its documentation is thin, such as + the properties of the `Send-ScriptMessage` result. The change gets a changelog entry only when a script that + follows the documentation could notice it. - **Requiring a new module or permission is a breaking change**, because existing installations and app registrations stop working until someone acts. - **Deprecate before removing.** Mark functionality as deprecated in a minor release (documentation, a warning, From 4b0b7b5889ed19cb67e3c3028b6713a0f0e01737 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:05:33 -0500 Subject: [PATCH 20/40] Keep the module's state in module scope, not global Set-ScriptMessageConfigFilePath and the cached service context kept their state in global variables, so any script could read or change it, and it outlived Remove-Module. Both now live in $script: variables that the .psm1 declares, and VariablesToExport is an empty list. Callers get a supported way to read the path instead: Get-ScriptMessageConfig -ReturnConfigFilePath adds a ConfigFilePath property holding the full path of the file it read. The error for a missing path names the cmdlet to run rather than a variable. Importing the module again with -Force now clears the path, which the changelog records. Research note section 12 has the measurements of what module scope survives. The changelog's version separators also get the blank line MD022 wants, and AGENTS.md shows it in its example. --- AGENTS.md | 6 +- CHANGELOG.md | 16 +++++ .../PowerShell-Language-Behavior.md | 32 ++++++++++ .../Public/Disconnect-ScriptMessage.ps1 | 2 +- .../Public/Get-ScriptMessageConfig.ps1 | 29 ++++++++-- .../Public/Get-ScriptMessageContext.ps1 | 6 +- .../Set-ScriptMessageConfigFilePath.ps1 | 2 +- ScriptMessage/ScriptMessage.psd1 | 2 +- ScriptMessage/ScriptMessage.psm1 | 14 +++-- Tests/Get-ScriptMessageConfig.Tests.ps1 | 58 +++++++++++++++++++ Tests/ScriptMessage.Module.Tests.ps1 | 26 +++++++++ 11 files changed, 174 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ce30a4e..cb036c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,10 +127,12 @@ covers when `[Unreleased]` becomes a version and how that version is chosen. entry also adds `## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/...develop)` at the top, where `` is `$Release` from the baseline commands above. Never add an empty one, and never leave one in a release: the Release workflow refuses to publish while it is there. -- Newest version first. A released version uses this heading shape, and a `---` line separates it from the next - (older) version: +- Newest version first. A released version uses this heading shape, and a `---` line, with a blank line on each + side of it, separates it from the next (older) version: ```markdown + --- + ## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - 2026-09-20 ### Fixed diff --git a/CHANGELOG.md b/CHANGELOG.md index ac1b678..558fabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - 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. - `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. + +### 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. ### Fixed @@ -24,6 +29,7 @@ - `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. --- + ## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 ### Changed @@ -44,6 +50,7 @@ - Minor: A configuration file whose path contains square brackets, such as a folder named `Scripts [old]`, can now be used. `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` stopped with "Can't find the JSON configuration file" and sent nothing. --- + ## [1.1.0](https://github.com/Sekers/ScriptMessage/tree/1.1.0) - (2025-10-16) ### Fixes @@ -68,6 +75,7 @@ Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.8](https://github.com/Sekers/ScriptMessage/tree/1.0.8) - (2025-09-18) ### Fixes @@ -90,6 +98,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.7](https://github.com/Sekers/ScriptMessage/tree/1.0.7) - (2024-12-26) ### Features @@ -103,6 +112,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.6](https://github.com/Sekers/ScriptMessage/tree/1.0.6) - (2024-12-19) ### Fixes @@ -123,6 +133,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.5](https://github.com/Sekers/ScriptMessage/tree/1.0.5) - (2024-05-22) ### Features @@ -132,6 +143,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.4](https://github.com/Sekers/ScriptMessage/tree/1.0.4) - (2024-05-21) ### Fixes @@ -141,6 +153,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.3](https://github.com/Sekers/ScriptMessage/tree/1.0.3) - (2024-05-21) ### Features @@ -150,6 +163,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.2](https://github.com/Sekers/ScriptMessage/tree/1.0.2) - (2024-05-21) ### Fixes @@ -159,6 +173,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.1](https://github.com/Sekers/ScriptMessage/tree/1.0.1) - (2024-05-20) ### Fixes @@ -168,6 +183,7 @@ Author: [**@Sekers**](https://github.com/Sekers) Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.0](https://github.com/Sekers/ScriptMessage/tree/1.0.0) - (2024-05-20) ### Features diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 44b223e..63e7d29 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -406,3 +406,35 @@ empty configuration file reads as `$null` without an error in both editions. stopped at, so an unquoted secret would appear in it. - What PowerShell 7 builds from the input it accepts but 5.1 rejects was not examined. - A file the account has no permission to read was not tested, only a folder given as the path. + +## 12. Measured: module-scope state across imports + +Measured on **2026-09-22**, same machine and editions as the header. Unlike the other sections, this one imported +a module, each run in a new `-NoProfile` process: an in-memory module built with `New-Module` for the first and +last rows, and this repository's module for the rest, loaded through `PSModulePath` so that it loaded automatically. At the +time, the module kept its cached context in a global variable that the .psm1 recreates on every import, so a +marker property added to that object showed whether a later import ran the .psm1 again. + +| Situation | Result | +| --- | --- | +| A function's default parameter value reads `$script:X`, and the function is called from the caller's scope or from a child scope | reads the module's value in both cases; `Get-Variable -Scope Global` does not show the variable | +| `Import-Module ScriptMessage` without `-Force`, after calling one of its functions loaded it automatically | the .psm1 does not run again; one module instance | +| Another module's code runs `Import-Module ScriptMessage` while it is loaded | the .psm1 does not run again; still one instance | +| `Import-Module ScriptMessage -Force` | the .psm1 runs again, so module-scope state starts over; one instance | +| `(Get-Module ScriptMessage).ExportedVariables` with `VariablesToExport = '*'` in the manifest and no `Export-ModuleMember` in the .psm1 | empty | +| `& (Get-Module ) { $script:X }` from outside the module | returns the module's value | + +So module-scope state survives everything except a forced import (or `Remove-Module` followed by an import), +while a global variable survives even that. + +**From source:** `Set-ScriptMessageConfigFilePath` stores the path in `$script:ScriptMessageConfigFilePath`, which +`ScriptMessage.psm1` declares, and `Get-ScriptMessageConfig` uses it as the default for `-Path`, so importing the +module again with `-Force` clears the path. `Get-ScriptMessageContext` and `Disconnect-ScriptMessage` share +`$script:ScriptMessageCachedServiceContext` the same way. `ScriptMessage.psd1` sets `VariablesToExport` to an +empty list, which the last-but-one row shows changes nothing for callers. The last row is not a supported way to +read the path; `Get-ScriptMessageConfig -ReturnConfigFilePath` is. + +### What this does not establish + +- 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. diff --git a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 index 5d5afc5..598d216 100644 --- a/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Disconnect-ScriptMessage.ps1 @@ -44,7 +44,7 @@ function Disconnect-ScriptMessage # Drop this service's cached context. 'Get-ScriptMessageContext -ReturnCachedContext' reads that cache, so # leaving an entry behind would keep reporting the connection this call just ended. Removing a name the # cache does not hold does nothing, which is the case when nothing has asked for the context yet. - $ScriptMessage_Global_CachedServiceContext.PSObject.Properties.Remove([string]$Service) + $script:ScriptMessageCachedServiceContext.PSObject.Properties.Remove([string]$Service) # Return the disconnection information, if requested. if ($ReturnConnectionInfo) diff --git a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 index 752ee16..ec27f81 100644 --- a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 @@ -14,6 +14,8 @@ function Get-ScriptMessageConfig Optional. If not provided, the function will use the path used in the current session (if set). .PARAMETER Service Optional. Return only the info related to a specific service. + .PARAMETER ReturnConfigFilePath + Adds a ConfigFilePath property to the result, holding the full path of the configuration file that was read. .EXAMPLE Get-ScriptMessageConfig @@ -21,6 +23,8 @@ function Get-ScriptMessageConfig Get-ScriptMessageConfig -Path '.\Config\config_scriptmessage.json' .EXAMPLE Get-ScriptMessageConfig -Service MicrosoftGraph + .EXAMPLE + Get-ScriptMessageConfig -Service MicrosoftGraph -ReturnConfigFilePath #> [CmdletBinding()] @@ -29,20 +33,24 @@ function Get-ScriptMessageConfig Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] - [string]$Path = $ScriptMessage_Global_ConfigFilePath, # If not entered will see if it can pull path from this variable. + [string]$Path = $script:ScriptMessageConfigFilePath, # The path set by Set-ScriptMessageConfigFilePath. [Parameter( Position=1, Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] - [MessagingService]$Service + [MessagingService]$Service, + + [Parameter( + Mandatory = $false)] + [switch]$ReturnConfigFilePath ) - + # Make Sure Requested Path Isn't Null or Empty (better to catch it here than validating on the parameter of this function) if ([string]::IsNullOrEmpty($Path)) { - throw "`'`$ScriptMessage_Global_ConfigFilePath`' is not specified. Don't forget to first use the `'Set-ScriptMessageConfigFilePath`' cmdlet!" + throw "No ScriptMessage configuration file path is set. Run `'Set-ScriptMessageConfigFilePath`' first." } # Read the configuration file. @@ -93,10 +101,19 @@ function Get-ScriptMessageConfig if (-not ($null -eq $Service)) { - return $ScriptMessageConfig.$Service + $Result = $ScriptMessageConfig.$Service } else { - return $ScriptMessageConfig + $Result = $ScriptMessageConfig } + + # No -Force, so a configuration file that has its own 'ConfigFilePath' setting fails here rather than having it + # silently replaced. A missing service section leaves nothing to add the path to. + if ($ReturnConfigFilePath -and ($null -ne $Result)) + { + $Result | Add-Member -MemberType NoteProperty -Name 'ConfigFilePath' -Value $FullPath + } + + return $Result } \ No newline at end of file diff --git a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 index 9a4d964..c9a3d12 100644 --- a/ScriptMessage/Public/Get-ScriptMessageContext.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageContext.ps1 @@ -42,7 +42,7 @@ function Get-ScriptMessageContext $ScriptMessageContext = New-Object System.Object # Enable a force refresh if no data exists for the specified service or if $ReturnCachedContext is not present. - if (($null -eq $ScriptMessage_Global_CachedServiceContext.$Service) -or (-not $ReturnCachedContext.IsPresent)) + if (($null -eq $script:ScriptMessageCachedServiceContext.$Service) -or (-not $ReturnCachedContext.IsPresent)) { $RefreshContext = $true } @@ -73,11 +73,11 @@ function Get-ScriptMessageContext } # Update cached context data for the specified service. - $ScriptMessage_Global_CachedServiceContext | Add-Member -MemberType NoteProperty -Name $Service -Value $ScriptMessageContext -Force # Force allows overwriting existing members. + $script:ScriptMessageCachedServiceContext | Add-Member -MemberType NoteProperty -Name $Service -Value $ScriptMessageContext -Force # Force allows overwriting existing members. } else { - $ScriptMessageContext = $ScriptMessage_Global_CachedServiceContext.$Service + $ScriptMessageContext = $script:ScriptMessageCachedServiceContext.$Service } return $ScriptMessageContext diff --git a/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 b/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 index 061d1a4..e8a6bd5 100644 --- a/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 +++ b/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 @@ -28,5 +28,5 @@ function Set-ScriptMessageConfigFilePath [string]$Path ) - New-Variable -Name 'ScriptMessage_Global_ConfigFilePath' -Value $Path -Scope Global -Force + $script:ScriptMessageConfigFilePath = $Path } \ No newline at end of file diff --git a/ScriptMessage/ScriptMessage.psd1 b/ScriptMessage/ScriptMessage.psd1 index 9a8b542..1df07a5 100644 --- a/ScriptMessage/ScriptMessage.psd1 +++ b/ScriptMessage/ScriptMessage.psd1 @@ -82,7 +82,7 @@ FunctionsToExport = @( CmdletsToExport = @() # Variables to export from this module -VariablesToExport = '*' +VariablesToExport = @() # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. AliasesToExport = @() diff --git a/ScriptMessage/ScriptMessage.psm1 b/ScriptMessage/ScriptMessage.psm1 index e0216f7..95657ee 100644 --- a/ScriptMessage/ScriptMessage.psm1 +++ b/ScriptMessage/ScriptMessage.psm1 @@ -1,9 +1,13 @@ -# Global Variables -New-Variable -Name 'ScriptMessage_Global_CachedServiceContext' -Value ([PSCustomObject]@{}) -Scope Global -Force - # Module Variables -# Each file in Services/ registers itself here as it is dot-sourced below. Module scope rather than global: -# nothing outside the module reads it, and it is rebuilt from scratch on every import. +# Module scope rather than global, so callers can neither see nor change them, and each import starts them fresh. + +# The configuration file path set by Set-ScriptMessageConfigFilePath, read by Get-ScriptMessageConfig. +$script:ScriptMessageConfigFilePath = $null + +# Each service's context from Get-ScriptMessageContext. Disconnect-ScriptMessage removes a service's entry. +$script:ScriptMessageCachedServiceContext = [PSCustomObject]@{} + +# Each file in Services/ registers itself here as it is dot-sourced below. $script:ScriptMessageServiceTable = [ordered]@{} # Aliases diff --git a/Tests/Get-ScriptMessageConfig.Tests.ps1 b/Tests/Get-ScriptMessageConfig.Tests.ps1 index e6cea73..66252f5 100644 --- a/Tests/Get-ScriptMessageConfig.Tests.ps1 +++ b/Tests/Get-ScriptMessageConfig.Tests.ps1 @@ -105,6 +105,64 @@ Describe 'Get-ScriptMessageConfig paths' { } } +Describe 'Get-ScriptMessageConfig and the stored path' { + BeforeAll { + $AsciiName = 'Stored path test' + } + + # Importing the module again clears the path, so each test starts with none set. + BeforeEach { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force + } + + It 'reads the file set by Set-ScriptMessageConfigFilePath' { + $Path = Join-Path $TestDrive 'stored.json' + New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + Set-ScriptMessageConfigFilePath -Path $Path + + (Get-ScriptMessageConfig -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $AsciiName + } + + It 'reports that no path is set' { + { Get-ScriptMessageConfig } | Should -Throw -ExpectedMessage 'No ScriptMessage configuration file path is set*' + } + + It 'adds the full path of the file it read with -ReturnConfigFilePath' { + New-TestConfigFile -Path (Join-Path $TestDrive 'relative.json') -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + Push-Location -LiteralPath $TestDrive + try + { + Set-ScriptMessageConfigFilePath -Path '.\relative.json' + $Config = Get-ScriptMessageConfig -ReturnConfigFilePath + } + finally + { + Pop-Location + } + + $Config.MicrosoftGraph.MgApp_CertificateName | Should -BeExactly $AsciiName + $Config.ConfigFilePath | Should -Be (Join-Path $TestDrive 'relative.json') + } + + It 'adds the path to the service section returned for -Service' { + $Path = Join-Path $TestDrive 'service.json' + New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + $Config = Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph -ReturnConfigFilePath + + $Config.MgApp_CertificateName | Should -BeExactly $AsciiName + $Config.ConfigFilePath | Should -Be $Path + } + + It 'adds no path without -ReturnConfigFilePath' { + $Path = Join-Path $TestDrive 'no-switch.json' + New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + (Get-ScriptMessageConfig -Path $Path).PSObject.Properties.Name | Should -Not -Contain 'ConfigFilePath' + } +} + Describe 'Get-ScriptMessageConfig errors' { It 'reports a folder that does not exist' { { Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'No Such Folder\config.json') } | diff --git a/Tests/ScriptMessage.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 index ab77fc5..2bdcd17 100644 --- a/Tests/ScriptMessage.Module.Tests.ps1 +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -18,6 +18,32 @@ Describe 'ScriptMessage module' { ($ExportedFunctions -join ', ') | Should -Be ($PublicFunctions -join ', ') } + # A new runspace has its own global scope, so variables left by the other test files' imports cannot hide one + # created here. The script runs in a local scope so that its own variables are not global either. + It 'creates no global variables' { + $Script = { + param($ManifestPath, $ConfigPath) + + $Before = @(Get-Variable -Scope Global | ForEach-Object { $_.Name }) + Import-Module $ManifestPath + Set-ScriptMessageConfigFilePath -Path $ConfigPath + @(Get-Variable -Scope Global | Where-Object { $_.Name -notin $Before } | ForEach-Object { $_.Name }) + } + + $PowerShell = [PowerShell]::Create() + try + { + $null = $PowerShell.AddScript($Script.ToString(), $true).AddArgument($ManifestPath).AddArgument((Join-Path $TestDrive 'config.json')) + $NewGlobals = $PowerShell.Invoke() + $PowerShell.Streams.Error | Should -BeNullOrEmpty + $NewGlobals | Should -BeNullOrEmpty + } + finally + { + $PowerShell.Dispose() + } + } + # PSScriptAnalyzer is optional locally. The check runs under PowerShell 7 only, because Windows PowerShell 5.1 # cannot see the copy the CI workflow installs; the analyzer has the same rules in both editions, and syntax # that only PowerShell 7 accepts already fails the 5.1 run when the module imports. 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 21/40] 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 22/40] 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 23/40] 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 24/40] 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 25/40] 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 26/40] 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 27/40] 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 28/40] 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 29/40] 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) From f4a0f05c22bc70db01af8136e1d4c28b5c291c8a Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:00:29 -0500 Subject: [PATCH 30/40] Allow live Graph testing in any tenant, reaching only your own accounts The pull request template, CONTRIBUTING.md, and RELEASING.md asked for live Microsoft Graph testing in a test tenant. What matters is that the test reaches nobody who didn't agree to it, and most contributors have no test tenant, so all three now ask for that instead: any tenant the tester is allowed to test in, with messages and files going only to accounts they control. --- .github/pull_request_template.md | 2 +- CONTRIBUTING.md | 5 +++-- RELEASING.md | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c3b250c..5c72483 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,4 +17,4 @@ - [ ] `CHANGELOG.md` has an entry in the right section under `[Unreleased]` (added if missing), or the change is not user-visible - [ ] `.\Tests\Invoke-Tests.ps1` passes - [ ] Contains no real tenant names, email domains, secrets, or configuration files -- [ ] Any live Microsoft Graph testing was done in a test tenant and is described above +- [ ] Any live Microsoft Graph testing is described above, and sent messages and files only to accounts you control diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdcc96f..62bdbba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,8 +26,9 @@ Thank you for helping improve ScriptMessage. This guide covers the everyday work - **Never commit a real configuration file, secret, or certificate.** Never name a real organization, tenant, or email domain in code, tests, examples, or commit messages; use an RFC 2606 reserved domain instead, such as `example.com`. Those can never be registered, so a placeholder that gets run unedited reaches nobody. -- **The automated tests never connect to a tenant.** If a change needs a live Microsoft Graph test, run it in - your own test tenant and describe what you tested in the pull request. +- **The automated tests never connect to a tenant.** If a change needs a live Microsoft Graph test, you don't need + a test tenant: any tenant you're allowed to test in will do, as long as every message and file you send goes + only to accounts you control. Describe what you tested in the pull request. - **Text files are UTF-8 with LF line endings and no byte order mark, and PowerShell files contain only ASCII.** Windows PowerShell 5.1 misreads non-ASCII characters in PowerShell files saved without a byte order mark. `.gitattributes` handles line endings, the test suite checks all three rules, and [AGENTS.md](./AGENTS.md) diff --git a/RELEASING.md b/RELEASING.md index ea940ca..a39ecaf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -215,5 +215,5 @@ enforceable: - [ ] The wiki (`Sekers/ScriptMessage.wiki`) describes this release, and its update was pushed after the Release workflow passed. - [ ] `main` was merged back into `develop` with a merge commit. -- [ ] Anything that needs a live Microsoft Graph test was tested separately in a test tenant. The automated tests - never connect to a tenant. +- [ ] Anything that needs a live Microsoft Graph test was tested separately, sending messages and files only to + accounts the tester controls. The automated tests never connect to a tenant. From e18eb93e46eef81a151e415ed371e9abc254457f Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:42:59 -0500 Subject: [PATCH 31/40] Post into the most recent of several matching group chats When more than one group chat has exactly the sender and the recipients as members, Send-ScriptMessage_MicrosoftGraph posts into the least recent one. The chats are sorted most recent first, but the match loop never stops, so the last match wins. Stop at the first match. With Chat.Read the most recent chat is the one with the latest message; with only Chat.ReadBasic it is the one most recently created, renamed, or given different members, because Graph does not change LastUpdatedDateTime when a message is posted. Correct the comment that said otherwise. Measured against a tenant on 2026-09-30: Graph creates a second group chat with the same members rather than returning the first, and posting leaves LastUpdatedDateTime alone. Chat.ReadBasic cannot expand lastMessagePreview, so that sort is the best it allows, and a delegated sign-in carries every permission already granted to the app, so the Chat.ReadBasic sort applies only when the app has no Chat.Read grant. See Research_Notes/Microsoft-Graph-Behavior.md section 9. --- CHANGELOG.md | 3 + Research_Notes/Microsoft-Graph-Behavior.md | 92 ++++++++++++++++++++-- ScriptMessage/Services/MicrosoftGraph.ps1 | 5 +- Tests/Send-ScriptMessage.Tests.ps1 | 57 ++++++++++++++ 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e931f..0815fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. +- `Send-ScriptMessage` with `ChatType` `Group` now posts into the most recent of several group chats that have exactly the same members, instead of the least recent, so a script's messages no longer land in a chat nobody uses while people talk in a newer one. Which chat counts as the most recent depends on whether the sign-in can read chat messages: + - With `Chat.Read`, which `MgDelegatedPermission_RequestChatReadPermission` set to `true` asks for: the chat with the latest message, whoever sent it. + - With only `Chat.ReadBasic`: the chat most recently created, renamed, or given different members. Messages don't count, so the same chat keeps being chosen until one of those changes. A sign-in gets every permission already granted to the app registration, so this applies only when the app registration hasn't been granted `Chat.Read`, whatever `MgDelegatedPermission_RequestChatReadPermission` says. - Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - 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`. diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index 7529b70..539d506 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -1,13 +1,14 @@ # Microsoft Graph behavior What Microsoft Graph and the Microsoft Graph PowerShell SDK do in the areas this module relies on: -permissions, what a successful send returns, chat creation, attachment and upload limits, how SDK sign-in +permissions, what a successful send returns, chat creation and reuse, attachment and upload limits, how SDK sign-in persists, and what disconnecting after a send changes. Each claim is tied to the part of the module it affects. -**Only section 7 was measured against a tenant**, on **2026-09-16**; that section gives the environment. -Documentation was read on **2026-09-15** from the pages listed under Sources, and the SDK source on -**2026-09-16**, except for section 8, whose pages and SDK source were read on **2026-09-21**. Microsoft revises -these pages, so re-read a page before relying on a limit or a permission. +**Only sections 7 and 9 were measured against a tenant**, on **2026-09-16** and **2026-09-30**; each section +gives its environment. Documentation was read on **2026-09-15** from the pages listed under Sources, and the SDK +source on **2026-09-16**, except for section 8, whose pages and SDK source were read on **2026-09-21**, and +section 9, whose pages were read on **2026-09-30**. Microsoft revises these pages, so re-read a page before +relying on a limit or a permission. **Claims are labelled with their evidence.** @@ -50,6 +51,8 @@ these pages, so re-read a page before relying on a limit or a permission. | [ConvertFrom-SecureString](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/convertfrom-securestring) | 2026-08-10 | | [about_Signing](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_signing) | 2026-09-21 | | [Microsoft Graph PowerShell SDK authentication source, branch `main`](https://github.com/microsoftgraph/msgraph-sdk-powershell/tree/main/src/Authentication) | none (branch, read 2026-09-21) | +| [List chats](https://learn.microsoft.com/en-us/graph/api/chat-list?view=graph-rest-1.0) | 2026-04-13 | +| [chat resource type](https://learn.microsoft.com/en-us/graph/api/resources/chat?view=graph-rest-1.0) | 2026-08-05 | ## 1. From documentation: permissions for each operation the module performs @@ -124,8 +127,8 @@ relies on this to land in the existing chat. For `Group`, it first lists all of (`Get-MgChat -All`, expanding members) and posts into one whose members exactly match the sender plus the recipients, creating a new group chat only when none matches. -**Unverified:** whether `New-MgChat` for a group chat always creates a new chat even when one with the same -members already exists. The page does not say, and the module's lookup exists on the assumption that it does. +**Measured** (section 9): creating a group chat with the same members as an existing one created a second chat +rather than returning the first. The page does not say, and the module's lookup exists because of it. ## 5. From documentation: attachment and upload size limits @@ -316,3 +319,78 @@ that runs the script, on the computer that runs it. - **Unverified:** the message `ConvertTo-SecureString` gives for a string encrypted by another account or on another computer. The wiki's troubleshooting entry uses "Key not valid for use in specified state.", the Windows message usually reported for it; it was not reproduced here. + +## 9. Measured: finding a group chat to reuse + +**Environment:** measured on 2026-09-30 in PowerShell 7.6.6 on Windows 11, with `Microsoft.Graph.Authentication` +and `Microsoft.Graph.Teams` 2.25.0. The sign-in was delegated, through an app registration with `Chat.Read` +consented, and `Send-ScriptMessage` came from `develop` at `bcc28ea`. Every chat and message involved only three +accounts the tester controls: the signed-in sender and two recipients. + +**Measured:** + +- `Get-MgChat -All -Filter "ChatType eq 'group'"` with `-ExpandProperty 'Members', 'LastMessagePreview'`, the + call the module makes, succeeded: Graph accepts both expansions in one request. Each of the signed-in account's + 6 group chats came back with its members' `email` values and a `LastMessagePreview`, and no two of them had the + same members. +- **A group chat with the same members as an existing one is a new chat.** `New-MgChat -BodyParameter`, with + `chatType` `group` and the same three members as an existing group chat, returned a chat with a new ID and a + `CreatedDateTime` of that moment. +- **Posting a message does not change `LastUpdatedDateTime`.** After three messages were posted into the existing + chat, its `LastUpdatedDateTime` still equalled its `CreatedDateTime`, more than a year earlier, while + `LastMessagePreview.CreatedDateTime` moved to each new message. +- With one matching chat, `Send-ScriptMessage -ChatType Group` posted into it. With two, where the newer chat held + the latest message, it posted into the older one, whose last message was older too. +- **A delegated sign-in gets every delegated permission already granted to the app, not only the ones asked + for.** With `MgDelegatedPermission_RequestChatReadPermission` false, `AllowableMessageTypes` only `Chat`, and + `MgDelegatedPermission_RequestFilesReadWritePermission` false, `Connect-ScriptMessage` asked for + `Chat.ReadBasic` but no `Chat.Read`, `Mail.Send`, or `Files.ReadWrite`. The connection's scopes, as + `Get-ScriptMessageContext` reported them, included all four, because the app registration had all of them + granted. The group chat lookup therefore took the `Chat.Read` sort. +- **`Chat.ReadBasic` cannot read `lastMessagePreview`.** With `Chat.Read`'s grant revoked, so the connection's + chat scopes were only `Chat.Create`, `Chat.ReadBasic`, and `ChatMessage.Send`, the same `Get-MgChat` call with + `-ExpandProperty 'Members', 'LastMessagePreview'` failed with 403 "Expansion on lastMessagePreview requires one + of the 'Chat.Read,Chat.ReadWrite' additional permissions". Expanding only `Members` succeeded. +- On that connection, with the same two matching chats, `Send-ScriptMessage -ChatType Group` from this change's + code posted into the newer chat, by `LastUpdatedDateTime`, although the older one held the latest message. +- Before `Chat.ReadBasic` was granted to the app, that sign-in stopped at an "Approval required" prompt listing it, + in a tenant whose user consent settings require an administrator. The earlier sign-ins, which asked for + `Chat.Read`, already granted, had no prompt. +- `New-MgChat` failed with 400 `'user@odata.bind' field is missing in the request.` when a member's + `user@odata.bind` value was present but named a user that does not exist (a quoted list of addresses passed as + one address). The message says the field is missing when it is there but wrong. Both the `-Members` form and + the `-BodyParameter` form gave it. + +**From documentation:** the chat resource type page defines `lastUpdatedDateTime` as "Date and time at which the +chat was renamed or the list of members was last changed", and says `lastMessagePreview` is "Null if no messages +were sent in the chat" and that only the list chats operation supports it. The List chats page lists +`Chat.ReadBasic` as the least privileged delegated permission and supports `$orderby` only on +`lastMessagePreview/createdDateTime`, descending. It also says `$expand=members` "returns a maximum of 25 member +items". Neither page says which permission is needed to expand `lastMessagePreview`. + +**From source:** the `Group` branch of `Send-ScriptMessage_MicrosoftGraph` lists every group chat and sorts it +most recent first: by `LastMessagePreview.CreatedDateTime` when the connection's scopes include `Chat.Read` or +`Chat.ReadWrite`, and otherwise by `LastUpdatedDateTime`. It posts into the first chat whose member `email` +values are exactly the sender plus the recipients, and creates a new group chat only when none matches. + +**Inference:** + +- Without `Chat.Read`, "most recent" means the chat most recently created, renamed, or given different members; + messages never change which chat is chosen. That sort is used only when the app registration has not been + granted `Chat.Read` (or `Chat.ReadWrite`), whatever `MgDelegatedPermission_RequestChatReadPermission` says; + the same holds for every setting that only chooses which delegated permission is asked for. +- A group chat with more than 25 members never matches, if the 25-member cap applies to `-All` paging too, so the + module would create a new chat on every send to it. +- A recipient given by an address other than the one Graph reports as the member's `email`, such as an alias, + never matches either, with the same result. + +### What this does not establish + +- **Unverified:** the module's own `New-MgChat -Members` call creating a group chat with valid members. A matching + chat already existed, so that path was not exercised. +- **Unverified:** whether the Teams client ever creates a second group chat with the same members, and whether + anything besides a rename or a member change moves `LastUpdatedDateTime` (only the documentation says so). +- **Unverified:** the 25-member cap and the alias mismatch in the inferences above. Neither was tested. +- **Unverified:** whether permissions a user consented to for themselves, rather than ones an administrator granted + for the organization, are carried the same way. Only administrator grants were measured. +- Nothing in this section was measured on Windows PowerShell 5.1 or with an SDK release other than 2.25.0. diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 8e95b0e..4026e92 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -1016,7 +1016,7 @@ function Send-ScriptMessage_MicrosoftGraph 'LastUpdatedDateTime' ) - # If the script has 'Chat.Read' or 'Chat.ReadWrite', then sort by the message preview (last time a message was sent). Otherwise, sort by the last time the chat OBJECT was updated. + # If the script has 'Chat.Read' or 'Chat.ReadWrite', then sort by the message preview (last time a message was sent). Otherwise, sort by LastUpdatedDateTime, which Graph sets when the chat is created, renamed, or its members change, but not when a message is sent. [array]$MicrosoftGraphScopes = $MicrosoftGraphContext | Select-Object -ExpandProperty Scopes if (@($MicrosoftGraphScopes) -contains 'Chat.Read' -or @($MicrosoftGraphScopes) -contains 'Chat.ReadWrite') { @@ -1031,13 +1031,14 @@ function Send-ScriptMessage_MicrosoftGraph $ExistingGroupChats = $ExistingGroupChats | Sort-Object -Property LastUpdatedDateTime -Descending } - # Reset the variable and then do a compare\search + # Graph allows several group chats with the same members. The chats are sorted most recent first, so use the first match. $LatestExistingGroupChatMatch = $null foreach ($existingGroupChat in $ExistingGroupChats) { if (-not (Compare-Object -ReferenceObject @($existingGroupChat.Members.AdditionalProperties.email) -DifferenceObject $AllChatParticipants)) { $LatestExistingGroupChatMatch = $existingGroupChat + break } } diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index f317772..1a0ba35 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -242,6 +242,63 @@ Describe 'Send-ScriptMessage chat message' { } } + Context 'A group chat whose members match more than one existing chat' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Chat') + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + + # Graph allows several group chats with the same members. The two matching chats disagree on which is + # most recent: 'active-chat' has the latest message, and 'renamed-chat' was renamed or had its members + # changed more recently. 'other-chat' is newer than both but has different members. + Mock -ModuleName ScriptMessage Get-MgChat { + function New-Chat($Id, $Emails, $LastUpdated, $LastMessage) + { + [pscustomobject]@{ + Id = $Id + LastUpdatedDateTime = [datetime]$LastUpdated + LastMessagePreview = [pscustomobject]@{ CreatedDateTime = [datetime]$LastMessage } + Members = @($Emails | ForEach-Object { [pscustomobject]@{ AdditionalProperties = @{ email = $_ } } }) + } + } + New-Chat 'renamed-chat' @('sender@example.org', 'recipient@example.org') '2026-06-01' '2026-08-01' + New-Chat 'other-chat' @('sender@example.org', 'someone@example.org') '2026-09-15' '2026-09-15' + New-Chat 'active-chat' @('recipient@example.org', 'sender@example.org') '2025-01-01' '2026-09-01' + } + } + + It 'with Chat.Read, posts into the chat with the most recent message' { + $Result = Send-ScriptMessage @MessageArguments -ChatType Group + + $Result.Error | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly -ParameterFilter { $ChatId -eq 'active-chat' } + } + + It 'with only Chat.ReadBasic, posts into the chat most recently created, renamed, or changed' { + Mock -ModuleName ScriptMessage Get-ScriptMessageContext { + [pscustomobject]@{ + Account = 'sender@example.org' + Scopes = @('Chat.ReadBasic') + } + } + + $Result = Send-ScriptMessage @MessageArguments -ChatType Group + + $Result.Error | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly -ParameterFilter { $ChatId -eq 'renamed-chat' } + } + } + Context 'Mail and Chat in the same call' { BeforeAll { Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { From a32d958ca532ea4111950a355f4076cec271ca55 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:40:31 -0500 Subject: [PATCH 32/40] Reword the group chat changelog entry in plainer terms The Chat.ReadBasic sub-bullet of the group chat entry says messages "don't count" and describes the permission grant in terms the reader has to work out. Say plainly which chat each permission picks, and state the case where both permissions are granted directly. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0815fe1..644e752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,8 @@ - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. - `Send-ScriptMessage` with `ChatType` `Group` now posts into the most recent of several group chats that have exactly the same members, instead of the least recent, so a script's messages no longer land in a chat nobody uses while people talk in a newer one. Which chat counts as the most recent depends on whether the sign-in can read chat messages: - - With `Chat.Read`, which `MgDelegatedPermission_RequestChatReadPermission` set to `true` asks for: the chat with the latest message, whoever sent it. - - With only `Chat.ReadBasic`: the chat most recently created, renamed, or given different members. Messages don't count, so the same chat keeps being chosen until one of those changes. A sign-in gets every permission already granted to the app registration, so this applies only when the app registration hasn't been granted `Chat.Read`, whatever `MgDelegatedPermission_RequestChatReadPermission` says. + - With `Chat.Read`, which `MgDelegatedPermission_RequestChatReadPermission` set to `true` asks for: the chat with the most recent message. + - With only `Chat.ReadBasic`: the chat created most recently, or most recently renamed or had people added or removed. New messages don't affect the choice. If the app registration has both permissions granted, ScriptMessage gets `Chat.Read` even with `MgDelegatedPermission_RequestChatReadPermission` set to `false`. - Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - 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`. From 524f8def11c8f88aeb55b2ece22cfc2fddcb5931 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:40 -0500 Subject: [PATCH 33/40] Correct the address mismatch inference in the Graph research notes Section 9 says a recipient given by an alias never matches an existing group chat. A chat member is named by users('
'), which takes a user ID or UPN, so a true alias more likely fails when the chat is created; the case that never matches is a UPN that differs from the primary email. Describe the UPN case, note that matching on userId would avoid it, and mark the alias case as a likely creation failure. --- Research_Notes/Microsoft-Graph-Behavior.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index 539d506..c7b8b2b 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -381,8 +381,11 @@ values are exactly the sender plus the recipients, and creates a new group chat the same holds for every setting that only chooses which delegated permission is asked for. - A group chat with more than 25 members never matches, if the 25-member cap applies to `-All` paging too, so the module would create a new chat on every send to it. -- A recipient given by an address other than the one Graph reports as the member's `email`, such as an alias, - never matches either, with the same result. +- A recipient given by a UPN that differs from their primary email address never matches either, with the same + result: the module names each member as `users('
')`, which going by Graph's + `/users/{id | userPrincipalName}` form takes a user ID or UPN, but matches on the member's `email`, the primary + address. Matching on each member's `userId` would avoid it. A true alias, neither the UPN nor the primary + address, probably fails when the chat is created instead. ### What this does not establish @@ -390,7 +393,8 @@ values are exactly the sender plus the recipients, and creates a new group chat chat already existed, so that path was not exercised. - **Unverified:** whether the Teams client ever creates a second group chat with the same members, and whether anything besides a rename or a member change moves `LastUpdatedDateTime` (only the documentation says so). -- **Unverified:** the 25-member cap and the alias mismatch in the inferences above. Neither was tested. +- **Unverified:** the 25-member cap, the UPN mismatch, and the alias failure in the inferences above. None was + tested. - **Unverified:** whether permissions a user consented to for themselves, rather than ones an administrator granted for the organization, are carried the same way. Only administrator grants were measured. - Nothing in this section was measured on Windows PowerShell 5.1 or with an SDK release other than 2.25.0. From 21fa6468c2f725fe294e0958129db9d5a4d055d9 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:37:52 -0500 Subject: [PATCH 34/40] Record the group chat lookup timing, and which account listed the chats Section 9 attributes its count of 6 group chats to the signed-in sender, but that read-only run signed in as one of the recipient accounts, and the section has no timing for the two lookup calls. Say which account the count came from, add the timing of the Chat.Read and Chat.ReadBasic calls, and list what the timing leaves open. --- Research_Notes/Microsoft-Graph-Behavior.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index c7b8b2b..2fd8c8a 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -330,9 +330,9 @@ accounts the tester controls: the signed-in sender and two recipients. **Measured:** - `Get-MgChat -All -Filter "ChatType eq 'group'"` with `-ExpandProperty 'Members', 'LastMessagePreview'`, the - call the module makes, succeeded: Graph accepts both expansions in one request. Each of the signed-in account's - 6 group chats came back with its members' `email` values and a `LastMessagePreview`, and no two of them had the - same members. + call the module makes, succeeded: Graph accepts both expansions in one request. In a separate read-only run, + signed in as one of the recipient accounts instead of the sender, each of that account's 6 group chats came + back with its members' `email` values and a `LastMessagePreview`, and no two of them had the same members. - **A group chat with the same members as an existing one is a new chat.** `New-MgChat -BodyParameter`, with `chatType` `group` and the same three members as an existing group chat, returned a chat with a new ID and a `CreatedDateTime` of that moment. @@ -360,6 +360,10 @@ accounts the tester controls: the signed-in sender and two recipients. `user@odata.bind` value was present but named a user that does not exist (a quoted list of addresses passed as one address). The message says the field is missing when it is there but wrong. Both the `-Members` form and the `-BodyParameter` form gave it. +- **Timing** (read-only, signed in as the sender, 21 group chats, five alternating runs of each call): the + `Chat.Read` path's call, expanding `Members` and `LastMessagePreview`, took 580 to 780 ms, median 668 ms. The + `Chat.ReadBasic` path's call, expanding only `Members`, took 541 to 646 ms, median 573 ms. The `Chat.Read` call + was slower in all five pairs. **From documentation:** the chat resource type page defines `lastUpdatedDateTime` as "Date and time at which the chat was renamed or the list of members was last changed", and says `lastMessagePreview` is "Null if no messages @@ -397,4 +401,6 @@ values are exactly the sender plus the recipients, and creates a new group chat tested. - **Unverified:** whether permissions a user consented to for themselves, rather than ones an administrator granted for the organization, are carried the same way. Only administrator grants were measured. +- How the timing gap grows with many more chats, and whether sorting on the server by + `lastMessagePreview/createdDateTime` would make the `Chat.Read` path faster. - Nothing in this section was measured on Windows PowerShell 5.1 or with an SDK release other than 2.25.0. From cf6e1afc950c9704106b4b5578807742e13addef Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:55:50 -0500 Subject: [PATCH 35/40] Record the live group chat creation and plus sign attachment results The research notes list the module's own New-MgChat -Members call as unverified, and say nothing about what OneDrive names a chat attachment whose name contains a plus sign. Add the 2026-10-01 measurements: a Group send with no matching chat creates a group chat with exactly the sender and recipients, a second send finds it, a member's email can differ in letter case from the address given and still matches, and a recipient who leaves a chat drops out of the members the sender's listing returns. Record that a plus sign in an attachment name is kept in OneDrive and in Teams. --- Research_Notes/Microsoft-Graph-Behavior.md | 37 ++++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index 2fd8c8a..f2d3b5f 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -4,11 +4,11 @@ What Microsoft Graph and the Microsoft Graph PowerShell SDK do in the areas this permissions, what a successful send returns, chat creation and reuse, attachment and upload limits, how SDK sign-in persists, and what disconnecting after a send changes. Each claim is tied to the part of the module it affects. -**Only sections 7 and 9 were measured against a tenant**, on **2026-09-16** and **2026-09-30**; each section -gives its environment. Documentation was read on **2026-09-15** from the pages listed under Sources, and the SDK -source on **2026-09-16**, except for section 8, whose pages and SDK source were read on **2026-09-21**, and -section 9, whose pages were read on **2026-09-30**. Microsoft revises these pages, so re-read a page before -relying on a limit or a permission. +**Only sections 7 and 9, and one finding in section 5, were measured against a tenant**, on **2026-09-16**, +**2026-09-30**, and **2026-10-01**; each section gives its environment. Documentation was read on **2026-09-15** +from the pages listed under Sources, and the SDK source on **2026-09-16**, except for section 8, whose pages and +SDK source were read on **2026-09-21**, and section 9, whose pages were read on **2026-09-30**. Microsoft revises +these pages, so re-read a page before relying on a limit or a permission. **Claims are labelled with their evidence.** @@ -147,6 +147,12 @@ requests `Mail.Send` only. Chat attachments are uploaded with a simple `PUT` to that name is taken), shared with the chat recipients as read-only with sign-in required and no invitation email, and then referenced from the chat message. +**Measured** (2026-10-01, in the environment section 9 gives for that date): a chat attachment named +`plus+sign test.txt`, in a folder whose name also contains a `+`, was stored in OneDrive under exactly that name, +and Teams showed it under that name in the chat. The module escapes the name with `[uri]::EscapeDataString`, +which turns `+` into `%2B` in both PowerShell editions (measured locally the same day), so the `+` is kept rather +than read as a space. + ### What this does not establish - **Unverified:** the largest attachment, or total request size, that `sendMail` accepts inline. The 3 MB figure @@ -158,6 +164,8 @@ email, and then referenced from the chat message. comment in the module says it overwrites, and the module renames the file to avoid the question. - **Unverified:** that `Microsoft Teams Chat Files` is the folder Teams itself uses for chat attachments. The module assumes it; none of the pages above mention it. +- The `+` result was seen from the sender's side only; whether the recipients can open the shared file was not + checked. No other special character in a file name was tested. ## 6. From documentation: how SDK sign-in persists, and other clouds @@ -365,6 +373,21 @@ accounts the tester controls: the signed-in sender and two recipients. `Chat.ReadBasic` path's call, expanding only `Members`, took 541 to 646 ms, median 573 ms. The `Chat.Read` call was slower in all five pairs. +**Measured on 2026-10-01** in the same environment, with `Microsoft.Graph.Files` 2.25.0 added, `Send-ScriptMessage` +from `develop` at `32282b7`, and the same three accounts: + +- **A recipient leaving a group chat in Teams changes the members the sender's listing returns.** One recipient + left both group chats holding all three accounts; afterwards the sender's `Get-MgChat` listing still returned + both, with only the two remaining members, so neither matched a send to all three. +- **The module's own `New-MgChat -Members` call creates a group chat with valid members.** With no group chat + holding exactly the sender and both recipients, `Send-ScriptMessage -ChatType Group` created one, of type + `group`, with exactly those three members, and posted its message into it. +- **A second send finds the chat the first one created.** The same send again posted into that chat, and the + sender still had only that one group chat with those three members. +- **A member's `email` can differ in letter case from the address the caller gave.** One recipient, given in + lower case, came back as a member `email` with capital letters. The match still succeeded: `Compare-Object` + ignores letter case unless given `-CaseSensitive`, in both PowerShell editions (measured locally the same day). + **From documentation:** the chat resource type page defines `lastUpdatedDateTime` as "Date and time at which the chat was renamed or the list of members was last changed", and says `lastMessagePreview` is "Null if no messages were sent in the chat" and that only the list chats operation supports it. The List chats page lists @@ -393,12 +416,12 @@ values are exactly the sender plus the recipients, and creates a new group chat ### What this does not establish -- **Unverified:** the module's own `New-MgChat -Members` call creating a group chat with valid members. A matching - chat already existed, so that path was not exercised. - **Unverified:** whether the Teams client ever creates a second group chat with the same members, and whether anything besides a rename or a member change moves `LastUpdatedDateTime` (only the documentation says so). - **Unverified:** the 25-member cap, the UPN mismatch, and the alias failure in the inferences above. None was tested. +- Only a three-member group chat was created. A `Group` send to a single recipient, which would match or create a + two-member group chat, was not tested. - **Unverified:** whether permissions a user consented to for themselves, rather than ones an administrator granted for the organization, are carried the same way. Only administrator grants were measured. - How the timing gap grows with many more chats, and whether sorting on the server by From 47201e0072d095c340b33cc4f2986bcb0fcd1559 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:02:03 -0500 Subject: [PATCH 36/40] Record which forms of address find a user in Microsoft Graph The research notes list the UPN mismatch and the alias failure as untested inferences, and say nothing about which forms of a user's address Graph accepts when a chat is created, an attachment is shared, or mail is sent. Add section 10 with the measurements from 2026-10-01 and 2026-10-02: a UPN and a primary email that is not the UPN both find the user for a chat, a secondary address that was never a UPN fails in New-MgChat with a 404 while the module's warning names the recipient, an attachment can be shared with a UPN, and mail to a UPN is delivered. Cite the Entra documentation that always adds a mailbox user's UPN as a proxy address, and say why the results still do not show whether Graph resolves a primary email that is not the UPN, and what would settle it. In section 9, record the duplicate group chat that a recipient given by a UPN gets, and point to section 10 for the forms of address. --- Research_Notes/Microsoft-Graph-Behavior.md | 115 ++++++++++++++++++--- 1 file changed, 102 insertions(+), 13 deletions(-) diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index f2d3b5f..f4eedfa 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -2,13 +2,15 @@ What Microsoft Graph and the Microsoft Graph PowerShell SDK do in the areas this module relies on: permissions, what a successful send returns, chat creation and reuse, attachment and upload limits, how SDK sign-in -persists, and what disconnecting after a send changes. Each claim is tied to the part of the module it affects. +persists, what disconnecting after a send changes, and which forms of address find a user. Each claim is tied to +the part of the module it affects. -**Only sections 7 and 9, and one finding in section 5, were measured against a tenant**, on **2026-09-16**, -**2026-09-30**, and **2026-10-01**; each section gives its environment. Documentation was read on **2026-09-15** -from the pages listed under Sources, and the SDK source on **2026-09-16**, except for section 8, whose pages and -SDK source were read on **2026-09-21**, and section 9, whose pages were read on **2026-09-30**. Microsoft revises -these pages, so re-read a page before relying on a limit or a permission. +**Only sections 7, 9, and 10, and one finding in section 5, were measured against a tenant**, on **2026-09-16**, +**2026-09-30**, **2026-10-01**, and **2026-10-02**; each section gives its environment. Documentation was read on +**2026-09-15** from the pages listed under Sources, and the SDK source on **2026-09-16**, except for section 8, +whose pages and SDK source were read on **2026-09-21**, section 9, whose pages were read on **2026-09-30**, and +section 10, whose pages were read on **2026-10-01** and **2026-10-02**. Microsoft revises these pages, so re-read a +page before relying on a limit or a permission. **Claims are labelled with their evidence.** @@ -53,6 +55,8 @@ these pages, so re-read a page before relying on a limit or a permission. | [Microsoft Graph PowerShell SDK authentication source, branch `main`](https://github.com/microsoftgraph/msgraph-sdk-powershell/tree/main/src/Authentication) | none (branch, read 2026-09-21) | | [List chats](https://learn.microsoft.com/en-us/graph/api/chat-list?view=graph-rest-1.0) | 2026-04-13 | | [chat resource type](https://learn.microsoft.com/en-us/graph/api/resources/chat?view=graph-rest-1.0) | 2026-08-05 | +| [Integrate Microsoft Entra Agent ID with third-party identity providers](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/third-party-identity-providers) | 2026-08-13 | +| [How the proxyAddresses attribute is populated in Microsoft Entra ID](https://learn.microsoft.com/en-us/troubleshoot/entra/entra-id/user-prov-sync/proxyaddresses-attribute-populate) | 2026-09-17 | ## 1. From documentation: permissions for each operation the module performs @@ -387,6 +391,12 @@ from `develop` at `32282b7`, and the same three accounts: - **A member's `email` can differ in letter case from the address the caller gave.** One recipient, given in lower case, came back as a member `email` with capital letters. The match still succeeded: `Compare-Object` ignores letter case unless given `-CaseSensitive`, in both PowerShell editions (measured locally the same day). +- **A recipient given by a UPN that differs from their primary email gets a new group chat instead of the + existing one.** One recipient's UPN was changed in Microsoft Entra ID to a name other than their primary email, + which stayed as it was. The sender's listing still showed that member's `email` as the primary address. A + `Group` send naming that recipient by the new UPN, with the other two members as before, found no match, + created a second group chat with the same three members as the existing one, and posted into it. Its `Error` + was empty, so the caller got no warning. Section 10 covers which forms of address find a user. **From documentation:** the chat resource type page defines `lastUpdatedDateTime` as "Date and time at which the chat was renamed or the list of members was last changed", and says `lastMessagePreview` is "Null if no messages @@ -408,18 +418,18 @@ values are exactly the sender plus the recipients, and creates a new group chat the same holds for every setting that only chooses which delegated permission is asked for. - A group chat with more than 25 members never matches, if the 25-member cap applies to `-All` paging too, so the module would create a new chat on every send to it. -- A recipient given by a UPN that differs from their primary email address never matches either, with the same - result: the module names each member as `users('
')`, which going by Graph's - `/users/{id | userPrincipalName}` form takes a user ID or UPN, but matches on the member's `email`, the primary - address. Matching on each member's `userId` would avoid it. A true alias, neither the UPN nor the primary - address, probably fails when the chat is created instead. +- The UPN mismatch measured above happens because the module names each member as `users('
')`, which + going by Graph's `/users/{id | userPrincipalName}` form takes a user ID or UPN, but matches on the member's + `email`, the primary address. Each later send by UPN finds no match in the same way, so it creates yet another + chat. Matching on each member's `userId` would avoid it. ### What this does not establish - **Unverified:** whether the Teams client ever creates a second group chat with the same members, and whether anything besides a rename or a member change moves `LastUpdatedDateTime` (only the documentation says so). -- **Unverified:** the 25-member cap, the UPN mismatch, and the alias failure in the inferences above. None was - tested. +- **Unverified:** the 25-member cap in the inferences above; it was not tested. +- The UPN mismatch was measured with one send. The account syncs from on-premises Active Directory, and its UPN + was changed in Microsoft Entra ID only. - Only a three-member group chat was created. A `Group` send to a single recipient, which would match or create a two-member group chat, was not tested. - **Unverified:** whether permissions a user consented to for themselves, rather than ones an administrator granted @@ -427,3 +437,82 @@ values are exactly the sender plus the recipients, and creates a new group chat - How the timing gap grows with many more chats, and whether sorting on the server by `lastMessagePreview/createdDateTime` would make the `Chat.Read` path faster. - Nothing in this section was measured on Windows PowerShell 5.1 or with an SDK release other than 2.25.0. + +## 10. Measured: which forms of address find a user + +**Environment:** measured on 2026-10-01 and 2026-10-02 in the environment section 9 gives for 2026-10-01, with +the same code and the same three accounts. Chat sends used a delegated sign-in, and the mail send the +`Mail.Send` application permission. The accounts sync from on-premises Active Directory. On 2026-10-01 one +recipient's UPN was changed in Microsoft Entra ID only, to a name other than their primary email, which stayed +as it was, and it stayed changed through every test below. + +**Measured:** + +- **A chat member given by UPN resolves.** A `Group` send naming that recipient by the new UPN created a group + chat with them as a member (section 9). On 2026-10-02 a `OneOnOne` send to the same UPN posted into the + existing one-on-one chat between the two accounts, created in 2024, with `Error` empty. +- **A chat member given by primary email resolves while it is not the UPN.** On 2026-10-01, and again on + 2026-10-02, a `OneOnOne` send to that recipient's primary email posted into the same one-on-one chat, with + `Error` empty. +- **A secondary email address that was never a UPN does not resolve.** On 2026-10-01 a `OneOnOne` send with + `From` set to one of the sender's own secondary SMTP addresses, which had never been their UPN, failed in + `New-MgChat` with 404 `Failed to find users with user principal name '
'`. Nothing was posted. The + result's `Error` held only the module's warning `Cannot create a chat with the recipient ''.`, + which names the recipient although the sender's address was the one that failed; Graph's own message was + printed to the console, not added to the result. +- **A chat attachment can be shared with a UPN.** On 2026-10-02 a `OneOnOne` send to the recipient's UPN with a + small attachment uploaded the file to the sender's OneDrive and gave the recipient a `read` permission on it, + with `Error` empty. That permission's SharePoint sign-in name was the UPN; the only other permission on the + file was the sender's, as owner. +- **Mail to that UPN was delivered.** On 2026-10-02 a `Group` mail send to the recipient's UPN returned `Status` + `$true` with `Error` empty, the message reached the recipient's inbox, and no non-delivery report reached the + sender. The recipient's proxy addresses, as Microsoft Entra ID listed them the same day, included the UPN as a + secondary `smtp:` address. + +**From documentation:** the page on how the proxyAddresses attribute is populated (read 2026-10-02) says "If the +user has an Exchange license assigned or the user is an Exchange Online recipient, such as a shared mailbox, the +`userPrincipalName` is always added as a proxy address", and its scenarios add the UPN as a secondary `smtp` +address whenever the primary SMTP address differs from it. The Agent ID integration page (read 2026-10-01) finds +a user from an email address by filtering on `mail`, then on `otherMails`, and addresses the user resource +directly only by UPN. + +**From source:** every address reaches Graph as the caller gave it. When a chat is created, +`ConvertTo-IMicrosoftGraphConversationMember` names the sender and each recipient as `users('
')`. When +an attachment is shared, `ConvertTo-IMicrosoftGraphDriveInvite` passes each chat recipient as an invitation +`email`. `ConvertTo-IMicrosoftGraphRecipient` passes each mail recipient as an `emailAddress` `address`. A +`OneOnOne` send calls `New-MgChat` for each recipient without listing or comparing chats. + +**Inference:** + +- A one-on-one send never compares addresses, so a UPN cannot cause the duplicate chat that section 9 describes + there. +- Mail to a mailbox user's UPN is delivered: going by the documentation above, a mailbox's UPN is always one of + its email addresses. +- A secondary address given for a recipient, rather than for `From` as measured above, fails the same way when + the chat is created, because the same function names the sender and every recipient. +- With section 9: a group chat is created only when every address resolves, and found again only when every + member is given by the address its `email` holds, the primary address. Of the forms tested, only the primary + email can do both, provided it resolves when it is not the UPN (unverified, below). The UPN worked for every + other use tested: one-on-one chats, attachment sharing, and mail. + +### What this does not establish + +- **Unverified:** whether `users('
')` resolves a primary email that is not the UPN. The primary email + that resolved had also been that account's UPN until 2026-10-01, so the results do not separate "Graph + resolves the primary email" from "Graph still resolves a former UPN". Against the first: the 404 above names + the lookup as a user principal name lookup, and the Agent ID integration page addresses the user resource + directly only by UPN. Against the second: the new UPN and the old one both resolved within the same hour, at + least half an hour after the change, and the old one resolved again more than an hour after it and again the + next day. That rules out a replaced UPN that stays resolvable for less than a day, but not one Graph keeps + longer or for good. An account whose primary email has never been its UPN would settle it. So would the + former UPN, once the changed UPN is put back, if it stops resolving: a secondary address that was never a UPN + does not resolve, so the primary email would be what resolved, even if the former UPN stays among the + account's secondary addresses. +- **Unverified:** a secondary address given for a recipient; only `From` was tested. +- Each result came from one or two sends, and every UPN and primary email result involved the same recipient + account. The attachment share and the mail send used a UPN that was also a proxy address, so neither shows + what happens with a UPN that is not an email address; going by the documentation above, a mailbox user cannot + have one. +- Attachment sharing was tested only with a UPN, not with a primary email that is not the UPN or with a secondary + address. +- Nothing in this section was measured on Windows PowerShell 5.1 or with an SDK release other than 2.25.0. From becfb27e4666620108332965b74857c214e11252 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:28:23 -0500 Subject: [PATCH 37/40] Record settled design decisions for contributors Several settled decisions are written down nowhere in the repository, so a contributor or an AI assistant working here has to rediscover them or reopens them. - Add a Design decisions section to AGENTS.md: who approves public changes, services staying in this repository, what adding a service takes, why generic settings resolve in the core, and the Mg prefix convention. Record why boolean settings have no typed parameters. - Add three changelog conventions: placeholders that never worked, input the documented format never supported, and result values that depend on the service's response. - Add a section on the wiki's conventions. - Explain in RELEASING.md why develop does not allow GitHub's rebase merge, and how to get a clean history instead. - Explain at enum MessagingService why it is an enum rather than a string parameter with an argument completer. Contributor documentation only, so there is no changelog entry. --- AGENTS.md | 53 +++++++++++++++++++++++++++++++- RELEASING.md | 4 +++ ScriptMessage/ScriptMessage.psm1 | 4 +++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index cb036c6..68d8158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,9 @@ fix to something that itself landed after the last tag is invisible to users. Th never also appear as a "Fixed" entry, and it should not be listed among the functions a fix "affects." The same goes for a new configuration setting or message type. +**A placeholder that never worked is described by what it does now**, with no history and no "New Parameter" +label. In 1.1.1, `-MailType` was such a placeholder: its help read "TODO". + **A change the module adapted to is not a fix.** "Fixed" claims a defect in this module. If the code was never wrong and something underneath it changed (the Microsoft Graph API, the Microsoft Graph PowerShell SDK, or PowerShell itself), the entry belongs in **Changed**, or in **Added** when it gives callers something new. The @@ -108,6 +111,10 @@ Leave out: contributors, so link them from the commit message or the code, not from a user-facing entry. - **Anything with no observable effect on a caller.** If no script a user could reasonably write would have hit the bug, there is nothing to log. +- **Input the documented format never supported**, such as a true/false setting written as quoted text. It gets + no entry of its own, at most a general `Minor:` one. +- **Result values that depend on the service's response**, such as `Status` being `$true`. Help text leaves them + out too. Two or three sentences is a normal entry. Length is not thoroughness: someone scanning a release to see whether it affects them should get the answer in the first line. @@ -200,6 +207,33 @@ $Text = [System.IO.File]::ReadAllText($Path) [System.IO.File]::WriteAllText($Path, ($Text -replace "`r`n","`n")) # writes UTF-8 without a BOM ``` +## Design decisions + +These are settled. Don't reopen one without the maintainer. + +- **Exported functions' parameters and output change only with the maintainer's approval, and internal plumbing + never becomes a public parameter.** Pass plumbing to the private handlers instead. `Connect-ScriptMessage + -ServiceConfig` was added as plumbing and removed before it shipped for this reason. +- **Messaging services live in this repository, permanently.** Third-party drop-in service files are not a goal, + so no public handler contract gets designed or frozen. +- **Adding a service** takes one `MessagingService` enum value in `ScriptMessage/ScriptMessage.psm1`, one file in + `ScriptMessage/Services/`, and one `Register-ScriptMessageService` call at the bottom of that file. No function + in `ScriptMessage/Public/` switches on the service or calls a Microsoft Graph cmdlet. +- **`enum MessagingService` stays.** The comment above it in `ScriptMessage/ScriptMessage.psm1` says why; don't + replace it with a `[string]` parameter and an argument completer. +- **A generic setting is not necessarily supported by every service.** The module exists to standardize calls so + services are interchangeable, or so one call sends through several at once, which makes the unsupported case + normal rather than exceptional. A service that can't honor one reports it as the [Code](#code) section says. + There is no declaration of which settings each service supports. +- **Generic settings are resolved in the core, not in each service.** `MailType`, `ChatType`, and + `IncludeBCCInGroupChat` are shared vocabulary, so resolving them in each service would repeat the same merge + rule (a parameter beats the configuration file) in every one. A service resolves only its own prefixed + settings, such as `MgDisconnectWhenDone` and `MgApp_CertificatePath`. +- **A setting's name shows which layer owns it.** Every Microsoft Graph setting starts with `Mg` + (`MgPermissionType`, `MgDisconnectWhenDone`, `MgApp_*`), and generic settings have no prefix + (`AllowableMessageTypes`, `MailType`, `ChatType`, `IncludeBCCInGroupChat`). Give a new service's settings a + prefix of their own. + ## Code - **A new public function needs an explicit entry in `FunctionsToExport` in @@ -211,7 +245,9 @@ $Text = [System.IO.File]::ReadAllText($Path) - **A boolean configuration setting is an opt-in flag: on only when it equals `$true`.** Read it as `$ServiceConfig. -eq $true`, with the setting on the left, so missing, `false`, typos, and any quoted text except `"true"` (in any letter case, which counts as on) all mean off. Never read one with a `[bool]` cast, a truthiness test, or `$true -eq`: each of those reads the - text `"false"` as true. Name a new boolean setting so that `true` opts in and off is the safe state. + text `"false"` as true. Name a new boolean setting so that `true` opts in and off is the safe state. Typed + parameters per boolean setting were considered and rejected: configuration reaches a service as one object, so + they would need a restructure. - **A service that cannot honor a setting or parameter it receives says so in its result and sends what it can.** Add a `Warning` entry to that message's `Error` property, as the Microsoft Graph service does for a chat sent on behalf of someone else, rather than failing the whole call or dropping the value silently. @@ -250,3 +286,18 @@ one. `CHANGELOG.md` or other user-facing text. - **The safety rules above apply while gathering evidence:** no live sends without permission, and no tenant name or email domain in a note. + +## The wiki + +The wiki is the separate repository `Sekers/ScriptMessage.wiki`, and its only branch, `master`, publishes on +push. + +- **It describes the latest release**, with no version notes and nothing held back. Changes for the next release + are written ahead of it and pushed once that release has shipped. +- **A service's setup page is named after its `MessagingService` value**, with a hyphen between words so GitHub + shows a readable title: `Microsoft-Graph.md` for `MicrosoftGraph`. +- **Pages use LF line endings.** Put anything containing `$` in a code span or block, in case GitHub reads a pair + of dollar signs as math. +- **Its `.markdownlint.json` turns off MD041**, because GitHub shows each page's name as its title. Indent bullets + nested under bullets 2 spaces, and bullets under numbered steps 4. When re-indenting a bullet, move the tables + and paragraphs under it too; a linter quick fix moves only the bullet line. diff --git a/RELEASING.md b/RELEASING.md index a39ecaf..aa52caa 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -107,6 +107,10 @@ Rules for the shared branches: exist on `develop`, so every later release pull request would conflict with it. - **Never rebase or force-push `main` or `develop`.** Release tags cannot move, and rewriting shared history breaks every contributor's clone. Rebase only your own feature branch, and only before it merges. +- **`develop` does not allow GitHub's rebase merge**, because GitHub re-creates rebased commits without their + signatures ([About merge methods on GitHub](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/about-merge-methods-on-github)). + For a clean history, rebase your feature branch locally onto `origin/develop` (Git re-signs each commit when + signing is set up), force-push that branch, and merge it with a merge commit. - **After every release or hotfix, merge `main` back into `develop` with a merge commit, never a squash or a rebase**, so `develop` contains the tagged release commit. A squash or a rebase copies `main`'s changes into new commits instead, so `develop` never contains the tagged commit, and a later release pull request can diff --git a/ScriptMessage/ScriptMessage.psm1 b/ScriptMessage/ScriptMessage.psm1 index 95657ee..697a21f 100644 --- a/ScriptMessage/ScriptMessage.psm1 +++ b/ScriptMessage/ScriptMessage.psm1 @@ -16,6 +16,10 @@ $script:ScriptMessageServiceTable = [ordered]@{} # Public Enum # Name: MessagingService +# An enum, not a [string] parameter with an [ArgumentCompleter]: the enum rejects an unknown service name when +# parameters bind, before anything runs, while a completer only suggests names. The generator form, +# [ValidateSet([IValidateSetValuesGenerator])], needs PowerShell 6 or later, so it is unavailable to a module +# that supports Windows PowerShell 5.1. enum MessagingService { MicrosoftGraph } From 4b4c5c27e35502d6bb5f34b23ccc613343b8edf4 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:45:40 -0500 Subject: [PATCH 38/40] Let the maintainer commit straight to develop when CI adds little Every change has to reach develop through a pull request, even a documentation edit whose tests already passed locally, so the maintainer waits on a test run before merging that adds little. Keep pull requests required for main and for contributors. Let the maintainer's own changes that a test run before merging wouldn't benefit go straight to develop after a local test run; the Tests workflow still runs on the push. Keep pull requests for squashing a branch. Contributor documentation only, so there is no changelog entry. --- RELEASING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index aa52caa..7bccf24 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -102,7 +102,11 @@ Gitflow without release branches, because only one version is maintained at a ti Rules for the shared branches: -- **Changes reach `develop` and `main` only through pull requests**, and the tests must pass first. +- **Changes reach `main` only through pull requests**, and the tests must pass first. +- **Changes reach `develop` through pull requests too, except the maintainer's own changes that a test run before + merging wouldn't benefit**, such as documentation. Those may be committed straight to `develop` after running + the tests locally, and the Tests workflow still runs on the push. Use a pull request anyway to squash a branch's + commits. - **Merge into `main` with a merge commit, never a squash or a rebase.** A squashed commit on `main` does not exist on `develop`, so every later release pull request would conflict with it. - **Never rebase or force-push `main` or `develop`.** Release tags cannot move, and rewriting shared history From 9ded398963aeda8d25cbea6790252f27697f1a64 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:49:21 -0500 Subject: [PATCH 39/40] Point CONTRIBUTING.md at the branch rules instead of repeating them CONTRIBUTING.md and RELEASING.md both state the rebase and force-push rules, so rewording one leaves the other out of date. RELEASING.md owns the branch rules, so link to them from CONTRIBUTING.md. Contributor documentation only, so there is no changelog entry. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62bdbba..cca3142 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ Thank you for helping improve ScriptMessage. This guide covers the everyday work Windows PowerShell 5.1 misreads non-ASCII characters in PowerShell files saved without a byte order mark. `.gitattributes` handles line endings, the test suite checks all three rules, and [AGENTS.md](./AGENTS.md) explains them. -- **Rebase only your own branch, and only before it merges.** Never force-push `develop` or `main`. +- **Rebasing and force-pushing follow the [branch rules](./RELEASING.md#branches)** in RELEASING.md. AI assistants working in this repository follow [AGENTS.md](./AGENTS.md), which also holds the detailed rules for writing changelog entries. From 098bf8fe3c5441c2f01b21bef64475f50947fd21 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:12:55 -0500 Subject: [PATCH 40/40] Restore parentheses around changelog release dates The 1.1.1 heading uses a bare date while older releases use parentheses. Restore the parenthesized format and align the contributor guidance, release examples, and release workflow validation. --- .github/workflows/Release.yml | 4 ++-- AGENTS.md | 6 +++--- CHANGELOG.md | 2 +- RELEASING.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 5f2fc86..876e1e7 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -50,8 +50,8 @@ jobs: if ($ManifestVersion -ne $Tag) { throw "Tag $Tag does not match the manifest version $ManifestVersion." } # The changelog must have a dated section for this version. - $HeadingPattern = '^## \[' + [regex]::Escape($Tag) + '\](\([^)]*\))? - \d{4}-\d{2}-\d{2}\s*$' - if (-not (Select-String -Path 'CHANGELOG.md' -Pattern $HeadingPattern -Quiet)) { throw "CHANGELOG.md has no dated section for $Tag, such as '## [$Tag] - 2026-01-31'." } + $HeadingPattern = '^## \[' + [regex]::Escape($Tag) + '\](\([^)]*\))? - \(\d{4}-\d{2}-\d{2}\)\s*$' + if (-not (Select-String -Path 'CHANGELOG.md' -Pattern $HeadingPattern -Quiet)) { throw "CHANGELOG.md has no dated section for $Tag, such as '## [$Tag] - (2026-01-31)'." } # [Unreleased] exists only on develop, so a released CHANGELOG.md never carries one. if (Select-String -Path 'CHANGELOG.md' -Pattern '^## \[Unreleased\]' -Quiet) { throw "CHANGELOG.md still has an [Unreleased] section. The release-prep pull request renames it to the $Tag heading and adds no new one." } diff --git a/AGENTS.md b/AGENTS.md index 68d8158..99ded45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,13 +134,13 @@ covers when `[Unreleased]` becomes a version and how that version is chosen. entry also adds `## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/...develop)` at the top, where `` is `$Release` from the baseline commands above. Never add an empty one, and never leave one in a release: the Release workflow refuses to publish while it is there. -- Newest version first. A released version uses this heading shape, and a `---` line, with a blank line on each - side of it, separates it from the next (older) version: +- Newest version first. Put release dates in parentheses. A released version uses this heading shape, and a + `---` line, with a blank line on each side of it, separates it from the next (older) version: ```markdown --- - ## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - 2026-09-20 + ## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - (2026-09-20) ### Fixed diff --git a/CHANGELOG.md b/CHANGELOG.md index 644e752..d41fcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ --- -## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 +## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - (2026-09-16) ### Changed diff --git a/RELEASING.md b/RELEASING.md index 7bccf24..075df54 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -143,7 +143,7 @@ section. 2. **Open a release-prep pull request into `develop`** that changes only: - `ModuleVersion`, and `Prerelease` for a prerelease, in `ScriptMessage/ScriptMessage.psd1`. - `CHANGELOG.md`: rename `## [Unreleased](...)` to a dated heading such as - `## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - 2026-09-20`. Do not add a new + `## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - (2026-09-20)`. Do not add a new `[Unreleased]` section. 3. **Open a pull request from `develop` into `main`**, and merge it with a merge commit once the checks pass. Merge nothing else into `develop` until then, so no new `[Unreleased]` section reaches `main`. @@ -189,7 +189,7 @@ immutable releases would lock, for a version that did not ship. ## Prereleases A prerelease follows the same steps, with three differences: the release-prep pull request also sets -`Prerelease`, the changelog heading names the prerelease (`## [2.0.0-rc01](...) - 2026-09-20`), and the tag goes +`Prerelease`, the changelog heading names the prerelease (`## [2.0.0-rc01](...) - (2026-09-20)`), and the tag goes on the release-prep merge commit on `develop` instead of on `main`. When the final version ships, its changelog section covers everything since the last stable release, not only the changes since the last prerelease.