diff --git a/CHANGELOG.md b/CHANGELOG.md index 558fabd..82e931f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,25 @@ ### 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. +- `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 - 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. + +### 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. @@ -23,10 +33,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 new file mode 100644 index 0000000..a4a58c9 --- /dev/null +++ b/Research_Notes/Certificate-File-Behavior.md @@ -0,0 +1,167 @@ +# 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`). 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 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.** + +- **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, 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 + +| 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:** 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 + +`[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`, 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 + +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`, 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 +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: how the module loads the file now + +`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 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 + +- 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 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/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..f7737d6 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -111,16 +111,27 @@ 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 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. +`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, { ... }` | 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 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 - 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. @@ -136,10 +147,12 @@ 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 +## 6. Measured: pipeline input, positional binding, and the `process` block Piping `'a','b','c'` into an advanced function whose parameter has `ValueFromPipeline = $true`: @@ -148,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 @@ -438,3 +507,77 @@ 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`. + +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, +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. + +### What this does not establish + +- 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. diff --git a/ScriptMessage/Private/Register-ScriptMessageService.ps1 b/ScriptMessage/Private/Register-ScriptMessageService.ps1 index ca41bc3..85bd345 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. #> [CmdletBinding()] diff --git a/ScriptMessage/Public/Connect-ScriptMessage.ps1 b/ScriptMessage/Public/Connect-ScriptMessage.ps1 index 1da54f8..4b84109 100644 --- a/ScriptMessage/Public/Connect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Connect-ScriptMessage.ps1 @@ -12,9 +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. .PARAMETER ReturnConnectionInfo Returns connection information after performing function. @@ -24,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, @@ -32,27 +30,17 @@ function Connect-ScriptMessage ValueFromPipelineByPropertyName = $true)] [MessagingService]$Service, - [Parameter( - Mandatory = $false, - ValueFromPipelineByPropertyName = $true)] - [pscustomobject]$ServiceConfig, - [parameter( - Position=1, - Mandatory=$false, - ValueFromPipeline=$true, - ValueFromPipelineByPropertyName=$true)] + Mandatory=$false)] [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. - if (-not $PSBoundParameters.ContainsKey('ServiceConfig')) - { - $ServiceConfig = Get-ScriptMessageConfig -Service $Service - } + # 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 + ServiceConfig = $ServiceConfig + ConfigFilePath = $ServiceConfig.ConfigFilePath } # Connect to the proper service. Each service checks for the modules its allowed message types need. 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 4b11f7b..8f178cc 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -149,248 +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. - $ScriptMessageConfig = Get-ScriptMessageConfig - - # 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 body into properly formatted PSObject. - $Body = ConvertTo-ScriptMessageBodyObject -Body $Body - - # Convert attachments into properly formatted PSObject. - $Attachment = ConvertTo-ScriptMessageAttachmentObject -Attachment $Attachment - - 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 - } - } - - foreach ($serviceTypeObj in $ServiceType) # TODO: Catch errors once we have multiple services so if one fails the other(s) can still be processed. + # 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 { - # 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) + # 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 "The ScriptMessage configuration file has no `'$($Registration.Name)`' section. Add one for that messaging service, or send from a service the file configures." + throw 'Please provide at least one parameter value for any of the following: To, CC, or BCC' } - # Set the connection parameters. - $ConnectionParameters = @{ - ServiceConfig = $ServiceConfig - } + # Convert body into properly formatted PSObject. + $Body = ConvertTo-ScriptMessageBodyObject -Body $Body - # 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 - } + # Convert attachments into properly formatted PSObject. + $Attachment = ConvertTo-ScriptMessageAttachmentObject -Attachment $Attachment - # '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 + if ($null -ne $Service) # If ServiceAndTypeSeparate { - [bool]$ResolvedIncludeBCCInGroupChat = ($ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat -eq $true) + # 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 + } } - # '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) + # 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) { - $ConfiguredMailType = $ConnectionParameters.ServiceConfig.MailType - if ($PSBoundParameters.ContainsKey('MailType')) - { - $ResolvedMailType = $MailType - } - elseif ([string]::IsNullOrWhiteSpace($ConfiguredMailType)) - { - $ResolvedMailType = [MailType]::Group - } - elseif ($ConfiguredMailType -in [Enum]::GetNames([MailType])) + # A MessageServiceType can name several services, and each one gets its own send. + foreach ($serviceItem in $serviceTypeObj.Service) { - $ResolvedMailType = [MailType]$ConfiguredMailType + $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." + } + + # 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 = $ServiceConfig.ChatType + } + + # '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) + } + + # '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`'." + } + } + + $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 + } + + # [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 + } } - 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). - 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 } - # [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)) + # 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) { - $SendMessageParameters['ChatType'] = $ResolvedChatType - } + # 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 - if ($null -ne $ResolvedMailType) - { - $SendMessageParameters['MailType'] = $ResolvedMailType + $SendMessageParameters = $preparedSend.SendMessageParameters + & $preparedSend.Registration.SendFunction @SendMessageParameters } - - & $Registration.SendFunction @SendMessageParameters } } \ No newline at end of file diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index aaeeae4..8e95b0e 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. + [Parameter( + Mandatory = $false, + ValueFromPipelineByPropertyName = $true)] + [string]$ConfigFilePath ) # Collect the connection settings from the service configuration. @@ -365,37 +371,69 @@ 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') + # 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 + # 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 + $PSCmdlet.SessionState.Path.IsPSAbsolute($MgApp_CertificatePath, [ref]$DriveName) -or + $MgApp_CertificatePath.StartsWith('~')) + if ($IsRelativePath -and -not [string]::IsNullOrEmpty($ConfigFilePath)) { - $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; the rest is used as written. - $MgApp_CertificatePath = $ServiceConfig.MgApp_CertificatePath -replace '\$\{env:([^}]+)\}|\$env:(\w+)', { - [Environment]::GetEnvironmentVariable($_.Groups[1].Value + $_.Groups[2].Value) + $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 + # 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 64e7d5a..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 { @@ -69,14 +80,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 { } @@ -105,7 +119,245 @@ Describe 'Connect-ScriptMessage certificate file path' -Skip:($PSVersionTable.PS MgApp_CertificatePath = $Setting } - Connect-ScriptMessage -Service MicrosoftGraph -ServiceConfig $ServiceConfig -ErrorAction Stop - Should -Invoke -ModuleName ScriptMessage Get-PfxCertificate -Times 1 -Exactly -ParameterFilter { $FilePath -eq $Expected } + Invoke-MicrosoftGraphConnect -ServiceConfig $ServiceConfig + 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' { + Invoke-MicrosoftGraphConnect -ServiceConfig $ServiceConfig + + 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 + + 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' { + { Invoke-MicrosoftGraphConnect -ServiceConfig $ServiceConfig } | + 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' + + { Invoke-MicrosoftGraphConnect -ServiceConfig $Missing } | + 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' { + 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 New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -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 New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -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 New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq (Join-Path $CurrentFolder '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 New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -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 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 '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 New-Object -Times 1 -Exactly -ParameterFilter { + $ArgumentList[0] -eq $Expected + } + } + + 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 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 + + 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 + Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 1 -Exactly -ParameterFilter { + $Certificate.Thumbprint -eq $TestThumbprint -and $Certificate.HasPrivateKey + } } } 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 2bdcd17..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) @@ -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 7a8b0bb..f317772 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,73 @@ 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 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 { } + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } Mock -ModuleName ScriptMessage Get-ScriptMessageContext { [pscustomobject]@{ Account = 'sender@example.org' @@ -276,7 +330,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 +358,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 +551,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 +588,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 +886,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() @@ -827,9 +898,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' { - Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } - + It 'Connect-ScriptMessage reads the file itself' { $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..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 } } @@ -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 + } + } }