diff --git a/.gitignore b/.gitignore index 424f373..313316e 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,6 @@ api-feedback-evidence/ # reads it: build-and-test.yml takes the result from -PassThru in memory and there # is no test-result publisher in the pipeline. testResults.xml + +# Local demo exports — real tenant data, never commit +demo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 959b745..be7d919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format follows [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/): a new feature or capability bumps MINOR, a bug fix or performance change that leaves the public surface alone bumps PATCH, and docs/tests/chore-only commits don't bump. While the module is pre-1.0 a breaking change also bumps MINOR and is called out under a **Breaking Changes** heading — 1.0.0 is reserved for the point the public surface is declared stable, after which breaking changes bump MAJOR. There is intentionally no `[Unreleased]` section — every entry is dated at ship time. +## [0.7.2] - 2026-09-11 + +### Bug Fixes + +- **`Get-Help` now shows the JSON path for every cmdlet that has one.** `Get-InforcerReportRun`, `Get-InforcerReportType`, `Get-InforcerUser`, `Invoke-InforcerAssessment`, `Invoke-InforcerReport` and `Save-InforcerReportOutput` all documented `-OutputType` but never demonstrated it, so `Get-Help -Examples` gave no hint that JSON output existed. `docs/CMDLET-REFERENCE.md` had covered all six for a while; only the in-shell help was missing them. A test now covers all 15 `-OutputType` cmdlets so the two can't drift apart again. +- **API validation errors no longer lose the reason that explains them.** When an entry in the API's `errors[]` array doesn't match any known shape, the module falls back to dumping it as JSON so you still see something actionable — but it dumped at `-Depth 5`. That fallback runs *precisely* when the entry's shape is unknown, so there was no basis for assuming the useful field sat above the cut. Past the limit PowerShell substitutes `@{...}`, which isn't JSON, emits a truncation warning into your stream, and drops the rest: a nested `{"reason":"tenant not in key scope"}` came out as `{"L8":{"L7":{"L6":{"L5":{"L4":{"L3":"@{L2=}"}}}}}}`. Now `-Depth 100`, matching every other `ConvertTo-Json` in the module. + ## [0.7.1] - 2026-09-11 ### Bug Fixes diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 4bb4b22..c96a83f 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -2150,6 +2150,15 @@ Describe 'Private helpers (via module scope)' { $out | Should -Match '"weird"' $out | Should -Match '"thing"' } + + It 'Does not truncate a deeply nested unrecognized entry' { + $json = '{"errors":[{"L8":{"L7":{"L6":{"L5":{"L4":{"L3":{"L2":{"L1":{"reason":"tenant not in key scope"}}}}}}}}}]}' + $parsed = $json | ConvertFrom-Json -Depth 100 + $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors + + $out | Should -Match 'tenant not in key scope' + $out | Should -Not -Match '@\{' + } } Context 'Disconnect-Inforcer cache clearing' { @@ -2556,23 +2565,19 @@ Describe 'Base64 fields decode to text or stay base64, never mojibake' { } It 'never returns a string containing U+FFFD' { - # 256 raw bytes is not valid UTF-8; a lenient decode would hand back replacement chars. $bytes = [byte[]](0..255) $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } ([Convert]::ToBase64String($bytes)) $out | Should -BeNullOrEmpty } It 'rejects a literal U+FFFD, which strict UTF-8 decoding accepts' { - # EF BF BD *is* valid UTF-8 — it encodes U+FFFD — so the strict decoder does not - # throw on it. Without an explicit check a digest carrying those bytes comes back - # as "text" and the caller renders mojibake, which is the bug this helper exists for. + # EF BF BD is valid UTF-8 for U+FFFD, so the strict decoder does not throw on it. $bytes = [byte[]](0x48,0x69,0xEF,0xBF,0xBD,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69) $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } ([Convert]::ToBase64String($bytes)) $out | Should -BeNullOrEmpty } It 'rejects DEL and the C1 control range, which are also valid UTF-8' { - # 0x7F and C2 80..C2 9F decode cleanly but never appear in a plist, script or JSON. $bytes = [byte[]](0x48,0x69,0xC2,0x85,0x48,0x69,0x7F,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69,0x48,0x69) $out = & (Get-Module InforcerCommunity) { param($v) ConvertFrom-InforcerBase64Text -Value $v } ([Convert]::ToBase64String($bytes)) $out | Should -BeNullOrEmpty @@ -2668,3 +2673,25 @@ Describe 'Base64 fields decode to text or stay base64, never mojibake' { } } } + +Describe 'Every -OutputType cmdlet documents the JsonObject path' { + # Audit item A3: six cmdlets documented the parameter but never showed it in an example, + # so Get-Help -Examples never surfaced the JSON route. + + It 'has a -OutputType JsonObject example in ' -ForEach @( + $publicDir = Join-Path (Split-Path -Parent $PSCommandPath) '../module/Public' + Get-ChildItem -Path $publicDir -Filter *.ps1 | ForEach-Object { + $src = Get-Content $_.FullName -Raw + if ($src -match '(?m)^\s*\[string\]\$OutputType|\$OutputType\s*=|\bOutputType\b\s*\)') { + if ($src -match "ValidateSet\('PowerShellObject'\s*,\s*'JsonObject'\)") { + @{ Name = $_.BaseName; Source = $src } + } + } + } + ) { + $blocks = [regex]::Matches($Source, '(?ms)^\s*\.EXAMPLE\s*$(.*?)(?=^\s*\.[A-Z]+\s*$|#>)') + $blocks.Count | Should -BeGreaterThan 0 -Because "$Name should document at least one example" + ($blocks | ForEach-Object { $_.Groups[1].Value }) -join "`n" | + Should -Match 'OutputType\s+JsonObject' -Because "$Name exposes -OutputType, so its help must show the JsonObject path" + } +} diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index c656de7..0c4799a 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'InforcerCommunity.psm1' - ModuleVersion = '0.7.1' + ModuleVersion = '0.7.2' GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' Author = 'Roy Klooster' Description = 'Community PowerShell module for the Inforcer API. Created by Roy Klooster. Not owned or officially maintained by Inforcer.' diff --git a/module/Private/ConvertFrom-InforcerBase64Text.ps1 b/module/Private/ConvertFrom-InforcerBase64Text.ps1 index 6709df7..a331c7c 100644 --- a/module/Private/ConvertFrom-InforcerBase64Text.ps1 +++ b/module/Private/ConvertFrom-InforcerBase64Text.ps1 @@ -27,13 +27,11 @@ function ConvertFrom-InforcerBase64Text { try { $bytes = [System.Convert]::FromBase64String($Value) } catch { return $null } if ($bytes.Length -eq 0) { return $null } - # Strict decode: invalid UTF-8 throws instead of silently yielding U+FFFD. try { $text = [System.Text.UTF8Encoding]::new($false, $true).GetString($bytes) } catch { return $null } $text = $text.TrimStart([char]0xFEFF) - # Strict decoding is necessary but not sufficient: EF BF BD is *valid* UTF-8 for U+FFFD, - # and C2 80..C2 9F are valid encodings of the C1 controls, so a digest can survive it. + # Strict decoding isn't enough: EF BF BD and C2 80..C2 9F are valid UTF-8, so a digest can survive it. if ($text.Contains([char]0xFFFD) -or $text -match '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]') { return $null } $text diff --git a/module/Private/ConvertTo-InforcerSettingRows.ps1 b/module/Private/ConvertTo-InforcerSettingRows.ps1 index b4e0828..6161977 100644 --- a/module/Private/ConvertTo-InforcerSettingRows.ps1 +++ b/module/Private/ConvertTo-InforcerSettingRows.ps1 @@ -328,8 +328,7 @@ function ConvertTo-FlatSettingRows { $joined = @($val | ForEach-Object { if ($_ -is [string] -or $_ -is [ValueType]) { $_.ToString() } }) -join ', ' if ([string]::IsNullOrWhiteSpace($joined) -and $val.Count -gt 0) { "$($val.Count) items" } else { $joined } } else { $val.ToString() } - # Decode scripts, rulesContent JSON and .mobileconfig payloads. hashedScriptContent - # matches this pattern too but is a digest — the helper rejects it. + # hashedScriptContent matches this pattern too, but the helper rejects digests. if ($prop.Name -match '(?i)scriptContent|rulesContent|^payload$' -and $strVal -is [string]) { $decoded = ConvertFrom-InforcerBase64Text -Value $strVal if ($decoded) { $strVal = "__SCRIPT_CODE__$decoded" } diff --git a/module/Private/Format-InforcerErrorDetail.ps1 b/module/Private/Format-InforcerErrorDetail.ps1 index c75b865..180588a 100644 --- a/module/Private/Format-InforcerErrorDetail.ps1 +++ b/module/Private/Format-InforcerErrorDetail.ps1 @@ -54,11 +54,9 @@ function Format-InforcerErrorDetail { if ($parts.Count -gt 0) { $rendered.Add(($parts -join ' ')) } else { - # Nothing we recognize — dump JSON so the user still sees something useful. - # Serialization can fail for objects with circular refs or COM types; in that case - # we drop the entry silently (the top-level message will still surface). + # Shape is unknown, so a shallow depth could cut off the only useful field. try { - $rendered.Add(($e | ConvertTo-Json -Compress -Depth 5)) + $rendered.Add(($e | ConvertTo-Json -Compress -Depth 100)) } catch { Write-Verbose "Format-InforcerErrorDetail: skipping unserializable entry ($($_.Exception.Message))" } diff --git a/module/Public/Get-InforcerReportRun.ps1 b/module/Public/Get-InforcerReportRun.ps1 index ddc2123..0c92fce 100644 --- a/module/Public/Get-InforcerReportRun.ps1 +++ b/module/Public/Get-InforcerReportRun.ps1 @@ -46,6 +46,10 @@ .EXAMPLE Get-InforcerReportRun -IncludeOutputs Returns every run with its outputs array embedded (1 extra API call per run). +.EXAMPLE + Get-InforcerReportRun -RunId 8842 -OutputType JsonObject + Returns the run as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String .LINK diff --git a/module/Public/Get-InforcerReportType.ps1 b/module/Public/Get-InforcerReportType.ps1 index d9cef72..23ed292 100644 --- a/module/Public/Get-InforcerReportType.ps1 +++ b/module/Public/Get-InforcerReportType.ps1 @@ -40,6 +40,10 @@ Discovers every security-tagged report type and pipes each into Invoke-InforcerReport. Works because Get-InforcerReportType emits a 'Key' alias which binds to Invoke-InforcerReport's -ReportType (ValueFromPipelineByPropertyName). +.EXAMPLE + Get-InforcerReportType -Key ActiveUserCount -OutputType JsonObject + Returns the report type as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String .LINK diff --git a/module/Public/Get-InforcerUser.ps1 b/module/Public/Get-InforcerUser.ps1 index 02b54bf..d4f28ba 100644 --- a/module/Public/Get-InforcerUser.ps1 +++ b/module/Public/Get-InforcerUser.ps1 @@ -48,6 +48,11 @@ function Get-InforcerUser { Lists all users in the piped tenant. + .EXAMPLE + Get-InforcerUser -TenantId 482 -OutputType JsonObject + + Returns the users as a JSON string (depth 100) instead of objects. + .LINK https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#get-inforceruser diff --git a/module/Public/Invoke-InforcerAssessment.ps1 b/module/Public/Invoke-InforcerAssessment.ps1 index 5c930d8..08be7e0 100644 --- a/module/Public/Invoke-InforcerAssessment.ps1 +++ b/module/Public/Invoke-InforcerAssessment.ps1 @@ -40,6 +40,10 @@ .EXAMPLE Invoke-InforcerAssessment -TenantId 144 -AssessmentId "Copilot Readiness" -OutputPath ./report.csv Exports assessment results to CSV. +.EXAMPLE + Invoke-InforcerAssessment -TenantId 144 -AssessmentId "Copilot Readiness" -OutputType JsonObject + Returns the assessment results as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject, String, or System.IO.FileInfo .LINK diff --git a/module/Public/Invoke-InforcerReport.ps1 b/module/Public/Invoke-InforcerReport.ps1 index 8f89eec..513331d 100644 --- a/module/Public/Invoke-InforcerReport.ps1 +++ b/module/Public/Invoke-InforcerReport.ps1 @@ -87,6 +87,10 @@ .EXAMPLE Invoke-InforcerReport -Key TenantAuditReport -OutputFormat html -TenantId 482 -Open Runs the report, saves it, and immediately opens the HTML in the default browser. +.EXAMPLE + Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -OutputType JsonObject + Returns the run result as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String. The shape depends on the mode: -NoWait → one object per run with { RunId, Status='queued', TenantId, ReportType, ... } diff --git a/module/Public/Save-InforcerReportOutput.ps1 b/module/Public/Save-InforcerReportOutput.ps1 index e05cf74..e311f1a 100644 --- a/module/Public/Save-InforcerReportOutput.ps1 +++ b/module/Public/Save-InforcerReportOutput.ps1 @@ -42,6 +42,10 @@ ForEach-Object { $_.outputs } | Save-InforcerReportOutput -OutputPath ./bulk Bulk-downloads every output from every visible run. +.EXAMPLE + Save-InforcerReportOutput -RunId 8842 -OutputId 1 -OutputPath ./out -OutputType JsonObject + Returns the saved-file result as a JSON string (depth 100) instead of objects. + .OUTPUTS PSObject or String — per saved file, with { RunId, OutputId, TenantId, ReportType, OutputFormat, FilePath, FileName, FileSize, ContentType, CorrelationId }. TenantId,