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/.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/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 280109c..1cf9408 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -34,14 +34,18 @@ 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. + # Research_Notes/Test-Tooling-Behavior.md says what to recheck when changing it. + 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/AGENTS.md b/AGENTS.md index 5dc8a3e..99ded45 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. @@ -69,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 @@ -105,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. @@ -124,11 +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 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 @@ -142,8 +154,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. @@ -178,28 +191,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 @@ -207,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 @@ -215,6 +242,31 @@ $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`, 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. 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. + `Send-ScriptMessage` passes every message parameter to every service, so each service's send function must + accept all of them. + +## 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 @@ -234,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/CHANGELOG.md b/CHANGELOG.md index 60882dd..d41fcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,51 @@ # Changelog for ScriptMessage PowerShell Module -## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 +## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/1.1.1...develop) + +### Added + +- `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. +- `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 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`. +- 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. + +--- + +## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - (2026-09-16) ### Changed @@ -20,6 +65,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 @@ -44,6 +90,7 @@ Author: [**@Sekers**](https://github.com/Sekers) --- + ## [1.0.8](https://github.com/Sekers/ScriptMessage/tree/1.0.8) - (2025-09-18) ### Fixes @@ -66,6 +113,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 @@ -79,6 +127,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 @@ -99,6 +148,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 @@ -108,6 +158,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 @@ -117,6 +168,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 @@ -126,6 +178,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 @@ -135,6 +188,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 @@ -144,7 +198,8 @@ 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/CONTRIBUTING.md b/CONTRIBUTING.md index a9a6c5a..cca3142 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,16 @@ 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. -- **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. + 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, 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, and [AGENTS.md](./AGENTS.md) explains the rules and how to check them. -- **Rebase only your own branch, and only before it merges.** Never force-push `develop` or `main`. + `.gitattributes` handles line endings, the test suite checks all three rules, and [AGENTS.md](./AGENTS.md) + explains them. +- **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. 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..075df54 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, @@ -90,12 +102,25 @@ 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 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. +- **`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 + 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 @@ -108,8 +133,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 @@ -117,7 +143,7 @@ publish a tag whose `CHANGELOG.md` still has an `[Unreleased]` 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`. @@ -138,7 +164,7 @@ publish a tag whose `CHANGELOG.md` still has an `[Unreleased]` 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. @@ -157,13 +183,13 @@ 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 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. @@ -173,7 +199,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. @@ -193,6 +220,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. -- [ ] `main` was merged back into `develop`. -- [ ] Anything that needs a live Microsoft Graph test was tested separately in a test tenant. The automated tests - never connect to a tenant. +- [ ] 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, sending messages and files only to + accounts the tester controls. The automated tests never connect to a tenant. 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 08332d2..8ae1153 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. @@ -151,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 7d87b49..f4eedfa 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -1,12 +1,16 @@ # 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 -persists, and what disconnecting after a send changes. Each claim is tied to the part of the module it affects. +permissions, what a successful send returns, chat creation and reuse, attachment and upload limits, how SDK sign-in +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 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. +**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.** @@ -15,8 +19,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 +40,23 @@ 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) | +| [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 @@ -89,6 +110,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 @@ -99,8 +131,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 @@ -119,6 +151,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 @@ -130,6 +168,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 @@ -197,10 +237,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:** @@ -223,3 +263,256 @@ 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 Graph 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. + +## 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. 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. +- **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. +- **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. + +**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). +- **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 +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. +- 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 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 + 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. + +## 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. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 908c3a0..f7737d6 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -39,8 +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 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. 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 @@ -84,8 +87,53 @@ 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 +`[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. +- 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. ## 5. Measured: looking up a property by a name held in an array @@ -99,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`: @@ -111,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 @@ -176,12 +282,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 | @@ -190,7 +296,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 @@ -235,3 +341,243 @@ 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 | +| `-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 +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 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. 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`, 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 + +- 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. +- 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. + +## 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. + +## 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/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/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..85bd345 --- /dev/null +++ b/ScriptMessage/Private/Register-ScriptMessageService.ps1 @@ -0,0 +1,44 @@ +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()] + 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..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,23 +31,21 @@ function Connect-ScriptMessage [MessagingService]$Service, [parameter( - Position=1, - Mandatory=$false, - ValueFromPipeline=$true, - ValueFromPipelineByPropertyName=$true)] + Mandatory=$false)] [switch]$ReturnConnectionInfo ) - - # Set the connection parameters. + + # 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 = Get-ScriptMessageConfig -Service $Service + ServiceConfig = $ServiceConfig + ConfigFilePath = $ServiceConfig.ConfigFilePath } - + # 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 4f29b3d..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,18 +31,18 @@ function Disconnect-ScriptMessage [MessagingService]$Service, [parameter( - Position=1, - Mandatory=$false, - ValueFromPipeline=$true, - ValueFromPipelineByPropertyName=$true)] + Mandatory=$false)] [switch]$ReturnConnectionInfo ) # 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 + # cache does not hold does nothing, which is the case when nothing has asked for the context yet. + $script:ScriptMessageCachedServiceContext.PSObject.Properties.Remove([string]$Service) # Return the disconnection information, if requested. if ($ReturnConnectionInfo) @@ -58,18 +59,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-ScriptMessageConfig.ps1 b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 index 639b962..ec27f81 100644 --- a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 @@ -10,10 +10,12 @@ 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. + .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,23 +33,27 @@ 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." } - # 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 +75,45 @@ 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)) + { + $Result = $ScriptMessageConfig.$Service } + else + { + $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 3e15b11..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 ) @@ -42,7 +40,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 } @@ -58,28 +56,26 @@ function Get-ScriptMessageContext $ScriptMessageContext | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) } - # Retrieve connection information. - switch ($Service) + # Ask the service for its own connection information. + $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. + if (-not [string]::IsNullOrEmpty($ServiceConnectionInfo)) { - 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) - } + foreach ($infoItem in $($ServiceConnectionInfo.PSObject.Properties)) + { + $ScriptMessageContext | Add-Member -MemberType NoteProperty -Name "$($infoItem.Name)" -Value $($infoItem.Value) } } # 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/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index cd30b05..8f178cc 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -49,8 +49,11 @@ 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'. + '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 @@ -58,8 +61,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 +73,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 +87,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 +102,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 @@ -117,7 +120,7 @@ function Send-ScriptMessage # $Content2 = Get-Content -AsByteStream -Raw -Path 'C:\Users\John\Downloads\AMovie.mp4' $Attachment = @( - @{ + @{ Name = 'MyPDF.pdf' Content = $Content1 }, @@ -125,188 +128,212 @@ function Send-ScriptMessage Name = 'funnyballgame.mp4' Content = $Content2 } - } + ) $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!" } Attachment = $Attachment } + + Send-ScriptMessage -Service MicrosoftGraph @MessageArguments #> [CmdletBinding()] 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, - # TODO: Implement MailType (similar to ChatType so we can do 1:1 emailing) [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)] - [ValidateSet($null, $true, $false)] - [Object]$IncludeBCCInGroupChat # Is an object so it can be set to $null + 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 + # 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 { - # 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 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' } - } - foreach ($serviceTypeObj in $ServiceType) # TODO: Catch errors once we have multiple services so if one fails the other(s) can still be processed. - { - # Set the connection parameters. - $ConnectionParameters = @{ - ServiceConfig = $ScriptMessageConfig.$($serviceTypeObj.Service) - } + # Convert body into properly formatted PSObject. + $Body = ConvertTo-ScriptMessageBodyObject -Body $Body + + # Convert attachments into properly formatted PSObject. + $Attachment = ConvertTo-ScriptMessageAttachmentObject -Attachment $Attachment - # Set default values if not specified by a parameter. - if (-not $ChatType) + if ($null -ne $Service) # If ServiceAndTypeSeparate { - [ChatType]$ChatType = $ConnectionParameters.ServiceConfig.ChatType + # 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 + } } - if (-not $IncludeBCCInGroupChat) + + # 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) { - [bool]$IncludeBCCInGroupChat = $ConnectionParameters.ServiceConfig.IncludeBCCInGroupChat - } + # A MessageServiceType can name several services, and each one gets its own send. + foreach ($serviceItem in $serviceTypeObj.Service) + { + $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." + } - # Connect to the messaging service, if necessary (e.g., API service). - Connect-ScriptMessage -Service $($serviceTypeObj.Service) -ErrorAction Stop + # 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`'." + } + } - switch ($($serviceTypeObj.Service)) - { - 'MicrosoftGraph' { $SendMessageParameters = [ordered]@{ From = $From ReplyTo = $ReplyTo @@ -319,19 +346,45 @@ function Send-ScriptMessage Attachment = $Attachment SenderId = $SenderId Type = $serviceTypeObj.Type - ChatType = $ChatType - IncludeBCCInGroupChat = $IncludeBCCInGroupChat + IncludeBCCInGroupChat = $ResolvedIncludeBCCInGroupChat + ServiceConfig = $ServiceConfig } - Send-ScriptMessage_MicrosoftGraph @SendMessageParameters + # [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 + } - # Disconnect from Microsoft Graph API, if enabled in config. - if ($ConnectionParameters.ServiceConfig.MgDisconnectWhenDone) + if ($null -ne $ResolvedMailType) { - $null = Disconnect-MgGraph -ErrorAction SilentlyContinue + $SendMessageParameters['MailType'] = $ResolvedMailType + } + + [PSCustomObject]@{ + Registration = $Registration + ServiceConfig = $ServiceConfig + SendMessageParameters = $SendMessageParameters } } - Default {throw "Invalid `'Service`' value."} + } + + # 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 } } } \ No newline at end of file diff --git a/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 b/ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1 index 0333c40..e8a6bd5 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 @@ -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 e8998a0..697a21f 100644 --- a/ScriptMessage/ScriptMessage.psm1 +++ b/ScriptMessage/ScriptMessage.psm1 @@ -1,5 +1,14 @@ -# Global Variables -New-Variable -Name 'ScriptMessage_Global_CachedServiceContext' -Value ([PSCustomObject]@{}) -Scope Global -Force +# Module Variables +# 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 @@ -7,6 +16,10 @@ New-Variable -Name 'ScriptMessage_Global_CachedServiceContext' -Value ([PSCustom # 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 } diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 35abe37..4026e92 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,34 +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 + $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 + } } - - $MgApp_CertificatePath = $ExecutionContext.InvokeCommand.ExpandString($ServiceConfig.MgApp_CertificatePath) - # 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 @@ -422,6 +463,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()] @@ -495,6 +543,12 @@ function Send-ScriptMessage_MicrosoftGraph ValueFromPipelineByPropertyName = $true)] [string]$SenderId, + [Parameter( + Mandatory = $false, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true)] + [MailType]$MailType = 'Group', + [Parameter( Mandatory = $false, ValueFromPipeline = $true, @@ -505,15 +559,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 + } # Send the message on each supported service specified. foreach ($typeItem in $Type) @@ -577,9 +639,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 @@ -664,7 +787,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 @@ -693,6 +816,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. @@ -887,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') { @@ -902,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 } } @@ -1039,4 +1169,18 @@ function Send-ScriptMessage_MicrosoftGraph } } } -} \ No newline at end of file + + # 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. +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/Connect-ScriptMessage.Tests.ps1 b/Tests/Connect-ScriptMessage.Tests.ps1 index ed608af..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 { @@ -68,3 +79,285 @@ Describe 'Connect-ScriptMessage Microsoft Graph module check' { } } } + +# 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 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 { } + 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 + } + + 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/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 + } +} 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/Get-ScriptMessageConfig.Tests.ps1 b/Tests/Get-ScriptMessageConfig.Tests.ps1 index 44da781..66252f5 100644 --- a/Tests/Get-ScriptMessageConfig.Tests.ps1 +++ b/Tests/Get-ScriptMessageConfig.Tests.ps1 @@ -104,3 +104,81 @@ 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 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') } | + 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*' + } +} 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 + } + } +} 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/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.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 index 364e491..5a38dc3 100644 --- a/Tests/ScriptMessage.Module.Tests.ps1 +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -18,11 +18,120 @@ 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) + # 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. + # + # 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. + # + # Research_Notes/Test-Tooling-Behavior.md records the measurements behind these choices. + It 'has no PSScriptAnalyzer errors' { + if ($PSVersionTable.PSEdition -ne 'Core') + { + 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) + 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 } } + +# 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/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 7ec8701..1a0ba35 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' @@ -133,8 +187,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 +194,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'OneOnOne' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -154,7 +205,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 +222,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'Group' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -182,7 +232,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' @@ -192,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 { @@ -199,7 +306,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'OneOnOne' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -212,6 +318,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 +340,6 @@ Describe 'Send-ScriptMessage chat message' { MicrosoftGraph = [pscustomobject]@{ AllowableMessageTypes = @('Mail', 'Chat') MailType = 'Group' - ChatType = 'OneOnOne' IncludeBCCInGroupChat = $false MgPermissionType = 'Delegated' MgDisconnectWhenDone = $false @@ -245,7 +351,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' } } } @@ -253,6 +359,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 @@ -280,7 +387,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 = @{ @@ -305,3 +412,580 @@ Describe 'Send-ScriptMessage mail attachment' { } } } + +Describe 'Send-ScriptMessage mail type' { + BeforeAll { + Mock -ModuleName ScriptMessage Connect-ScriptMessage_MicrosoftGraph { } + 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_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() + $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_MicrosoftGraph { } + 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 'Boolean settings and parameters' { + 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 + } + } + + # 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 { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + ChatType = 'Group' + IncludeBCCInGroupChat = 'false' + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + 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 as "false"' { + 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 '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 + } + } + } + + 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 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() + $Arguments['Type'] = @('Mail', 'Chat') + + $null = Send-ScriptMessage @Arguments -WarningAction SilentlyContinue + + Should -Invoke -ModuleName ScriptMessage Get-ScriptMessageConfig -Times 1 -Exactly + } + + It 'Connect-ScriptMessage reads the file itself' { + $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 + # 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 + } + } +} diff --git a/Tests/ServiceDispatch.Tests.ps1 b/Tests/ServiceDispatch.Tests.ps1 new file mode 100644 index 0000000..e376e7f --- /dev/null +++ b/Tests/ServiceDispatch.Tests.ps1 @@ -0,0 +1,132 @@ +# 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, $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 } } + + $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' + } + } + + # 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 + } + } +}