From 1f8354173cf3587e21ba2010d4971a3370ef6611 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:10:38 +0100 Subject: [PATCH] fix: count only the tenant's baselines in the documentation header The HTML header counted every baseline the credential can see, so a partner with 29 baselines showed "29 baselines" on every tenant, even with -Baseline filtering the report to one. It now counts the baselines the tenant is the source or a member of, shows no count when -Baseline is set (the "Baseline:" line already names it), and reads "1 product", "1 policy" and "1 baseline". Measured live: Inforcer Blueprint Library (beta) 29 -> 24, filtered to one baseline -> no count; Roytoso (v1) 10 -> 5. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- Tests/DocModel.Tests.ps1 | 53 +++++++++++++++++++ module/Private/ConvertTo-InforcerDocModel.ps1 | 9 +++- module/Private/ConvertTo-InforcerHtml.ps1 | 8 +-- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5814aac..8794d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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. -## [1.0.0] - 2026-11-08 +## [1.0.0] - 2026-11-09 inforcer now has a versioned API (v1) with API clients that log in with a client ID and secret. API keys keep working, but they only reach inforcer's legacy beta API, which inforcer says is due to be retired (no date announced). 1.0.0 supports both. The login decides which API is used, and every cmdlet works with either. @@ -36,6 +36,7 @@ API-key output is identical to 0.8.0 (checked live, side by side), except for th - Error messages: HTTP 400 errors show the API's title, detail and field errors. Network failures carry the `Inforcer API request failed:` prefix. Export and Compare report a failed policy fetch as a short message instead of a stack trace. `-ErrorVariable` holds the module's `ApiRequestFailed_` record. - Security: `$Error` and `Get-Error` no longer contain the API key after a failed call (0.8.0 kept it in the request headers), and `Test-InforcerConnection -Verbose` no longer prints part of the key. `-Debug` on report downloads and run polling (`Save-InforcerReportOutput`, `Invoke-InforcerReport`, `Get-InforcerReportRun -Wait`) no longer prints the key in the request headers, and an error about a non-JSON answer no longer quotes it. - Export and Compare no longer label a report with another tenant's name when a tenant record is missing. +- `Export-InforcerTenantDocumentation` HTML: the header counts only the baselines the tenant is the source or a member of (it counted every baseline the credential can see), shows no count when `-Baseline` filters the report, and reads "1 product", "1 policy", "1 baseline". - **Failed requests are no longer hidden.** `Get-InforcerUser -UserId` reports a failed request (it returned nothing, even with `-ErrorAction Stop`). `Get-InforcerAlignmentDetails -BaselineId` reports it as an error instead of a warning. An empty answer from the API gives "API returned an empty response" instead of "You cannot call a method on a null-valued expression". `Get-InforcerUser`, `Get-InforcerGroup` and `Get-InforcerAuditEvent -OutputType JsonObject` return nothing when a page fails, instead of an incomplete list. - `Invoke-InforcerReport`: TAB completion of assessment IDs works before `-ReportType` is bound. - **Report downloads** (`Invoke-InforcerReport`, `Save-InforcerReportOutput`) never overwrite an existing file: a taken name is saved as `name (1).ext`, `name (2).ext`, and so on. A server file name that starts with a dot gets a leading underscore (`.csv` becomes `_.csv`), and `-FileName` writes your name directly. On Windows the files are marked as downloaded from the internet, so Office opens them in Protected View. `Invoke-InforcerReport -Open` no longer launches `.xls` files; it opens the folder. @@ -69,7 +70,7 @@ The full per-object difference list is in [docs/API-REFERENCE.md](docs/API-REFER ### Tests -- 374 new Pester tests cover the v1 transport, token renewal, field mapping, every cmdlet on v1, error scrubbing, pipeline input, download safety, redirect refusal, settings-catalog refresh and Graph enrichment (852 in total). +- 378 new Pester tests cover the v1 transport, token renewal, field mapping, every cmdlet on v1, error scrubbing, pipeline input, download safety, redirect refusal, settings-catalog refresh and Graph enrichment (856 in total). ## [0.8.0] - 2026-10-22 diff --git a/Tests/DocModel.Tests.ps1 b/Tests/DocModel.Tests.ps1 index 34cf5ef..3ef4b97 100644 --- a/Tests/DocModel.Tests.ps1 +++ b/Tests/DocModel.Tests.ps1 @@ -1545,3 +1545,56 @@ Describe 'Compare-InforcerDocModels - cross-category reconciliation' -Tag 'Cross $result.Counters.DestOnly | Should -Be 0 } } + +# --------------------------------------------------------------------------- +# Describe: the baseline count in the report header (2026-11-09) +# --------------------------------------------------------------------------- +Describe 'Documentation baselines are this tenant''s, not every baseline the credential sees' { + BeforeAll { + $script:BlTenant = '0000000a-0000-0000-0000-00000000000b' + $script:BlDocModel = InModuleScope InforcerCommunity -Parameters @{ T = $script:BlTenant } { + $member = { param($id) [pscustomobject]@{ msTenantId = $id; tenantFriendlyName = 'x' } } + $docData = @{ + Tenant = [pscustomobject]@{ tenantFriendlyName = 'Contoso'; msTenantId = $T.ToUpper() } + TenantId = $T + CollectedAt = [datetime]::UtcNow + Policies = @() + Baselines = @( + [pscustomobject]@{ id = 'b1'; name = 'Source of'; baselineMsTenantId = $T; members = @() } + [pscustomobject]@{ id = 'b2'; name = 'Member of'; baselineMsTenantId = '11111111-1111-1111-1111-111111111111'; members = @((& $member $T)) } + [pscustomobject]@{ id = 'b3'; name = 'Unrelated'; baselineMsTenantId = '22222222-2222-2222-2222-222222222222'; members = @((& $member '33333333-3333-3333-3333-333333333333')) } + [pscustomobject]@{ id = 'b4'; name = 'No members'; baselineMsTenantId = '44444444-4444-4444-4444-444444444444'; members = $null } + ) + } + ConvertTo-InforcerDocModel -DocData $docData + } + } + + It 'lists only baselines the tenant is the source or a member of' { + @($script:BlDocModel.Baselines) | Should -Be @('Source of', 'Member of') + } + + It 'the HTML header counts the tenant''s baselines' { + $html = InModuleScope InforcerCommunity -Parameters @{ M = $script:BlDocModel } { ConvertTo-InforcerHtml -DocModel $M } + $html | Should -Match '>2 baselines<' + } + + It 'the HTML header shows no count when the report is filtered to one baseline' { + $filtered = $script:BlDocModel.Clone(); $filtered['FilterBaseline'] = 'Source of' + $html = InModuleScope InforcerCommunity -Parameters @{ M = $filtered } { ConvertTo-InforcerHtml -DocModel $M } + $html | Should -Not -Match '\d+ baselines<' + $html | Should -Match 'Baseline: Source of' + } +} + +Describe 'HTML header counts read as numbers of things' { + It 'one product, one policy and one baseline are singular' { + $model = @{ TenantName = 'T'; TenantId = 1; GeneratedAt = [datetime]'2026-01-15'; Baselines = @('B') + Products = [ordered]@{ 'P' = @{ Categories = [ordered]@{ 'C' = [System.Collections.Generic.List[object]]@( + @{ Basics = @{ Name = 'Pol' }; Settings = @(); Assignments = @() }) } } } } + $html = InModuleScope InforcerCommunity -Parameters @{ M = $model } { ConvertTo-InforcerHtml -DocModel $M } + $html | Should -Match '>1 product<' + $html | Should -Match '>1 policy<' + $html | Should -Match '>1 baseline<' + } +} diff --git a/module/Private/ConvertTo-InforcerDocModel.ps1 b/module/Private/ConvertTo-InforcerDocModel.ps1 index 74d8902..420f9b9 100644 --- a/module/Private/ConvertTo-InforcerDocModel.ps1 +++ b/module/Private/ConvertTo-InforcerDocModel.ps1 @@ -260,10 +260,15 @@ function ConvertTo-InforcerDocModel { if ([string]::IsNullOrWhiteSpace($tenantName) -and $null -ne $tenant) { $tenantName = $tenant.tenantDnsName } if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = "Tenant $($DocData.TenantId)" } - # Collect all baseline names for this tenant + # The baseline list holds every baseline the credential sees: keep the ones this tenant is the source or a member of. $baselineNames = [System.Collections.Generic.List[string]]::new() - if ($null -ne $baselines -and $baselines.Count -gt 0) { + $tenantGuid = if ($null -ne $tenant) { "$($tenant.msTenantId)" } + if ($null -ne $baselines -and $baselines.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($tenantGuid)) { foreach ($bl in @($baselines)) { + if ($null -eq $bl) { continue } + $isSource = "$($bl.baselineMsTenantId)" -eq $tenantGuid + $isMember = @($bl.members | Where-Object { $null -ne $_ -and "$($_.msTenantId)" -eq $tenantGuid }).Count -gt 0 + if (-not ($isSource -or $isMember)) { continue } $blName = $bl.name if ([string]::IsNullOrWhiteSpace($blName)) { $blName = $bl.baselineGroupName } if (-not [string]::IsNullOrWhiteSpace($blName)) { [void]$baselineNames.Add($blName) } diff --git a/module/Private/ConvertTo-InforcerHtml.ps1 b/module/Private/ConvertTo-InforcerHtml.ps1 index 075c866..39efe9b 100644 --- a/module/Private/ConvertTo-InforcerHtml.ps1 +++ b/module/Private/ConvertTo-InforcerHtml.ps1 @@ -886,10 +886,10 @@ tr:hover td { background: var(--row-hover); } [void]$sb.AppendLine("

$tenantNameEsc

") [void]$sb.AppendLine('
') [void]$sb.AppendLine("$generatedAt UTC") - [void]$sb.AppendLine("$totalProducts products") - [void]$sb.AppendLine("$totalPolicies policies") - if ($baselineNames.Count -gt 0) { - [void]$sb.AppendLine("$($baselineNames.Count) baselines") + [void]$sb.AppendLine("$totalProducts product$(if ($totalProducts -ne 1) { 's' })") + [void]$sb.AppendLine("$totalPolicies polic$(if ($totalPolicies -ne 1) { 'ies' } else { 'y' })") + if ($baselineNames.Count -gt 0 -and -not $DocModel.FilterBaseline) { + [void]$sb.AppendLine("$($baselineNames.Count) baseline$(if ($baselineNames.Count -ne 1) { 's' })") } if ($DocModel.FilterBaseline) { $filterBaselineEsc = [System.Net.WebUtility]::HtmlEncode($DocModel.FilterBaseline)