diff --git a/CHANGELOG.md b/CHANGELOG.md index d18bbe9..e67fe3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,38 @@ All notable changes to this module are recorded here. Format follows ## [Unreleased] -Nothing yet. +### Added + +- **The module detects which PowerShell it is running on, once, and uses what that one can + do.** Windows PowerShell 5.1 stays, because a freshly built domain controller has nothing else; + the REST providers are better served by PowerShell 7.4. `Get-TestRuntime` in Core reads the + edition at import and detects each capability on the cmdlet that has it - a parameter that + exists on `Invoke-WebRequest` is one that works, whatever the build - and everything that + differs by edition reads that object instead of testing `$PSVersionTable` for itself, which + seven places used to do. `Get-TestEnvironmentRuntime` shows the decision: Edition, Version, + Platform, the capabilities found, what the HTTP layer does with them, and which PowerShell is + preferred - 7.4 or later for every provider, with 5.1 supported for the domain controller - + said in the object so a run on the slower edition sees it without reading the help. + + What differs today: on PowerShell 7 a failed response is asked for with `-SkipHttpErrorCheck` + and its body read like any other, and TLS is left to negotiate; on Windows PowerShell the + cmdlet throws and the body is read once from the exception's response stream, and TLS 1.2 is + added. HTTP/2 is not requested: `-HttpVersion 2.0` was tried on PowerShell 7 and a core-tier + PingOne seed measured 25 seconds with it and 25 without, twice each, and a capability that + changes nothing is noise. Either way a provider now receives one error shape - an integer + `Response.StatusCode`, a `Response.Headers` table with each value one string, the body in + `ErrorDetails` - so the four `Get-ErrorDetail` helpers no longer read a stream and the + retry loops read one header table on both editions. A transport failure with no response + propagates untouched. Verified live against PingOne from both editions: the same 404 and the + same validation refusal, word for word, and the headers present on both. + + What was tried and not kept: moving the Active Directory user and device steps from + `Start-Job` onto the runspace pool. Measured on the lab domain controller, the users were + created in 10 seconds instead of 28 and then 270 of 310 manager assignments failed with + "invalid enumeration context", and the device step took five and a half minutes instead of 33 + seconds with 148 failures: the RSAT module keeps one ADWS session per process, and runspaces in + one process trample its enumeration contexts. The steps stay on a process per batch, and + `CLAUDE.md` says why. ## [1.3.0] - 2026-09-14 diff --git a/CLAUDE.md b/CLAUDE.md index a290fc5..eeb50dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,34 @@ now proves every `$script:` variable a provider reads is assigned somewhere. Every provider shares one session state, so two providers defining the same function name means the second silently wins. The contract test checks for that too. +### The edition is read once, in `Core/Get-TestRuntime.ps1`, and nothing else tests `$PSVersionTable` for it + +The module runs on Windows PowerShell 5.1, because a freshly built domain controller has nothing +else, and on PowerShell 7.4, which the REST providers are better served by. `Get-TestRuntime` +detects what the running PowerShell can do - on the cmdlet, never from a version number: a +parameter that exists on `Invoke-WebRequest` is one that works - caches the answer in +`$script:TestEnvironmentRuntime` at import, and `Get-TestEnvironmentRuntime` shows it. Everything +that differs by edition reads that object; a new `$PSVersionTable.PSEdition` test elsewhere is the +old scattering coming back. + +What it decides today lives in `Invoke-TestWebRequest`: with `-SkipHttpErrorCheck` (PowerShell 7) +a failed response comes back as a response and its body is read like any other; without it +(Windows PowerShell) the cmdlet throws and the body is read once from the exception's response +stream. Either way the caller gets the one shape `New-TestWebRequestError` builds - an integer +`Response.StatusCode`, a hashtable `Response.Headers` with each value one string, the body in +`ErrorDetails` - so a provider's retry loop and its `Get-ErrorDetail` read one thing and +nothing outside Core touches a response stream. TLS 1.2 is added only where the edition needs it. +HTTP/2 is deliberately not requested: `-HttpVersion 2.0` was tried on 7 and a core-tier PingOne seed +measured 25 seconds with it and 25 without, twice each, so it is not a decision the runtime makes. A +transport failure with no response propagates untouched. The two things that are the same on both editions - bodies as UTF-8 bytes, responses +decoded from raw bytes - are in the section below and are not optional on either. + +Two lessons from building it, both in the suite: a `WebHeaderCollection` assigned from an `if` +expression arrives as an array of its key names, because a statement's output is enumerated on the +way out, so it is assigned inside the branch; and Windows PowerShell rewraps a bare thrown +exception and loses a member added to it, so a test fake that needs `Response` on its exception +throws an `ErrorRecord`. + ### FreeIPA sends in batches; Authentik works on a runspace pool; both keep the row as the unit The FreeIPA seed steps for users, hosts and DNS records decide each row one at a time - the @@ -70,13 +98,23 @@ block inline; a suite that forgets to would call the real instance URL from the DNS rather than pass. The groups sweep asks for one worker, because a group is deleted before the group it nests under and that order has to hold. -### Never read module scope inside `Start-Job` +The Active Directory user and device steps stay on `Start-Job`, a process per batch, and that is +not an oversight. Moving them onto the pool was tried on 2026-09-16 and measured on the lab domain +controller: the users were created in 10 seconds instead of 28, and then 270 of 310 manager +assignments failed with "invalid enumeration context" and "a connection to the directory was +unavailable", and the device step took five and a half minutes instead of 33 seconds with 148 +failures. The RSAT `ActiveDirectory` module keeps one ADWS session per process, and runspaces in +one process trample its enumeration contexts; a process per batch is what keeps them apart. The +jobs cost the marshalling through `-ArgumentList`, and they are worth it. + +### Never read module scope inside `Start-Job` or a worker block -A job runs in a fresh runspace where `$script:Anything` is empty and module functions are -undefined, and neither fails loudly. The AD provider once did this for its prefix: a live -run created 688 computers with no prefix and silently failed to create all 296 users. Pass -values through `-ArgumentList`. A contract test walks every `Start-Job` body for `$script:` -reads and module-function calls. +A job and a worker runspace both start without this session's state: `$script:Anything` is empty +there, and in a job the module's functions are undefined as well, and neither fails loudly. The AD +provider once did this for its prefix: a live run created 688 computers with no prefix and silently +failed to create all 296 users. Pass values through `-ArgumentList` on a job and `-Parameter` on +the pool. A contract test walks every `Start-Job` body for `$script:` reads and module-function +calls, and every `Invoke-TestParallel` block for `$script:` reads. ### The AD commands keep a `Test` infix; the others do not @@ -184,7 +222,8 @@ and its error and retry handling: `Invoke-RestMethod`. 5.1 decodes by the declared charset and falls back to Latin-1; Okta declares none, which turned every accented name into mojibake. A service that declares UTF-8 today is not a reason to trust it, because the header is not this module's to control. -- **TLS 1.2 is added on the Desktop edition**, only ever adding to the enabled set. +- **TLS 1.2 is added on the Desktop edition**, only ever adding to the enabled set. PowerShell 7 + negotiates on its own, and `Get-TestRuntime` is what says which this is. - **The progress bar is suppressed** around `Invoke-WebRequest`, which on 5.1 costs more than the calls. FreeIPA reaches the same result through `HttpClient`: `StringContent` with UTF-8 out, and @@ -436,13 +475,14 @@ then a SecretManagement secret named `PSGallery-ApiKey`. ## Targeting Windows PowerShell 5.1 and PowerShell 7, `CompatiblePSEditions = Desktop, Core`. That rules -out the ternary and null-coalescing operators, `ForEach-Object -Parallel`, and anything else -7-only, anywhere in `Core/`, `Providers/` or `Public/`. The `desktop` job in -`quality-gates.yml` imports the module under 5.1 to catch it, and then runs the whole suite -there, because 5.1 also differs at run time in ways a suite run on 7 cannot see: string bodies -sent as Latin-1, responses decoded by their declared charset, a name above the basic plane -measured one longer. The build script under -`Build/` is 7.4-only, which is fine: it never ships. +out the ternary and null-coalescing operators, `ForEach-Object -Parallel`, `Sort-Object -Stable`, +and anything else 7-only, anywhere in `Core/`, `Providers/` or `Public/`; a 7-only *parameter* is +used only behind a capability `Get-TestRuntime` detected, the way `Invoke-TestWebRequest` uses +`-SkipHttpErrorCheck`. The `desktop` job in `quality-gates.yml` imports the +module under 5.1 to catch it, and then runs the whole suite there, because 5.1 also differs at run +time in ways a suite run on 7 cannot see: string bodies sent as Latin-1, responses decoded by their +declared charset, a name above the basic plane measured one longer, a parameter the 7 suite passed +that 5.1 does not have. The build script under `Build/` is 7.4-only, which is fine: it never ships. ## Checks diff --git a/Core/Get-TestRuntime.ps1 b/Core/Get-TestRuntime.ps1 new file mode 100644 index 0000000..449c1ef --- /dev/null +++ b/Core/Get-TestRuntime.ps1 @@ -0,0 +1,128 @@ +function Get-TestRuntime { + <# + .SYNOPSIS + Detects the PowerShell the module is running on and decides, once, which methods it uses + + .DESCRIPTION + The module targets Windows PowerShell 5.1, because a freshly built domain controller has + nothing else, and PowerShell 7.4, which the REST providers are better served by. The two + differ in what Invoke-WebRequest can do and in what it gets wrong, and every place that + cares used to test $PSVersionTable for itself. This is now the one place the edition is + read, and what it returns is what the rest of the module acts on. + + Each capability is detected on the cmdlet, never inferred from a version number: a + parameter that exists is one that works on whatever build this is, and a version check + would be wrong the day a backport or a preview moved one. The result is computed on + first use and cached in module scope, because it cannot change for the life of the + session. + + What is decided here: + + - Http.ErrorBody: how the body of a failed response is read. 'SkipHttpErrorCheck' where + Invoke-WebRequest has that switch (PowerShell 7), so a 4xx or 5xx comes back as a + response and the body is read like any other; 'ResponseStream' on Windows PowerShell, + where the cmdlet throws and the body has to be pulled from the exception's response + stream, once, because the stream cannot be read twice. + - Not HTTP/2, deliberately. -HttpVersion exists from PowerShell 7.3 and was tried: a + core-tier PingOne seed measured 25 seconds with it and 25 without, twice each, so it + is not requested and not listed. A capability that changes nothing is noise. + - Http.Tls: 'Default' on PowerShell 7, whose HttpClient negotiates TLS 1.2 and 1.3 on + its own; 'Tls12Added' on Windows PowerShell, which can still default to TLS 1.0 and + has TLS 1.2 added to the enabled set, never removing any. + - Http.Encoding and Http.Progress are the same on both editions and are listed so the + object says everything the HTTP layer does: the body always goes out as UTF-8 bytes + and the response is always decoded from its raw bytes, because Windows PowerShell + corrupts both silently and PowerShell 7 merely hides that; and the progress bar is + suppressed around every call, which on Windows PowerShell costs more than the call. + - Parallel: 'RunspacePool' on both. Invoke-TestParallel runs a block over many items on + a pool of runspaces with the module loaded, which is the mechanism ForEach-Object + -Parallel is built on and is available on Windows PowerShell 5.1. + - Preferred and Recommendation: whether this is the PowerShell the module is best run + on, and one sentence saying so. PowerShell 7.4 or later is preferred for every + provider; Windows PowerShell 5.1 is supported because a freshly built domain + controller has nothing else, and the Active Directory provider is at home there. + Said here, in the object, so a run on the slower edition can see it without reading + the help. + + .PARAMETER Refresh + Detect again rather than returning the cached result. For tests. + + .EXAMPLE + PS> (Get-TestRuntime).Http.ErrorBody + + DESCRIPTION: Which way a failed response's body is read on this host + OUTPUT: SkipHttpErrorCheck on PowerShell 7, ResponseStream on Windows PowerShell + USE CASE: Called by Invoke-TestWebRequest on every call, from the cache + + .OUTPUTS + PSCustomObject of type TestEnvironmentRuntime: Edition, Version, Platform, Preferred, + Recommendation, Capability (one boolean per detected feature), Http, Parallel. + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [switch]$Refresh + ) + + if ($script:TestEnvironmentRuntime -and -not $Refresh) { return $script:TestEnvironmentRuntime } + + # $PSVersionTable is a hashtable: a key it lacks reads as null, and Windows PowerShell 5.0 + # lacks PSEdition altogether. + $edition = if ($PSVersionTable['PSEdition']) { [string]$PSVersionTable['PSEdition'] } else { 'Desktop' } + $version = [version]$PSVersionTable['PSVersion'] + $platform = if ($edition -eq 'Desktop') { 'Windows' } + elseif ($PSVersionTable['Platform'] -eq 'Win32NT') { 'Windows' } + elseif ([string]$PSVersionTable['OS'] -match 'Darwin') { 'macOS' } + elseif ($PSVersionTable['Platform'] -eq 'Unix') { 'Linux' } + else { 'Unknown' } + + $webRequest = @((Get-Command -Name Invoke-WebRequest -CommandType Cmdlet -ErrorAction Stop).Parameters.Keys) + $fromJson = @((Get-Command -Name ConvertFrom-Json -CommandType Cmdlet -ErrorAction Stop).Parameters.Keys) + + $capability = [PSCustomObject]@{ + SkipHttpErrorCheck = ($webRequest -contains 'SkipHttpErrorCheck') + HttpTimeouts = ($webRequest -contains 'ConnectionTimeoutSeconds') + JsonAsHashtable = ($fromJson -contains 'AsHashtable') + ModernTls = ($edition -eq 'Core') + NativeUtf8 = ($edition -eq 'Core') + } + + $http = [PSCustomObject]@{ + ErrorBody = if ($capability.SkipHttpErrorCheck) { 'SkipHttpErrorCheck' } else { 'ResponseStream' } + Tls = if ($capability.ModernTls) { 'Default' } else { 'Tls12Added' } + Encoding = 'Utf8Bytes' + Progress = 'Suppressed' + } + + $preferred = ($edition -eq 'Core' -and $version -ge [version]'7.4') + $recommendation = if ($preferred) { + 'PowerShell 7.4 or later: the preferred PowerShell for every provider.' + } + elseif ($edition -eq 'Core') { + 'PowerShell 7.4 or later is preferred; this build has the same HTTP paths and is supported.' + } + else { + 'Windows PowerShell 5.1 is supported and is what a freshly built domain controller has; the Entra, Okta, Authentik, FreeIPA and PingOne providers are better served by PowerShell 7.4 or later.' + } + + $script:TestEnvironmentRuntime = [PSCustomObject]@{ + PSTypeName = 'TestEnvironmentRuntime' + Edition = $edition + Version = $version + Platform = $platform + Preferred = $preferred + Recommendation = $recommendation + Capability = $capability + Http = $http + Parallel = 'RunspacePool' + } + Write-Verbose ("Running on PowerShell $version ($edition, $platform): failed HTTP bodies read by " + + "$($http.ErrorBody), TLS $($http.Tls)") + return $script:TestEnvironmentRuntime +} diff --git a/Core/Invoke-TestWebRequest.ps1 b/Core/Invoke-TestWebRequest.ps1 index 295459d..6422028 100644 --- a/Core/Invoke-TestWebRequest.ps1 +++ b/Core/Invoke-TestWebRequest.ps1 @@ -1,41 +1,45 @@ -function Invoke-TestWebRequest { +function Invoke-TestWebRequest { <# .SYNOPSIS - The one HTTP call every REST provider makes, with the encoding Windows PowerShell gets wrong + The one HTTP call every REST provider makes, done the way this PowerShell does it best .DESCRIPTION - Windows PowerShell 5.1 corrupts non-ASCII text in both directions, silently, and - PowerShell 7 hides both faults, so a provider tested only on 7 looks correct. This is the - one place the four things every HTTP provider needs are done, so a new provider calls it - rather than copying them: - - - The body goes out as UTF-8 bytes. A string body is sent by 5.1 as ISO-8859-1 when the - content type names no charset, whatever the machine's code page: against PingOne a plain - accented e went out as the lone byte E9 and was stored as U+FFFD, and a Han character - as '?'. A string is encoded, an object is serialised to JSON and encoded, and a byte - array is sent as it stands. - - The response is decoded from its raw bytes as UTF-8, never from .Content. 5.1 decodes - by the declared charset and falls back to Latin-1; Okta declares none, and a service - that declares UTF-8 today does so with a header this module does not control. - - TLS 1.2 is added on the Desktop edition, which can still default to TLS 1.0. Only ever - added to the enabled set: clearing it would change behaviour for everything else in the - session. - - The progress bar is suppressed, which on 5.1 costs more than the calls do, and restored - whether the call succeeded or threw. - - What this does not do is interpret the response or the failure. The status code, headers - and decoded text are returned for the caller to page through, and an error is left to - propagate untouched, so a provider's error-detail function still sees the response and - its retry logic still reads Retry-After from it. + Every request the module sends to Entra, Okta, Authentik or PingOne, and every token + request, goes through here. Windows PowerShell 5.1 corrupts non-ASCII text in both + directions, silently, and hides the body of a failed response on a stream; PowerShell 7 + gets the text right and can hand a failed response back like any other. Rather than test + the edition in every request function, this reads Get-TestRuntime once and does each + thing the way the running PowerShell supports: + + - The body goes out as UTF-8 bytes with the charset named, on both editions. A string + body is sent by 5.1 as ISO-8859-1 when no charset is named, whatever the machine's code + page: observed against PingOne, an accented e went out as the lone byte E9 and was + stored as U+FFFD, and a Han character as '?'. A string is encoded, an object is + serialised to JSON and encoded, and a byte array is sent as it stands. + - The response is decoded from its raw bytes as UTF-8, never from .Content, on both. 5.1 + decodes by the declared charset and falls back to Latin-1, and Okta declares none. + - A failed response is read the way this PowerShell allows. With -SkipHttpErrorCheck + (PowerShell 7) a 4xx or 5xx comes back as a response and its body is decoded like any + other; without it (Windows PowerShell) the cmdlet throws and the body is read from the + exception's response stream, once. Either way the caller receives the one error shape + New-TestWebRequestError describes: the status as an integer, the headers as a + case-insensitive hashtable, the body in ErrorDetails. A transport failure with no + response propagates untouched. + - TLS 1.2 is added on Windows PowerShell, which can still default to TLS 1.0, only ever + adding to the enabled set. PowerShell 7 negotiates on its own. + - The progress bar is suppressed around the call, which on 5.1 costs more than the call, + and the preference is restored whether the call threw or not. + + Get-TestRuntime detects each of those on the cmdlet itself, not from a version number. .PARAMETER Uri - The full request URI. + The absolute URI. .PARAMETER Method - The HTTP method. + GET, POST, PUT, PATCH or DELETE. .PARAMETER Headers - Request headers. Authorization and Accept belong to the caller. + Request headers, authorization included. Content-Type is set from -ContentType. .PARAMETER Body A string, a byte array, or an object to serialise as JSON. Nothing is sent when omitted. @@ -44,19 +48,27 @@ function Invoke-TestWebRequest { The content type sent with a body. Defaults to JSON with the charset named. .PARAMETER JsonDepth - How deep an object body is serialised. - - .OUTPUTS - PSCustomObject with StatusCode, Headers and Content, the response text decoded as UTF-8, - or an empty Content for a response with no body. + How deep an object body is serialised. ConvertTo-Json's default of two silently + flattens anything nested further. .EXAMPLE - PS> $response = Invoke-TestWebRequest -Uri $uri -Method GET -Headers @{ Authorization = "Bearer $token" } + PS> $response = Invoke-TestWebRequest -Uri 'https://api.example.com/users' -Method POST -Headers $auth -Body @{ name = 'José' } PS> $page = $response.Content | ConvertFrom-Json - DESCRIPTION: One read, decoded correctly on both editions - OUTPUT: The response text and headers - USE CASE: Every Invoke-*Request in the module, and every token endpoint + DESCRIPTION: Sends one JSON body and reads the answer + OUTPUT: StatusCode, Headers and the decoded Content + USE CASE: Called by every provider's Invoke-Request + + .EXAMPLE + PS> try { Invoke-TestWebRequest -Uri $uri -Method GET -Headers $auth } catch { $_.Exception.Response.StatusCode; $_.ErrorDetails.Message } + + DESCRIPTION: Reads a failure the same way on either edition + OUTPUT: The status and the body the server sent + USE CASE: Every provider's retry and error handling + + .OUTPUTS + PSCustomObject with StatusCode (integer), Headers (case-insensitive hashtable, each + value a string) and Content (the body as a string, empty when there was none). .NOTES Author: Jeffrey Stuhr @@ -90,7 +102,9 @@ function Invoke-TestWebRequest { [int]$JsonDepth = 20 ) - if ($PSVersionTable.PSEdition -eq 'Desktop') { + $runtime = Get-TestRuntime + + if (-not $runtime.Capability.ModernTls) { $tls12 = [System.Net.SecurityProtocolType]::Tls12 if (([System.Net.ServicePointManager]::SecurityProtocol -band $tls12) -ne $tls12) { [System.Net.ServicePointManager]::SecurityProtocol = @@ -104,6 +118,7 @@ function Invoke-TestWebRequest { UseBasicParsing = $true ErrorAction = 'Stop' } + if ($runtime.Capability.SkipHttpErrorCheck) { $arguments['SkipHttpErrorCheck'] = $true } if ($Headers -and $Headers.Count -gt 0) { $arguments['Headers'] = $Headers } if ($null -ne $Body) { @@ -124,30 +139,106 @@ function Invoke-TestWebRequest { $arguments['ContentType'] = $ContentType } + # Every header value as one string under a case-insensitive key - a PowerShell hashtable + # literal keys that way - whichever collection the response carried: a hashtable, a + # WebHeaderCollection, PowerShell 7's dictionary of string arrays, or an HttpResponseMessage's + # enumerable of pairs. Multiple values are joined the way the wire joins them, so a + # provider's Link parsing reads one string on both editions. + $toHeaderTable = { + param($source) + $table = @{} + if ($null -eq $source) { return $table } + if ($source -is [System.Collections.IDictionary]) { + foreach ($key in @($source.Keys)) { $table[[string]$key] = (@($source[$key]) -join ', ') } + } + elseif ($source -is [System.Collections.Specialized.NameValueCollection]) { + foreach ($key in @($source.AllKeys)) { if ($null -ne $key) { $table[[string]$key] = [string]$source[$key] } } + } + elseif ($source -is [System.Collections.IEnumerable]) { + foreach ($pair in $source) { + if ($null -ne $pair -and $pair.PSObject.Properties['Key']) { $table[[string]$pair.Key] = (@($pair.Value) -join ', ') } + } + } + return $table + } + $decode = { + param($response) + if ($response.RawContentStream -and $response.RawContentStream.Length -gt 0) { + return [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + } + if ($response.Content -is [byte[]]) { return [System.Text.Encoding]::UTF8.GetString($response.Content) } + if ($null -ne $response.Content) { return [string]$response.Content } + return '' + } + $previousProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' + $failure = $null try { Write-Verbose "$Method $Uri" $response = Invoke-WebRequest @arguments } + catch { + # Windows PowerShell's way: the cmdlet threw, and the status, headers and body are on the + # exception's response. The stream can be read once, so this is the only place that reads + # it. A record that already carries the body in ErrorDetails - which is what a test's + # fake, or PowerShell 7 without the switch, hands over - is read from there instead. An + # exception with no response at all is a transport failure and is not this function's + # to describe. + $thrown = $_ + $errorResponse = $null + if ($thrown.Exception.PSObject.Properties['Response'] -and $thrown.Exception.Response) { $errorResponse = $thrown.Exception.Response } + if ($null -eq $errorResponse) { throw } + + $status = 0 + try { $status = [int]$errorResponse.StatusCode } catch { $status = 0 } + if ($status -le 0) { throw } + + $body = $null + if ($thrown.ErrorDetails -and -not [string]::IsNullOrEmpty($thrown.ErrorDetails.Message)) { + $body = $thrown.ErrorDetails.Message + } + elseif ($errorResponse.PSObject.Methods['GetResponseStream']) { + try { + $stream = $errorResponse.GetResponseStream() + if ($stream) { + $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) + try { $body = $reader.ReadToEnd() } finally { $reader.Dispose() } + } + } + catch { Write-Verbose "Could not read the failed response's body: $($_.Exception.Message)" } + } + $description = if ($errorResponse.PSObject.Properties['StatusDescription']) { [string]$errorResponse.StatusDescription } + elseif ($errorResponse.PSObject.Properties['ReasonPhrase']) { [string]$errorResponse.ReasonPhrase } else { '' } + # Assigned inside the if, not from it as an expression: a statement's output is + # enumerated on the way out, and a WebHeaderCollection assigned that way arrives as an + # array of its key names, with every value gone. + $headerSource = $null + if ($errorResponse.PSObject.Properties['Headers']) { $headerSource = $errorResponse.Headers } + $failure = New-TestWebRequestError -Method $Method -Uri $Uri -StatusCode $status -StatusDescription $description ` + -Headers (& $toHeaderTable $headerSource) -Body $body -InnerException $thrown.Exception + } finally { $ProgressPreference = $previousProgress } - - $content = '' - if ($response.RawContentStream -and $response.RawContentStream.Length -gt 0) { - $content = [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) - } - elseif ($response.Content -is [byte[]]) { - $content = [System.Text.Encoding]::UTF8.GetString($response.Content) - } - elseif ($null -ne $response.Content) { - $content = [string]$response.Content + if ($failure) { $PSCmdlet.ThrowTerminatingError($failure) } + + $content = & $decode $response + $headerTable = & $toHeaderTable $response.Headers + $statusCode = [int]$response.StatusCode + + # PowerShell 7's way: with -SkipHttpErrorCheck a failed response arrives here like any other, + # body already decoded from its raw bytes, and becomes the same record the catch above builds. + if ($statusCode -ge 400) { + $description = if ($response.PSObject.Properties['StatusDescription']) { [string]$response.StatusDescription } else { '' } + $failure = New-TestWebRequestError -Method $Method -Uri $Uri -StatusCode $statusCode -StatusDescription $description ` + -Headers $headerTable -Body $content + $PSCmdlet.ThrowTerminatingError($failure) } [PSCustomObject]@{ - StatusCode = $response.StatusCode - Headers = $response.Headers + StatusCode = $statusCode + Headers = $headerTable Content = $content } } diff --git a/Core/New-TestWebRequestError.ps1 b/Core/New-TestWebRequestError.ps1 new file mode 100644 index 0000000..524c148 --- /dev/null +++ b/Core/New-TestWebRequestError.ps1 @@ -0,0 +1,120 @@ +function New-TestWebRequestError { + <# + .SYNOPSIS + Builds the one error shape a failed HTTP response has, on either edition + + .DESCRIPTION + Windows PowerShell and PowerShell 7 report a failed HTTP response differently: one throws + a WebException whose Response holds the body on a stream that can be read once, the other + keeps the body in the error record's ErrorDetails and the status on an HttpResponseMessage. + Every provider used to handle both. Invoke-TestWebRequest now reads the status, the headers + and the body once, whichever way it got them, and throws this record, so a provider reads + one shape: + + - $_.Exception.Message names the method, the URI and the status. + - $_.Exception.Response.StatusCode is an integer, and .Headers a hashtable - which + PowerShell keys case-insensitively - with each header's values joined by a comma and a space, so + $_.Exception.Response.Headers['Retry-After'] reads the same on both editions. + - $_.ErrorDetails.Message is the body, decoded as UTF-8, when there was one. + + The exception is a plain System.Exception carrying Response as an added property, not a + WebException, whose Response is read-only and typed to a class PowerShell 7 never + produces. A transport failure - a refused connection, a name that does not resolve - has + no response and is never wrapped in this: Invoke-TestWebRequest lets those through + untouched, and a provider that reads a missing Response as status 0 keeps doing so. + + .PARAMETER Method + The HTTP method of the request that failed. + + .PARAMETER Uri + The URI it was sent to. + + .PARAMETER StatusCode + The HTTP status the server answered with. + + .PARAMETER StatusDescription + The reason phrase, when one was given. + + .PARAMETER Headers + The response headers, already normalised to a hashtable. + + .PARAMETER Body + The decoded body, or nothing. + + .PARAMETER InnerException + The exception the cmdlet threw, when it threw one, kept as the inner exception. + + .EXAMPLE + PS> $PSCmdlet.ThrowTerminatingError((New-TestWebRequestError -Method GET -Uri $uri -StatusCode 429 -Headers $headers -Body $body)) + + DESCRIPTION: What Invoke-TestWebRequest does with a failed response + OUTPUT: Nothing; the caller's catch receives the record + USE CASE: Called only by Invoke-TestWebRequest + + .OUTPUTS + System.Management.Automation.ErrorRecord + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Builds an error record in memory; nothing outside the process changes.')] + [CmdletBinding()] + [OutputType([System.Management.Automation.ErrorRecord])] + param( + [Parameter(Mandatory = $true)] + [string]$Method, + + [Parameter(Mandatory = $true)] + [string]$Uri, + + [Parameter(Mandatory = $true)] + [int]$StatusCode, + + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string]$StatusDescription, + + [Parameter()] + [AllowNull()] + [hashtable]$Headers, + + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string]$Body, + + [Parameter()] + [AllowNull()] + [System.Exception]$InnerException + ) + + $reason = if ([string]::IsNullOrWhiteSpace($StatusDescription)) { '' } else { " $StatusDescription" } + $message = "$Method $Uri answered HTTP $StatusCode$reason" + + $exception = if ($InnerException) { New-Object System.Exception($message, $InnerException) } else { New-Object System.Exception($message) } + $response = [PSCustomObject]@{ + PSTypeName = 'TestWebResponse' + StatusCode = $StatusCode + StatusDescription = $StatusDescription + Headers = if ($Headers) { $Headers } else { @{} } + } + # An added property survives the exception being handed around as a .NET object: PowerShell + # keeps instance members it added in a table keyed by the object, so $_.Exception.Response + # is there in the caller's catch. + $exception | Add-Member -NotePropertyName 'Response' -NotePropertyValue $response + + $category = if ($StatusCode -eq 401 -or $StatusCode -eq 403) { [System.Management.Automation.ErrorCategory]::PermissionDenied } + elseif ($StatusCode -eq 404) { [System.Management.Automation.ErrorCategory]::ObjectNotFound } + elseif ($StatusCode -ge 500) { [System.Management.Automation.ErrorCategory]::ResourceUnavailable } + else { [System.Management.Automation.ErrorCategory]::InvalidOperation } + + $record = New-Object System.Management.Automation.ErrorRecord($exception, "TestWebRequest.HTTP$StatusCode", $category, $Uri) + if (-not [string]::IsNullOrEmpty($Body)) { + $record.ErrorDetails = New-Object System.Management.Automation.ErrorDetails($Body) + } + return $record +} diff --git a/Providers/Authentik/Private/Get-AuthentikErrorDetail.ps1 b/Providers/Authentik/Private/Get-AuthentikErrorDetail.ps1 index 8db3f5f..a53ea6b 100644 --- a/Providers/Authentik/Private/Get-AuthentikErrorDetail.ps1 +++ b/Providers/Authentik/Private/Get-AuthentikErrorDetail.ps1 @@ -10,8 +10,8 @@ function Get-AuthentikErrorDetail { 'non_field_errors', and the field name is the part that says what to fix: 'slug: This field must be unique' is actionable where 'Bad Request' is not. - The body is read from ErrorDetails first, which is where PowerShell 7 puts it, and from - the response stream on Windows PowerShell, where it can be read exactly once. Anything + The body is read from ErrorDetails, where Invoke-TestWebRequest puts it on both + editions after reading the failed response once. Anything that is not JSON is reduced to its first non-blank line so an HTML error page from a proxy in front of Authentik does not become a screenful. @@ -41,28 +41,13 @@ function Get-AuthentikErrorDetail { [System.Management.Automation.ErrorRecord]$ErrorRecord ) + # The body is in ErrorDetails on both editions: Invoke-TestWebRequest reads a failed response + # once, with -SkipHttpErrorCheck on PowerShell 7 and from the response stream on Windows + # PowerShell, and puts it there. Nothing here reads a stream. $raw = $null if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { $raw = $ErrorRecord.ErrorDetails.Message } - elseif ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { - $response = $ErrorRecord.Exception.Response - # Probed by method rather than by edition: HttpWebResponse has GetResponseStream, the - # HttpResponseMessage PowerShell 7 raises does not, and its body is already in - # ErrorDetails above. - if ($response.PSObject.Methods.Name -contains 'GetResponseStream') { - try { - $stream = $response.GetResponseStream() - if ($stream) { - $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) - try { $raw = $reader.ReadToEnd() } finally { $reader.Dispose() } - } - } - catch { - Write-Verbose "Could not read the error response body: $($_.Exception.Message)" - } - } - } if ([string]::IsNullOrWhiteSpace($raw)) { return $ErrorRecord.Exception.Message diff --git a/Providers/Entra/Private/Get-EntraErrorDetail.ps1 b/Providers/Entra/Private/Get-EntraErrorDetail.ps1 index 4dc95d9..4e9088e 100644 --- a/Providers/Entra/Private/Get-EntraErrorDetail.ps1 +++ b/Providers/Entra/Private/Get-EntraErrorDetail.ps1 @@ -7,10 +7,11 @@ Graph returns its diagnosis in the response body - error.code, error.message and a request id - and the token endpoint uses a different shape again, with error_description carrying the AADSTS code that actually identifies the problem. - PowerShell surfaces neither by default: Windows PowerShell throws away the body of a - failed response entirely, and PowerShell 7 keeps it only in ErrorDetails. + PowerShell surfaces neither by default; Invoke-TestWebRequest reads the body once, + whichever way the running PowerShell hands it over, and puts it in ErrorDetails on + both editions. - Both editions are handled, and the request id is included whenever Graph sends one. + The body is read from there, and the request id is included whenever Graph sends one. That id is the only thing Microsoft support can correlate against their side, so discarding it turns a supportable failure into an anecdote. @@ -40,29 +41,14 @@ [System.Management.Automation.ErrorRecord]$ErrorRecord ) + # The body is in ErrorDetails on both editions: Invoke-TestWebRequest reads a failed response + # once, with -SkipHttpErrorCheck on PowerShell 7 and from the response stream on Windows + # PowerShell, and puts it there. Nothing here reads a stream. $raw = $null - - # PowerShell 7 path: the body is kept here and the stream has already been consumed. if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { $raw = $ErrorRecord.ErrorDetails.Message } - # Windows PowerShell path: read the response stream directly, because the body is not - # attached to the error record at all. - if (-not $raw -and $ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { - try { - $stream = $ErrorRecord.Exception.Response.GetResponseStream() - if ($stream) { - $stream.Position = 0 - $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8) - try { $raw = $reader.ReadToEnd() } finally { $reader.Dispose() } - } - } - catch { - Write-Verbose "Could not read the error response stream: $($_.Exception.Message)" - } - } - if ([string]::IsNullOrWhiteSpace($raw)) { return $ErrorRecord.Exception.Message } try { diff --git a/Providers/Okta/Private/Get-OktaErrorDetail.ps1 b/Providers/Okta/Private/Get-OktaErrorDetail.ps1 index a2482af..62d5c74 100644 --- a/Providers/Okta/Private/Get-OktaErrorDetail.ps1 +++ b/Providers/Okta/Private/Get-OktaErrorDetail.ps1 @@ -6,12 +6,12 @@ .DESCRIPTION Okta puts the actionable text in the response body, under errorSummary and errorCauses, and PowerShell throws that body away in favour of a generic - "The remote server returned an error" message. Worse, the two editions surface the - body differently: PowerShell 7 populates $_.ErrorDetails.Message, Windows PowerShell - often leaves it empty and only exposes the response stream. + "The remote server returned an error" message. Invoke-TestWebRequest reads the body + once, whichever way the running PowerShell hands it over, and puts it in + $_.ErrorDetails.Message on both editions. - This reads whichever one is available and flattens errorCauses, which is the field - that actually says which attribute was rejected and why. + This reads it from there and flattens errorCauses, which is the field that actually + says which attribute was rejected and why. .PARAMETER ErrorRecord The ErrorRecord caught from Invoke-WebRequest @@ -35,30 +35,13 @@ [System.Management.Automation.ErrorRecord]$ErrorRecord ) + # The body is in ErrorDetails on both editions: Invoke-TestWebRequest reads a failed response + # once, with -SkipHttpErrorCheck on PowerShell 7 and from the response stream on Windows + # PowerShell, and puts it there. Nothing here reads a stream. $rawBody = $null - if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { $rawBody = $ErrorRecord.ErrorDetails.Message } - elseif ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { - # Windows PowerShell only. GetResponseStream does not exist on the PowerShell 7 - # HttpResponseMessage, hence the method probe rather than a version check. - $response = $ErrorRecord.Exception.Response - if ($response.PSObject.Methods['GetResponseStream']) { - $reader = $null - try { - $stream = $response.GetResponseStream() - $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) - $rawBody = $reader.ReadToEnd() - } - catch { - Write-Verbose "Could not read the error response stream: $($_.Exception.Message)" - } - finally { - if ($reader) { $reader.Dispose() } - } - } - } # Whatever is left when the body is absent or is not an Okta error document. A transport # failure is the common case: PowerShell 7 puts the inner exception's entire ToString, diff --git a/Providers/PingOne/Private/Get-PingOneErrorDetail.ps1 b/Providers/PingOne/Private/Get-PingOneErrorDetail.ps1 index 3b51b1b..9b21806 100644 --- a/Providers/PingOne/Private/Get-PingOneErrorDetail.ps1 +++ b/Providers/PingOne/Private/Get-PingOneErrorDetail.ps1 @@ -17,11 +17,9 @@ function Get-PingOneErrorDetail { The `id` is the correlation id, and it is the only thing Ping's support can act on, so it is kept even though nothing in this module reads it. - Reading the body is version-dependent, which is the reason this is its own function. - PowerShell 7 puts it in ErrorDetails.Message. Windows PowerShell 5.1 usually does too, - but leaves it empty for some failures and only exposes the body on the exception's - response stream - which can be read exactly once, so a caller that peeks at it before - calling here gets nothing back from here. + The body is in ErrorDetails.Message on both editions: Invoke-TestWebRequest reads a + failed response once, whichever way the running PowerShell hands it over, and puts it + there. This function turns PingOne's shape into one line. .PARAMETER ErrorRecord The error record from the failed call. @@ -55,22 +53,11 @@ function Get-PingOneErrorDetail { try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { $status = 0 } } - $raw = $ErrorRecord.ErrorDetails.Message - - # Windows PowerShell 5.1 leaves the body on the response stream for some failures. The - # stream can only be read once, so this is the only place that reads it. - if ([string]::IsNullOrWhiteSpace($raw) -and $ErrorRecord.Exception.Response) { - try { - $stream = $ErrorRecord.Exception.Response.GetResponseStream() - if ($stream) { - $reader = New-Object System.IO.StreamReader($stream) - try { $raw = $reader.ReadToEnd() } finally { $reader.Dispose() } - } - } - catch { - Write-Verbose "Could not read the error body from the response stream: $($_.Exception.Message)" - } - } + # The body is in ErrorDetails on both editions: Invoke-TestWebRequest reads a failed response + # once, with -SkipHttpErrorCheck on PowerShell 7 and from the response stream on Windows + # PowerShell, and puts it there. Nothing here reads a stream. + $raw = $null + if ($ErrorRecord.ErrorDetails) { $raw = $ErrorRecord.ErrorDetails.Message } $code = $null $message = $null diff --git a/Public/Get-TestEnvironmentRuntime.ps1 b/Public/Get-TestEnvironmentRuntime.ps1 new file mode 100644 index 0000000..77da2e4 --- /dev/null +++ b/Public/Get-TestEnvironmentRuntime.ps1 @@ -0,0 +1,12 @@ +function Get-TestEnvironmentRuntime { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Reports which PowerShell the module is running on and which methods it is using because of it + #> + [CmdletBinding()] + [OutputType('TestEnvironmentRuntime')] + param() + + Get-TestRuntime +} diff --git a/README.md b/README.md index 71d274d..6ddb644 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ Install-Module -Name TestEnvironment -Scope CurrentUser Requires Windows PowerShell 5.1 or PowerShell 7, and nothing else: `RequiredModules` is empty and a contract test keeps it that way. The AD provider needs RSAT's `ActiveDirectory` and `GroupPolicy` modules, which it imports at connect time and names clearly when they are absent. +Windows PowerShell 5.1 is kept because a freshly built domain controller has nothing else; the +REST providers are better served by PowerShell 7.4, and the module detects which it is running +on once, at import, and uses what that PowerShell can do. `Get-TestEnvironmentRuntime` shows the +decision. The Entra, Okta, Authentik, FreeIPA and PingOne providers need nothing beyond a stock host, on any platform. From a clone: @@ -229,7 +233,7 @@ replaced still work. ## 📊 Module information - **Author**: Jeffrey Stuhr -- **PowerShell**: 5.1+ (Desktop/Core compatible) +- **PowerShell**: 5.1+ (Desktop/Core compatible); PowerShell 7.4 gets the faster HTTP paths, detected at import - **Dependencies**: none - **Providers**: Entra, Active Directory, Okta, Authentik, FreeIPA, PingOne - **Module GUID**: c4e91b7d-5a63-4f28-9d10-8b2e6f3a71c5 diff --git a/TestEnvironment.psd1 b/TestEnvironment.psd1 index 63c782a..cedaccd 100644 --- a/TestEnvironment.psd1 +++ b/TestEnvironment.psd1 @@ -34,6 +34,7 @@ 'Test-TestEnvironment', 'Compare-TestEnvironment', 'Repair-TestEnvironment', + 'Get-TestEnvironmentRuntime', 'Get-TestAccessToken', 'New-TestServiceApp', 'Get-TestServiceApp', diff --git a/TestEnvironment.psm1 b/TestEnvironment.psm1 index 3bc39b8..37f29d5 100644 --- a/TestEnvironment.psm1 +++ b/TestEnvironment.psm1 @@ -36,6 +36,11 @@ foreach ($function in (Get-ChildItem -Path "$ModuleRoot\Core\*.ps1" -ErrorAction . $function.FullName } +# Which PowerShell this is and what the module will do about it, decided once. Every HTTP call +# reads this rather than testing $PSVersionTable for itself; Get-TestEnvironmentRuntime shows it. +$script:TestEnvironmentRuntime = $null +$null = Get-TestRuntime + # --- Providers -------------------------------------------------------------------------- # Every provider is dot-sourced at import. That costs nothing: a provider's functions only # reference their platform's cmdlets when actually invoked, so loading the AD provider on a @@ -92,6 +97,7 @@ Export-ModuleMember -Function @( 'Test-TestEnvironment', 'Compare-TestEnvironment', 'Repair-TestEnvironment', + 'Get-TestEnvironmentRuntime', 'Get-TestAccessToken', 'New-TestServiceApp', 'Get-TestServiceApp', diff --git a/Tests/README.md b/Tests/README.md index 374235f..3653f2e 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -52,7 +52,8 @@ promise the README makes, or a regression for a bug that reached a real director | `Core\SeedPeople.Tests.ps1` | The one file the shared people's names live in: unique ASCII keys, the writing-system cohort present, the decomposed name, ideographic space and astral surname kept by codepoint, every generator reading the file, and every provider's users file carrying each shared person under exactly the shared names | | `Core\Export-TestCredentialRecord.Tests.ps1` | The one credential record: the secret is never on disk readable where the platform can protect it, a vault pointer is written only after the vault is proven usable, the record is UTF-8 with no byte order mark, the folder and file are restricted to the current user, a record that lies about its protection or names no vault is refused, and every provider path helper builds on the one credential root | | `Core\Protect-TestSecret.Tests.ps1` | That the plaintext is not recoverable from a protected value by inspection, the round trip is exact including non-ASCII, DPAPI is claimed only on Windows, and a blob another user or machine wrote is explained rather than failing opaquely | -| `Core\Invoke-TestWebRequest.Tests.ps1` | The one HTTP call: a string, object or byte-array body reaches `Invoke-WebRequest` as UTF-8 bytes with the charset named, the response is decoded from its raw stream rather than `.Content`, the progress preference is restored even when the call throws, an error propagates untouched, and TLS 1.2 is added on the Desktop edition without removing anything | +| `Core\Invoke-TestWebRequest.Tests.ps1` | The one HTTP call: a string, object or byte-array body reaches `Invoke-WebRequest` as UTF-8 bytes with the charset named, the response is decoded from its raw stream rather than `.Content`, the progress preference is restored even when the call throws, a failed response reaches the caller as one shape on either edition - an integer status, a header table and the body in `ErrorDetails` - whichever way the running PowerShell handed it over, a transport failure propagates untouched, `-SkipHttpErrorCheck` is asked for exactly where the cmdlet has it and HTTP/2 never, and TLS 1.2 is added on the Desktop edition without removing anything | +| `Core\Get-TestRuntime.Tests.ps1` | That every capability is detected on the cmdlet that has it, so the flags agree with `Get-Command` on whichever PowerShell runs the suite, that the HTTP decisions follow the flags, that the result is computed once and cached, and that `Get-TestEnvironmentRuntime` returns it | | `Core\New-TestEnvironmentCheck.Tests.ps1` | The one shape every verification result takes: identifiers compared as sets with the missing and unexpected named on both sides, a decomposed and a precomposed name told apart where `-eq` would not, memberships judged on what is missing only, an observational count kept out of the verdict, and the console line per check | | `Core\Invoke-TestParallel.Tests.ps1` | Real workers, no network: one result per item in input order whatever order the workers finish, the block running inside the module where a private function is in reach, one item's exception failing that item alone, and none of the session's state - the active connection - present in a worker | | `Providers\FreeIPA\Invoke-FreeIPABatch.Tests.ps1` | The realm's wire shape - one `batch` method carrying the commands with their arguments, options and API version - chunking, one answer per command in order, a refused command failing alone, an expected error name ignored, a request the realm could not take failing every command in it with the same message, and an unanswered command reported rather than dropped | diff --git a/Tests/Unit/Core/Get-TestRuntime.Tests.ps1 b/Tests/Unit/Core/Get-TestRuntime.Tests.ps1 new file mode 100644 index 0000000..12a0e05 --- /dev/null +++ b/Tests/Unit/Core/Get-TestRuntime.Tests.ps1 @@ -0,0 +1,78 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The one place the edition is read. What has to hold: every capability is detected on the + cmdlet that has it, so the flags agree with Get-Command on whichever PowerShell runs the + suite; the HTTP decisions follow the flags; the result is computed once and cached; and the + exported command returns the same object. +#> + +BeforeAll { + $moduleRoot = (Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent) + . (Join-Path $moduleRoot 'Tests\Stubs\Add-ADTestStubPath.ps1') + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $moduleRoot 'TestEnvironment.psd1') } +} + +Describe 'Get-TestRuntime' -Tag 'Unit', 'Private' { + + It 'reports the edition and version the session runs, and a platform' { + InModuleScope TestEnvironment { + $runtime = Get-TestRuntime + $runtime.PSObject.TypeNames[0] | Should-Be 'TestEnvironmentRuntime' + $runtime.Edition | Should-Be $PSVersionTable.PSEdition + $runtime.Version | Should-Be ([version]$PSVersionTable.PSVersion) + (@('Windows', 'Linux', 'macOS') -contains $runtime.Platform) | Should-BeTrue + $runtime.Parallel | Should-Be 'RunspacePool' + } + } + + It 'says which PowerShell is preferred, in the object, and that 5.1 is supported for the domain controller' { + InModuleScope TestEnvironment { + $runtime = Get-TestRuntime + $expected = ($PSVersionTable.PSEdition -eq 'Core' -and [version]$PSVersionTable.PSVersion -ge [version]'7.4') + $runtime.Preferred | Should-Be $expected + $runtime.Recommendation | Should-MatchString 'PowerShell 7.4' + if (-not $expected) { $runtime.Recommendation | Should-MatchString 'supported' } + } + } + + It 'detects each HTTP capability on Invoke-WebRequest itself, never from the version' { + InModuleScope TestEnvironment { + $real = @((Get-Command -Name Invoke-WebRequest -CommandType Cmdlet).Parameters.Keys) + $runtime = Get-TestRuntime -Refresh + $runtime.Capability.SkipHttpErrorCheck | Should-Be ($real -contains 'SkipHttpErrorCheck') + $runtime.Capability.HttpTimeouts | Should-Be ($real -contains 'ConnectionTimeoutSeconds') + $runtime.Capability.JsonAsHashtable | Should-Be (@((Get-Command -Name ConvertFrom-Json -CommandType Cmdlet).Parameters.Keys) -contains 'AsHashtable') + $runtime.Capability.ModernTls | Should-Be ($PSVersionTable.PSEdition -eq 'Core') + } + } + + It 'decides the HTTP methods from the capabilities' { + InModuleScope TestEnvironment { + $runtime = Get-TestRuntime + $runtime.Http.ErrorBody | Should-Be $(if ($runtime.Capability.SkipHttpErrorCheck) { 'SkipHttpErrorCheck' } else { 'ResponseStream' }) + # Tried and measured at no gain, so not a decision the object makes. + $runtime.Http.PSObject.Properties['Version'] | Should-BeNull + $runtime.Capability.PSObject.Properties['HttpVersion'] | Should-BeNull + $runtime.Http.Tls | Should-Be $(if ($runtime.Capability.ModernTls) { 'Default' } else { 'Tls12Added' }) + # The same on both editions, and listed so the object says everything the layer does. + $runtime.Http.Encoding | Should-Be 'Utf8Bytes' + $runtime.Http.Progress | Should-Be 'Suppressed' + } + } + + It 'is computed once and cached in module scope' { + InModuleScope TestEnvironment { + $first = Get-TestRuntime + [object]::ReferenceEquals($first, (Get-TestRuntime)) | Should-BeTrue + [object]::ReferenceEquals($first, $script:TestEnvironmentRuntime) | Should-BeTrue + [object]::ReferenceEquals($first, (Get-TestRuntime -Refresh)) | Should-BeFalse + } + } + + It 'is what the exported command returns' { + $exported = Get-TestEnvironmentRuntime + $exported.PSObject.TypeNames[0] | Should-Be 'TestEnvironmentRuntime' + (@('SkipHttpErrorCheck', 'ResponseStream') -contains $exported.Http.ErrorBody) | Should-BeTrue + } +} diff --git a/Tests/Unit/Core/Invoke-TestWebRequest.Tests.ps1 b/Tests/Unit/Core/Invoke-TestWebRequest.Tests.ps1 index de8040f..6b97e23 100644 --- a/Tests/Unit/Core/Invoke-TestWebRequest.Tests.ps1 +++ b/Tests/Unit/Core/Invoke-TestWebRequest.Tests.ps1 @@ -5,8 +5,10 @@ way back, and PowerShell 7 hides both faults, so these pin the four things the helper exists for in terms that fail on either edition: the body reaches Invoke-WebRequest as UTF-8 bytes with the charset named, the response is decoded from its raw stream and not from .Content, - the progress preference comes back whether the call threw or not, and an error propagates - untouched so a provider can still read the response it carries. + the progress preference comes back whether the call threw or not, and a failed response + reaches the caller as one shape on either edition - an integer status, a case-insensitive + header table and the body in ErrorDetails - whichever way the running PowerShell handed it + over, while a transport failure with no response propagates untouched. #> BeforeAll { @@ -36,7 +38,7 @@ Describe 'Invoke-TestWebRequest' -Tag 'Unit', 'Private', 'Safety' { } $script:Sent = $null Mock Invoke-WebRequest { - $script:Sent = @{ Body = $Body; ContentType = $ContentType; Headers = $Headers; Method = $Method; Uri = $Uri; Bound = @($PSBoundParameters.Keys) } + $script:Sent = @{ Body = $Body; ContentType = $ContentType; Headers = $Headers; Method = $Method; Uri = $Uri; Bound = @($PSBoundParameters.Keys); SkipHttpErrorCheck = [bool]$SkipHttpErrorCheck; HttpVersion = $HttpVersion } & $script:Respond } } @@ -114,18 +116,127 @@ Describe 'Invoke-TestWebRequest' -Tag 'Unit', 'Private', 'Safety' { } } - It 'lets an error propagate untouched, so the caller still has the response it carries' { + It 'lets a transport failure with no response propagate untouched' { InModuleScope TestEnvironment { Mock Invoke-WebRequest { - $exception = [System.Net.WebException]::new('The remote server returned an error: (429) Too Many Requests.') - throw $exception + throw [System.Net.WebException]::new('The remote name could not be resolved: api.example.com') } $caught = $null try { Invoke-TestWebRequest -Uri 'https://api.example.com/x' -Method GET } catch { $caught = $_ } $caught.Exception -is [System.Net.WebException] | Should-BeTrue - $caught.Exception.Message | Should-MatchString '429' + $caught.Exception.Message | Should-MatchString 'could not be resolved' + $caught.Exception.Response | Should-BeNull + } + } + + It 'turns the exception Windows PowerShell throws into the one error shape, reading the stream once' { + InModuleScope TestEnvironment { + # A hashtable, because the stream method below is a closure and a counter it + # captures by value would be its own copy. + $script:Reads = @{ Count = 0 } + Mock Invoke-WebRequest { + $body = [System.Text.Encoding]::UTF8.GetBytes('{"error":"José was refused"}') + $reads = $script:Reads + $response = [PSCustomObject]@{ + # A plain number: .NET Framework's HttpStatusCode has no 429 member and + # refuses the cast, where a real response carries the unnamed value and + # Invoke-TestWebRequest reads it as an integer either way. + StatusCode = 429 + StatusDescription = 'Too Many Requests' + Headers = @{ 'Retry-After' = '7'; 'X-Trace' = @('a', 'b') } + } + $response | Add-Member -MemberType ScriptMethod -Name GetResponseStream -Value { $reads.Count++; [System.IO.MemoryStream]::new($body) }.GetNewClosure() + # A plain exception carrying the response as an added property, thrown as a + # record: a real WebException's Response is read-only and cannot be overridden + # by an added member, and Windows PowerShell rewraps a bare thrown exception + # and loses the member on the way, where a thrown record keeps it. + $exception = New-Object System.Exception('The remote server returned an error: (429) Too Many Requests.') + $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response + throw (New-Object System.Management.Automation.ErrorRecord($exception, 'WebCmdletWebResponseException', 'InvalidOperation', $null)) + } + + $caught = $null + try { Invoke-TestWebRequest -Uri 'https://api.example.com/x' -Method POST -Body @{ a = 1 } } catch { $caught = $_ } + + $caught.Exception.Response.StatusCode | Should-Be 429 + $caught.Exception.Response.StatusCode -is [int] | Should-BeTrue + $caught.Exception.Response.Headers['retry-after'] | Should-Be '7' + $caught.Exception.Response.Headers['X-Trace'] | Should-Be 'a, b' + $caught.ErrorDetails.Message | Should-Be '{"error":"José was refused"}' + $caught.Exception.Message | Should-Be 'POST https://api.example.com/x answered HTTP 429 Too Many Requests' + $caught.Exception.InnerException.Message | Should-MatchString 'The remote server returned an error' + $caught.FullyQualifiedErrorId | Should-MatchString 'TestWebRequest.HTTP429' + $script:Reads.Count | Should-Be 1 + } + } + + It 'reads a record that already carries the body in ErrorDetails without touching a stream' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { + $response = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } + $exception = New-Object System.Exception('Forbidden') + $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response + $record = New-Object System.Management.Automation.ErrorRecord($exception, 'x', 'InvalidOperation', $null) + $record.ErrorDetails = New-Object System.Management.Automation.ErrorDetails('{"error":{"code":"Authorization_RequestDenied"}}') + throw $record + } + + $caught = $null + try { Invoke-TestWebRequest -Uri 'https://api.example.com/x' -Method GET } catch { $caught = $_ } + + $caught.Exception.Response.StatusCode | Should-Be 403 + $caught.ErrorDetails.Message | Should-MatchString 'Authorization_RequestDenied' + } + } + + It 'asks for the failed response back exactly where the cmdlet can, never HTTP/2, and reads a 4xx response as the same shape' { + InModuleScope TestEnvironment { + $real = @((Get-Command -Name Invoke-WebRequest -CommandType Cmdlet).Parameters.Keys) + $null = Invoke-TestWebRequest -Uri 'https://api.example.com/x' -Method GET + $script:Sent.SkipHttpErrorCheck | Should-Be ($real -contains 'SkipHttpErrorCheck') + # Measured at no gain on a core-tier seed, so not asked for even where it exists. + ([string]$script:Sent.HttpVersion) | Should-Be '' + + # PowerShell 7's way: the cmdlet returns the failed response and its body, decoded + # from the raw bytes, becomes ErrorDetails. Taken on both editions, because a + # response object with a failing status is the same object either way. + $script:Respond = { + $bytes = [System.Text.Encoding]::UTF8.GetBytes('{"detail":"Niño exists"}') + [PSCustomObject]@{ + StatusCode = 400 + StatusDescription = 'Bad Request' + Headers = @{ 'Content-Type' = @('application/json') } + Content = [System.Text.Encoding]::GetEncoding('ISO-8859-1').GetString($bytes) + RawContentStream = [System.IO.MemoryStream]::new($bytes) + } + } + $caught = $null + try { Invoke-TestWebRequest -Uri 'https://api.example.com/x' -Method GET } catch { $caught = $_ } + + $caught.Exception.Response.StatusCode | Should-Be 400 + $caught.Exception.Response.Headers['content-type'] | Should-Be 'application/json' + $caught.ErrorDetails.Message | Should-Be '{"detail":"Niño exists"}' + $caught.Exception.Message | Should-Be 'GET https://api.example.com/x answered HTTP 400 Bad Request' + } + } + + It 'flattens the response headers to one string per name, case-insensitively, on success too' { + InModuleScope TestEnvironment { + $script:Respond = { + [PSCustomObject]@{ + StatusCode = 200 + Headers = @{ 'Link' = @('; rel="self"', '; rel="next"'); 'x-rate-limit-reset' = '1700000000' } + Content = '{}' + RawContentStream = [System.IO.MemoryStream]::new([System.Text.Encoding]::UTF8.GetBytes('{}')) + } + } + $r = Invoke-TestWebRequest -Uri 'https://api.example.com/x' -Method GET + + $r.Headers['link'] | Should-Be '; rel="self", ; rel="next"' + $r.Headers['X-Rate-Limit-Reset'] | Should-Be '1700000000' + $r.Headers['absent'] | Should-BeNull } } diff --git a/Tests/Unit/Providers/Entra/Invoke-EntraRequest.Tests.ps1 b/Tests/Unit/Providers/Entra/Invoke-EntraRequest.Tests.ps1 index 98a9bd1..9d596fb 100644 --- a/Tests/Unit/Providers/Entra/Invoke-EntraRequest.Tests.ps1 +++ b/Tests/Unit/Providers/Entra/Invoke-EntraRequest.Tests.ps1 @@ -456,7 +456,10 @@ Describe 'Invoke-EntraRequest' -Tag 'Unit' { $caught | Should-NotBeNull $caught.InnerException | Should-NotBeNull - $caught.InnerException.Message | Should-Be 'The original failure' + # Invoke-TestWebRequest's own record sits between: it names the status, and + # keeps what the cmdlet threw as its inner exception. + $caught.InnerException.Message | Should-MatchString 'answered HTTP 400' + $caught.InnerException.InnerException.Message | Should-Be 'The original failure' } } diff --git a/docs/TestEnvironment/Get-TestEnvironmentRuntime.md b/docs/TestEnvironment/Get-TestEnvironmentRuntime.md new file mode 100644 index 0000000..9fa1258 --- /dev/null +++ b/docs/TestEnvironment/Get-TestEnvironmentRuntime.md @@ -0,0 +1,112 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/Get-TestEnvironmentRuntime.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 16 2026 +PlatyPS schema version: 2024-05-01 +title: Get-TestEnvironmentRuntime +--- + +# Get-TestEnvironmentRuntime + +## SYNOPSIS + +Reports which PowerShell the module is running on and which methods it is using because of it. + +## SYNTAX + +### __AllParameterSets + +``` +Get-TestEnvironmentRuntime [] +``` + +## DESCRIPTION + +The module runs on Windows PowerShell 5.1, because a freshly built domain controller has nothing +else, and on PowerShell 7.4, which the REST providers are better served by. It detects the +difference once at import and picks its methods from what the running PowerShell supports, not from +a version number: a parameter that exists on Invoke-WebRequest is one that works. This command shows +that decision, so a run that behaves differently on two hosts can be explained by the first line of +its output. + +Edition and Version are what $PSVersionTable says. Preferred says whether this is the PowerShell +the module is best run on, and Recommendation says so in a sentence: PowerShell 7.4 or later is +preferred for every provider; Windows PowerShell 5.1 is supported because a freshly built domain +controller has nothing else. Capability holds one boolean per detected +feature. Http says what the HTTP layer does with them: how the body of a failed response is read and +whether TLS 1.2 had to be added. The encoding and the progress handling are the same on both editions +and are listed so the object is complete. Parallel names the mechanism the seed steps run several +objects at a time on, which is the same runspace pool on both. HTTP/2 is not among the decisions: it +was tried on PowerShell 7.3 and measured no faster, so it is not requested. + +## EXAMPLES + +### Example 1: Shows the running PowerShell and the methods chosen for it + +```powershell +Get-TestEnvironmentRuntime +``` + +Output: Edition, Version, Platform, Preferred, Recommendation, Capability, Http and Parallel. + +Use case: The first thing to include when reporting a run that behaved differently on two hosts. + +### Example 2: Shows only the HTTP decisions + +```powershell +(Get-TestEnvironmentRuntime).Http +``` + +Output: ErrorBody SkipHttpErrorCheck and Tls Default on PowerShell 7.4; ResponseStream and +Tls12Added on Windows PowerShell. + +Use case: Confirming that a host is getting the PowerShell 7 paths. + +### Example 3: Branches a script on a detected capability rather than on a version + +```powershell +if (-not (Get-TestEnvironmentRuntime).Capability.SkipHttpErrorCheck) { + Write-Warning 'Windows PowerShell: the REST providers work, but 7.4 is faster.' +} +``` + +Output: A warning on Windows PowerShell, nothing on PowerShell 7. + +Use case: A lab script that runs on both. + +## PARAMETERS + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### TestEnvironmentRuntime + +Edition, Version and Platform; Preferred and Recommendation; Capability with SkipHttpErrorCheck, +HttpTimeouts, JsonAsHashtable, ModernTls and NativeUtf8; Http with ErrorBody, Tls, Encoding and +Progress; and Parallel. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [Connect-TestEnvironment]() +- [about_TestEnvironment]() diff --git a/docs/TestEnvironment/TestEnvironment.md b/docs/TestEnvironment/TestEnvironment.md index a05bfb7..dfb0868 100644 --- a/docs/TestEnvironment/TestEnvironment.md +++ b/docs/TestEnvironment/TestEnvironment.md @@ -46,6 +46,10 @@ Lists the identity providers this module can seed, and which one is active Reports what is currently seeded through the active provider +### [Get-TestEnvironmentRuntime](Get-TestEnvironmentRuntime.md) + +Reports which PowerShell the module is running on and which methods it is using because of it + ### [Get-TestServiceApp](Get-TestServiceApp.md) Reports the bootstrapped credential and whether it still works diff --git a/en-US/TestEnvironment-Help.xml b/en-US/TestEnvironment-Help.xml index b2351bb..8454f91 100644 --- a/en-US/TestEnvironment-Help.xml +++ b/en-US/TestEnvironment-Help.xml @@ -1184,6 +1184,130 @@ provider, so a script that checks it does not care which directory is connected. + + + Get-TestEnvironmentRuntime + + Reports which PowerShell the module is running on and which methods it is using because of it. + + Get + TestEnvironmentRuntime + + + The module runs on Windows PowerShell 5.1, because a freshly built domain controller has nothing +else, and on PowerShell 7.4, which the REST providers are better served by. It detects the +difference once at import and picks its methods from what the running PowerShell supports, not from +a version number: a parameter that exists on Invoke-WebRequest is one that works. This command shows +that decision, so a run that behaves differently on two hosts can be explained by the first line of +its output. + +Edition and Version are what $PSVersionTable says. Preferred says whether this is the PowerShell +the module is best run on, and Recommendation says so in a sentence: PowerShell 7.4 or later is +preferred for every provider; Windows PowerShell 5.1 is supported because a freshly built domain +controller has nothing else. Capability holds one boolean per detected +feature. Http says what the HTTP layer does with them: how the body of a failed response is read and +whether TLS 1.2 had to be added. The encoding and the progress handling are the same on both editions +and are listed so the object is complete. Parallel names the mechanism the seed steps run several +objects at a time on, which is the same runspace pool on both. HTTP/2 is not among the decisions: it +was tried on PowerShell 7.3 and measured no faster, so it is not requested. + + + + Get-TestEnvironmentRuntime + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + TestEnvironmentRuntime + + + Edition, Version and Platform; Preferred and Recommendation; Capability with SkipHttpErrorCheck, +HttpTimeouts, JsonAsHashtable, ModernTls and NativeUtf8; Http with ErrorBody, Tls, Encoding and +Progress; and Parallel. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Shows the running PowerShell and the methods chosen for it --------- + + ```powershell +Get-TestEnvironmentRuntime +``` + € + Output: Edition, Version, Platform, Preferred, Recommendation, Capability, Http and Parallel. + € + Use case: The first thing to include when reporting a run that behaved differently on two hosts. + + + + + + --------- Example 2: Shows only the HTTP decisions --------- + + ```powershell +(Get-TestEnvironmentRuntime).Http +``` + € + Output: ErrorBody SkipHttpErrorCheck and Tls Default on PowerShell 7.4; ResponseStream and +Tls12Added on Windows PowerShell. + € + Use case: Confirming that a host is getting the PowerShell 7 paths. + + + + + + --------- Example 3: Branches a script on a detected capability rather than on a version --------- + + ```powershell +if (-not (Get-TestEnvironmentRuntime).Capability.SkipHttpErrorCheck) { + Write-Warning 'Windows PowerShell: the REST providers work, but 7.4 is faster.' +} +``` + € + Output: A warning on Windows PowerShell, nothing on PowerShell 7. + € + Use case: A lab script that runs on both. + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/Get-TestEnvironmentRuntime.md + + + Connect-TestEnvironment + + + + about_TestEnvironment + + + + Get-TestServiceApp diff --git a/en-US/about_TestEnvironment.help.txt b/en-US/about_TestEnvironment.help.txt index 13a7f42..e1769d1 100644 --- a/en-US/about_TestEnvironment.help.txt +++ b/en-US/about_TestEnvironment.help.txt @@ -201,6 +201,14 @@ REQUIREMENTS GroupPolicy modules at connect time and says so clearly when they are absent. + Windows PowerShell 5.1 is kept because a freshly built domain controller has + nothing else. The REST providers are better served by PowerShell 7.4, and the + module detects which PowerShell it is running on once, at import, and uses what + that one can do: on 7 a failed response is read back whole rather than dug out + of an exception, and TLS needs no help. Get-TestEnvironmentRuntime + shows the decision and says which PowerShell is preferred, so a run that + behaves differently on two hosts can be explained by its first line. + SEE ALSO Connect-TestEnvironment New-TestEnvironment