diff --git a/.gitignore b/.gitignore
index 760b72b..424f373 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,3 +89,15 @@ scripts/Test-ApiFeedbackItems.ps1
# Local API key for live-* scripts (never commit)
.inforcer-key.local
+
+# API feedback for the Inforcer API team, and the raw captures behind it.
+# Untracked on purpose: the evidence carries tenant ids, policy names and live
+# response bodies from real customer tenants.
+api-feedback.md
+Reports-API-Feedback.md
+api-feedback-evidence/
+
+# Pester's default TestResult output path, produced by Invoke-Pester -CI. Nothing
+# 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34dc4ab..ab1c647 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,52 @@
All notable changes to this project will be documented in this file.
-The format follows [Conventional Commits](https://www.conventionalcommits.org/). Versioning deviates from strict SemVer: every shipped change (feat / fix / perf / non-breaking refactor) bumps MINOR; only breaking changes bump MAJOR; docs/tests/chore-only commits don't bump. There is intentionally no `[Unreleased]` section — every entry is dated at ship time.
+The format follows [Conventional Commits](https://www.conventionalcommits.org/). Versioning deviates from strict SemVer: every shipped change (feat / fix / perf / non-breaking refactor) bumps MINOR; 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.0] - 2026-09-09
+
+### Breaking Changes
+
+- **`Compare-InforcerEnvironments` and `Export-InforcerTenantDocumentation` no longer write files by default.** Both had `$OutputPath = '.'` plus an unconditional `Start-Process` on the rendered HTML, so neither could run without dropping a file into the caller's working directory and spawning a browser window. In a CI pipeline or a container that is wrong twice over: the file is litter, and there is nothing to open it with.
+ - Writing now happens only when `-OutputPath` is given. There is no default. `-OutputPath`'s presence *is* the opt-in — a separate `-Export` switch would carry no information the path does not already carry.
+ - **Opening a browser is unchanged for interactive use.** The new `-Show` switch defaults to on at a prompt and off on a build agent (`CI`, `TF_BUILD`, `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `BUILDKITE`), via `Test-InforcerInteractiveHost`. Passing `-Show` or `-Show:$false` always beats the detection. The two halves of this change had different justifications and only one of them survived scrutiny: a default `-OutputPath` writes a file nobody asked for, but opening a report you *did* ask for by passing `-OutputPath` is the continuation of an explicit request, not a surprise. The original driver for suppressing it — the MCP server invoking the cmdlet and spawning a browser — no longer exists: the MCP reads the REST API directly and never shells out to PowerShell (`src/manifest.ts`: "a pointer, not an integration: the repos never call each other").
+ - **Without `-OutputPath` the cmdlets return the model instead of `System.IO.FileInfo`** — the comparison hashtable from `Compare-InforcerEnvironments`, the DocModel hashtable from `Export-InforcerTenantDocumentation`. This makes it possible to read alignment scores or tenant configuration without producing artefacts.
+ - **Migration:** add `-OutputPath
` to any call that relied on files appearing in the working directory. Scripts consuming the `FileInfo` return value need `-OutputPath` to keep that return type. Nothing is needed for the browser: interactive callers keep the old behaviour, and CI callers that never wanted it now get a file only. Add `-Show:$false` to an interactive script that writes many reports in a loop and does not want a window per tenant.
+
+### Features
+
+- **HTML reports open automatically again in an interactive session — and never on a CI runner.** `Compare-InforcerEnvironments` and `Export-InforcerTenantDocumentation` gained `-Show`, which defaults to `(Test-InforcerInteractiveHost)`: true at a prompt, false when `CI`, `TF_BUILD`, `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL`, `TEAMCITY_VERSION` or `BUILDKITE` is set. `-Show` forces it and `-Show:$false` suppresses it; an explicit value always beats the detection, so a script writing one report per tenant in a loop opts out with a single flag. The detection is deliberately conservative — it answers "is this definitely not a build agent", not "is there definitely a browser", because `[Environment]::UserInteractive` is true for most non-Windows PowerShell sessions including scripted ones, leaving the CI variables to carry the weight. See **Breaking Changes** for why only half of the original opt-in change survived.
+
+- **`Get-InforcerGroup -Group` now shows `MembershipRule` and `OnPremisesSyncEnabled` by default.** Both were returned by the by-ID endpoint and dropped by the `Format.ps1xml` view, so the property that says what a dynamic group actually *does*, and the one that says whether a group is even editable in the cloud, were invisible unless you knew to ask for `Select-Object *`. `MembershipRule` is guarded by an `ItemSelectionCondition` and appears only when populated, so static groups render exactly as before instead of gaining a permanently blank line. `OnPremisesSyncEnabled` is always shown and renders the API's three states distinctly — `True`, `False (cloud-only)` for `null`, `False (no longer syncing)` for an explicit `false` — because a bare blank cannot distinguish "never synced" from "de-synced", and the two mean different things when you are deciding where a group can be changed.
+
+ **`MembershipRule` is not obtainable from the group *list*.** `GET /beta/tenants/{id}/groups` emits the `membershipRule` key but leaves it `null` on every group, `DynamicMembership` ones included — verified against a live tenant, where `-Group` on the same group ID returns `(user.userType -eq "Member")` and the list returns `null`. `docs/API-REFERENCE.md` claimed the opposite (added in this release's Documentation section, from the #39 OpenAPI drift snapshot) and has been corrected; `onPremisesSyncEnabled` is absent from the list payload entirely. The `GroupSummary` view carries the same conditional row so it lights up with no code change if the API starts filling it, but today it renders nothing — reading rules across a tenant costs one call per dynamic group. Filed as item 26 in the API feedback document.
+
+### Bug Fixes
+
+- **`Invoke-InforcerAssessment` could never reach the API.** Every call failed with `400 ValidationFailure — "Unspecified content type application/json is not allowed."` `POST /beta/tenants/{id}/assessments/{assessmentId}/runs` accepts no request body and rejects *any* `Content-Type`. Removing the header from the hashtable was not enough: `Invoke-RestMethod` supplies `application/x-www-form-urlencoded` on a bodyless POST, which is rejected the same way. Now sends `-ContentType ''`, which suppresses it entirely.
+- **`Invoke-InforcerAssessment -MultiTenant` crashed instead of reporting when every tenant failed.** The run loop fell through to renderers whose `-TenantResults` is mandatory, producing `Cannot bind argument to parameter 'TenantResults' because it is an empty collection` — a parameter-binding error that said nothing about why the runs failed. A single guard after the loop now covers the JSON, HTML and CSV paths and emits `NoAssessmentResults`.
+- **`Compare-InforcerEnvironments -SourceBaselineId` reported a meaningless alignment score.** Scoping the source left the destination at its full policy set, so N baseline policies were compared against the destination's entire estate and every destination-only policy counted as a deviation: a 3-policy baseline against a 756-policy tenant scored **0.2%** where Inforcer's own alignment for the same pair is 100%. The docstring advertised exactly that one-sided form as an example. The destination now inherits `-SourceBaselineId` unless `-DestinationBaselineId` overrides it (same pair now scores 100% over 3 items). When the destination genuinely is not a member of the baseline it falls back to its full policy set with a warning naming the consequence, rather than erroring; an explicitly passed `-DestinationBaselineId` that fails is still an error.
+- **`Compare-InforcerEnvironments -ExcludeOS` was a no-op for every value its own documentation gave as an example.** It matched only the product name (`Entra`, `Intune`, `Defender`, …), while the OS lives in the category key built from `primaryGroup` (`Windows`, `macOS`, `iOS/iPadOS`, `Android`). `-ExcludeOS 'macOS','iOS'` removed 0 of 2229 items while reporting success. It now matches the category key as well as the product name, so `-ExcludeOS 'macOS','iOS'` removes 720 items and passing a product name still works.
+- **`-FetchGraphData` silently installed a module onto the machine.** `Connect-InforcerGraph` ran `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force -AllowClobber` with no consent when the module was absent. `-Force` suppresses the untrusted-repository prompt and `-AllowClobber` permits overwriting commands owned by other modules — neither is appropriate for a read-only reporting module, and on a locked-down or offline host it failed with a PowerShellGet error rather than saying what was missing. It now detects and reports, naming the install command, matching how the `ImportExcel` dependency is already handled. A **warning**, not an error: every caller already falls back to raw ObjectIDs on a null Graph context, and `Write-Error` would have terminated that graceful path under `$ErrorActionPreference = 'Stop'`.
+
+- **`Connect-Inforcer` reported `Connected` for an expired API key.** The validation probe interprets a 4xx carrying the Inforcer app envelope (`success`/`errorCode`/`errors`) as "subscription is live, this key just lacks the scope for `/beta/baselines`" — deliberately, so a key holding only `Reports.Read` can still connect. An expired key returns that *same* envelope, down to `errorCode: "forbidden"` — `403` + `{"data":null,"errorCode":"forbidden","success":false,"message":"A valid API key is required to access this endpoint.","errors":["API key has expired."]}` — so it passed validation and every subsequent cmdlet failed with a 403 the session had just promised would not happen. Neither the status code, the envelope shape nor `errorCode` separates the two cases, and the API exposes no key-introspection endpoint (no `/beta/me`, no scope list) — so `errors[]` is the only signal available. The probe now reads it and refuses to connect when it names a key-lifecycle problem (expired, revoked, deactivated, disabled). It reads `errors[]` *only*, never the top-level `message`: the API serves that same generic `a valid API key is required…` sentence for plain scope denials, and matching it would lock out every narrow-scope key — the one thing this validation must never do, since a key holding only `Reports.Read` is a legitimate caller.
+
+ Connecting on a denied probe is still correct, but it now says so instead of hiding it under `-Verbose`: a key whose probe returned 403 gets a warning that the subscription is live and the key is not expired, but its scopes are unverified, so a later 403 is a scope gap rather than a bad session. Filed with the API team as item 25 in the feedback document, with the fix that would remove the string matching: a distinct `errorCode` for key-level failures, or `401` for an invalid credential versus `403` for an unpermitted one, or a `GET /beta/me` returning `{isActive, expiresAt, scopes[]}`.
+
+- **`Export-InforcerTenantDocumentation -Tag` rendered an empty document when nothing matched.** A 0.1 KB file and exit 0 reads as "nothing in this tenant is tagged that way" when the likelier cause is a tag name that does not exist. It now emits `TagMatchedNothing` and names the tags that do exist, writing no file.
+
+- **`Export-InforcerTenantDocumentation -Format Markdown` named the wrong baseline in the header.** The Markdown header printed `DocModel.BaselineName`, which `ConvertTo-InforcerDocModel` fills with the *first baseline attached to the tenant* — unrelated to `-Baseline`. Exporting the Blueprint Library filtered to `Tier 2 - Enhanced` produced a correctly filtered 238-policy document headed `*Baseline: … Tier 0 - Initiate*`, and an unfiltered export named a baseline it was not scoped to at all. The header now reads `FilterBaseline`, the same field the HTML renderer already used, so it names the baseline actually filtered on and is omitted when there is none. `-Tag` is now shown in the Markdown header too, matching HTML.
+
+### Documentation
+
+- `-OutputPath`, the new `-Show` switch, and the changed return types documented in `Get-Help` and `docs/CMDLET-REFERENCE.md` for both affected cmdlets. The **Output** sections of both entries in `docs/CMDLET-REFERENCE.md` still promised a `FileInfo` return and an auto-opening browser — the parameter tables had been corrected but the return-type prose had not.
+- `-ExcludeOS` help now states that matching applies to both product names and platform category keys, in `docs/CMDLET-REFERENCE.md` as well as `Get-Help`.
+- `membershipRule` added to the **TenantGroupSummary** schema in `docs/API-REFERENCE.md`, picked up from the OpenAPI drift snapshot in #39, which the nightly workflow updates without touching the hand-written reference. The entry originally read "the API now returns it on the group *list* endpoint as well as by-ID" — the schema says so, the runtime does not: the key is emitted and always `null`. Corrected to say exactly that, with the by-ID endpoint named as the only source. A schema gaining a property is not evidence the property is populated; this one was documented off the snapshot without a live check.
+- `onPremisesSyncEnabled` in the **TenantGroup** schema now documents all three states (`true` / `false` / `null`) rather than "whether synced from on-premises AD", which read as a plain boolean and gave `null` no meaning.
+- `-SourceBaselineId` help documents destination inheritance and the non-member fallback; the stale example claiming a one-sided comparison against "all Fabrikam policies" corrected.
+- Versioning note above clarified: pre-1.0, breaking changes bump MINOR under a **Breaking Changes** heading rather than MAJOR.
+- `-Show`'s interactive/CI default documented in `Get-Help` and the `docs/CMDLET-REFERENCE.md` parameter tables for both cmdlets, and the **Output** sections of both entries corrected — they still said the report "opens in the default browser only when `-Show` is passed", which stopped being true when the default changed.
+- `Get-InforcerGroup` example output in `docs/CMDLET-REFERENCE.md` now shows a real dynamic group with its `MembershipRule` and the `OnPremisesSyncEnabled` three-state table, plus the per-group call needed to read rules in bulk.
## [0.6.0] - 2026-07-06
diff --git a/README.md b/README.md
index 661588c..c20a79e 100644
--- a/README.md
+++ b/README.md
@@ -82,12 +82,14 @@ Get-InforcerTenantPolicies -TenantId 482
# Show policy changes (PolicyDiff on each tenant when available)
Get-InforcerTenant | Select-Object ClientTenantId, TenantFriendlyName, PolicyDiff
-# Generate tenant documentation as HTML
+# Generate tenant documentation as HTML. -OutputPath is what writes the file: omit it and the
+# DocModel is returned instead, so you can read the configuration without producing artefacts.
+# The report opens in your browser automatically, unless you are on a CI runner or pass -Show:$false.
Export-InforcerTenantDocumentation -Format Html -TenantId 482 -OutputPath ./docs
# Generate documentation for a specific baseline with Graph group name resolution
Connect-Inforcer -ApiKey "your-api-key" -Region uk -FetchGraphData
-Export-InforcerTenantDocumentation -Format Html -TenantId 482 -Baseline "Production" -FetchGraphData
+Export-InforcerTenantDocumentation -Format Html -TenantId 482 -Baseline "Production" -FetchGraphData -OutputPath ./docs
# Disconnect when done
Disconnect-Inforcer
@@ -111,8 +113,8 @@ Disconnect-Inforcer
| **Get-InforcerGroup** | Retrieves Entra ID groups from a tenant (list/search or detail by GroupId). |
| **Get-InforcerRole** | Retrieves Entra ID directory role definitions from a tenant. |
| **Get-InforcerSecureScore** | Retrieves the current and 90-day historic Microsoft Secure Score for a tenant, including per-category scores and actionable control profiles. |
-| **Export-InforcerTenantDocumentation** | Generates comprehensive tenant documentation in HTML, Markdown, or Excel format. |
-| **Compare-InforcerEnvironments** | Compares two tenants' Intune configuration and generates an interactive HTML comparison report. Supports baseline-scoped comparison via `-SourceBaselineId` / `-DestinationBaselineId` with automatic baseline owner resolution. |
+| **Export-InforcerTenantDocumentation** | Generates comprehensive tenant documentation in HTML, Markdown, or Excel format. Pass `-OutputPath` to write files; without it the DocModel is returned and nothing is written. |
+| **Compare-InforcerEnvironments** | Compares two tenants' Intune configuration and generates an interactive HTML comparison report. Pass `-OutputPath` to write the file; without it the comparison model is returned and nothing is written. Supports baseline-scoped comparison via `-SourceBaselineId` / `-DestinationBaselineId` with automatic baseline owner resolution. |
| **Get-InforcerAssessment** | Lists available assessments (CIS, Essential Eight, etc.). |
| **Invoke-InforcerAssessment** | Runs an assessment against one or more tenants. Supports HTML/CSV export. |
| **Get-InforcerReportType** | Lists the report catalog from the Reports API. Cached after first call; supports `-Key`, `-Tag`, and `-OutputFormat` filters. |
diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1
index ee6a29c..1c1ce6f 100644
--- a/Tests/Consistency.Tests.ps1
+++ b/Tests/Consistency.Tests.ps1
@@ -54,8 +54,8 @@ Describe 'Consistency contract' {
'Get-InforcerGroup' = @('TenantId', 'Search', 'Filter', 'MaxResults', 'Group', 'OutputType')
'Get-InforcerRole' = @('TenantId', 'OutputType')
'Get-InforcerSecureScore' = @('TenantId', 'OutputType')
- 'Export-InforcerTenantDocumentation' = @('Format', 'TenantId', 'OutputPath', 'SettingsCatalogPath', 'FetchGraphData', 'Baseline', 'Tag')
- 'Compare-InforcerEnvironments' = @('SourceTenantId', 'DestinationTenantId', 'SourceSession', 'DestinationSession', 'SourceBaselineId', 'DestinationBaselineId', 'IncludingAssignments', 'SettingsCatalogPath', 'FetchGraphData', 'ExcludeOS', 'PolicyNameFilter', 'OutputPath')
+ 'Export-InforcerTenantDocumentation' = @('Format', 'TenantId', 'OutputPath', 'Show', 'SettingsCatalogPath', 'FetchGraphData', 'Baseline', 'Tag')
+ 'Compare-InforcerEnvironments' = @('SourceTenantId', 'DestinationTenantId', 'SourceSession', 'DestinationSession', 'SourceBaselineId', 'DestinationBaselineId', 'IncludingAssignments', 'SettingsCatalogPath', 'FetchGraphData', 'ExcludeOS', 'PolicyNameFilter', 'OutputPath', 'Show')
'Get-InforcerAssessment' = @('Format', 'OutputType')
'Invoke-InforcerAssessment' = @('TenantId', 'AssessmentId', 'OutputType')
'Get-InforcerReportType' = @('Key', 'Tag', 'OutputFormat', 'Force', 'Format', 'OutputType')
@@ -1861,6 +1861,41 @@ Describe 'Private helpers (via module scope)' {
$result.Status | Should -Be 'Connected'
}
+ It 'Rejects an expired key even though it uses the Inforcer 403 envelope' {
+ # Verbatim body from api-uk.dev with an expired key. errorCode is 'forbidden' —
+ # identical to a scope denial — so only errors[] distinguishes the two.
+ # Must NOT report Connected.
+ Mock -ModuleName InforcerCommunity Invoke-WebRequest {
+ [PSCustomObject]@{
+ StatusCode = 403
+ Content = '{"data":null,"errorCode":"forbidden","success":false,"message":"A valid API key is required to access this endpoint.","errors":["API key has expired."]}'
+ Headers = @{}
+ }
+ }
+ $secure = ConvertTo-SecureString 'expired-key' -AsPlainText -Force
+ $err = $null
+ $result = Connect-Inforcer -ApiKey $secure -Region uk -ErrorAction SilentlyContinue -ErrorVariable err
+ $result | Should -BeNullOrEmpty
+ @($err).Count | Should -BeGreaterThan 0
+ $err[0].Exception.Message | Should -Match 'API key has expired'
+ }
+
+ It 'Connects a narrow-scope key whose 403 carries the same generic key message' {
+ # Same top-level message as the expired key above — the API serves it for scope
+ # denials too — but errors[] names a scope, not the key. Must still connect, or
+ # the expired-key fix would lock out every key that lacks Baselines.Read.
+ Mock -ModuleName InforcerCommunity Invoke-WebRequest {
+ [PSCustomObject]@{
+ StatusCode = 403
+ Content = '{"data":null,"errorCode":"forbidden","success":false,"message":"A valid API key is required to access this endpoint.","errors":["Insufficient scope: Baselines.Read is required."]}'
+ Headers = @{}
+ }
+ }
+ $secure = ConvertTo-SecureString 'reports-only-key' -AsPlainText -Force
+ $result = Connect-Inforcer -ApiKey $secure -Region uk -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
+ $result.Status | Should -Be 'Connected'
+ }
+
It 'Treats APIM 401 envelope as a real auth failure' {
# APIM gateway rejects the subscription: {statusCode, message} with no Inforcer markers.
Mock -ModuleName InforcerCommunity Invoke-WebRequest {
@@ -2140,4 +2175,339 @@ Describe 'Private helpers (via module scope)' {
}
}
}
+
+ Context 'Group Format.ps1xml views' {
+ # The group views carry the only conditional display logic in the module: MembershipRule is
+ # suppressed when empty, and OnPremisesSyncEnabled renders three API states that a plain
+ # boolean would collapse to two. Both are easy to "simplify" into a wrong answer.
+ BeforeAll {
+ function script:Format-Group ([hashtable]$Props, [string]$TypeName) {
+ $g = [PSCustomObject]$Props
+ $g.PSObject.TypeNames.Insert(0, $TypeName)
+ ($g | Format-List | Out-String)
+ }
+ $script:GroupBase = @{
+ id = 'f44f2f5c-3160-420b-900d-5ecbede954fc'; displayName = 'G'; description = 'd'
+ mail = $null; visibility = $null; groupTypes = @(); membershipRule = $null
+ mailEnabled = $false; createdDateTime = '2026-01-01'; onPremisesSyncEnabled = $null
+ members = @()
+ }
+ }
+
+ It 'Detail view shows MembershipRule when the group has one' {
+ $p = $script:GroupBase.Clone()
+ $p.groupTypes = @('DynamicMembership')
+ $p.membershipRule = '(user.userType -eq "Member")'
+ $out = script:Format-Group $p 'InforcerCommunity.Group'
+ $out | Should -Match 'MembershipRule\s+:\s+\(user\.userType -eq "Member"\)'
+ }
+
+ It 'Detail view omits the MembershipRule row entirely for a static group' {
+ $out = script:Format-Group $script:GroupBase.Clone() 'InforcerCommunity.Group'
+ $out | Should -Not -Match 'MembershipRule'
+ }
+
+ It 'Summary view omits the MembershipRule row entirely for a static group' {
+ $out = script:Format-Group $script:GroupBase.Clone() 'InforcerCommunity.GroupSummary'
+ $out | Should -Not -Match 'MembershipRule'
+ }
+
+ # null and $false both mean "not syncing now" but they are NOT the same fact: null is
+ # "never synced" (cloud-only), $false is "was synced, no longer". Collapsing them mislabels
+ # a de-synced group as cloud-editable.
+ It 'Detail view distinguishes all three OnPremisesSyncEnabled states' -ForEach @(
+ @{ Value = $true; Expected = 'True' }
+ @{ Value = $null; Expected = 'False \(cloud-only\)' }
+ @{ Value = $false; Expected = 'False \(no longer syncing\)' }
+ ) {
+ $p = $script:GroupBase.Clone()
+ $p.onPremisesSyncEnabled = $Value
+ $out = script:Format-Group $p 'InforcerCommunity.Group'
+ $out | Should -Match "OnPremisesSyncEnabled\s+:\s+$Expected"
+ }
+ }
+}
+
+Describe 'Writing files and opening a browser stay opt-in (0.7.0 breaking change)' {
+ # Both cmdlets used to carry $OutputPath = '.' plus an unconditional Start-Process, so neither
+ # could run without dropping a file in the caller's working directory and spawning a browser.
+ # Reintroducing either default is invisible to every other test in this suite: nothing else
+ # asserts on where files land. These parse the param() block directly so no API call is needed.
+ BeforeDiscovery {
+ $script:OptInCmdlets = @(
+ @{ Name = 'Export-InforcerTenantDocumentation' }
+ @{ Name = 'Compare-InforcerEnvironments' }
+ )
+ }
+
+ It ' declares -OutputPath with no default value' -ForEach $script:OptInCmdlets {
+ $path = Join-Path $PSScriptRoot "../module/Public/$Name.ps1"
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
+ $fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
+ $p = $fn.Body.ParamBlock.Parameters |
+ Where-Object { $_.Name.VariablePath.UserPath -eq 'OutputPath' }
+ $p | Should -Not -BeNullOrEmpty -Because "$Name must still expose -OutputPath"
+ $p.DefaultValue | Should -BeNullOrEmpty -Because "a default -OutputPath writes files into the caller's working directory without being asked"
+ }
+
+ It ' declares -Show as a switch' -ForEach $script:OptInCmdlets {
+ $cmd = Get-Command $Name
+ $cmd.Parameters['Show'].ParameterType | Should -Be ([switch])
+ }
+
+ # -Show auto-detects: on for a human, off on a build agent. The detection is the whole
+ # safety story now that auto-open is back, so it gets tested directly rather than through
+ # the cmdlets (which would need a live tenant and two minutes per case).
+ #
+ # These tests MUST neutralise every detector variable before exercising one, because the suite
+ # itself runs on a build agent. Setting only the variable under test leaves the runner's own
+ # GITHUB_ACTIONS=true in place: the "false" assertions then pass for the wrong reason, and the
+ # empty-value assertion fails outright. That is exactly how this failed in CI the first time.
+ BeforeAll {
+ $script:CiVars = 'CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'TEAMCITY_VERSION', 'BUILDKITE'
+ function script:Invoke-WithCiEnv {
+ param([hashtable]$Set, [scriptblock]$Body)
+ $saved = @{}
+ foreach ($v in $script:CiVars) {
+ $saved[$v] = [Environment]::GetEnvironmentVariable($v)
+ [Environment]::SetEnvironmentVariable($v, $null)
+ }
+ try {
+ foreach ($k in $Set.Keys) { [Environment]::SetEnvironmentVariable($k, $Set[$k]) }
+ & $Body
+ } finally {
+ foreach ($v in $script:CiVars) { [Environment]::SetEnvironmentVariable($v, $saved[$v]) }
+ }
+ }
+ }
+
+ It 'Test-InforcerInteractiveHost returns false when is set' -ForEach @(
+ @{ Var = 'CI' }, @{ Var = 'TF_BUILD' }, @{ Var = 'GITHUB_ACTIONS' }
+ @{ Var = 'GITLAB_CI' }, @{ Var = 'JENKINS_URL' }, @{ Var = 'TEAMCITY_VERSION' }, @{ Var = 'BUILDKITE' }
+ ) {
+ # Only $Var is set — every other detector is cleared, so a false result can only come from
+ # this one variable. Without the clearing this assertion would be vacuous on any CI runner.
+ script:Invoke-WithCiEnv -Set @{ $Var = 'true' } -Body {
+ & (Get-Module InforcerCommunity) { Test-InforcerInteractiveHost }
+ } | Should -BeFalse -Because "a build agent must never have a browser launched at it"
+ }
+
+ It 'Test-InforcerInteractiveHost ignores an empty CI variable' {
+ # With every detector cleared and CI set to empty string, the answer must fall through to
+ # UserInteractive — an empty value is not "in CI".
+ script:Invoke-WithCiEnv -Set @{ 'CI' = '' } -Body {
+ & (Get-Module InforcerCommunity) { Test-InforcerInteractiveHost }
+ } | Should -Be ([Environment]::UserInteractive) -Because 'an empty value is not "in CI"'
+ }
+
+ It 'Test-InforcerInteractiveHost returns true when no CI variable is set at all' {
+ script:Invoke-WithCiEnv -Set @{} -Body {
+ & (Get-Module InforcerCommunity) { Test-InforcerInteractiveHost }
+ } | Should -Be ([Environment]::UserInteractive) -Because 'with nothing set it is the host that decides'
+ }
+
+ It ' resolves -Show from the interactive check, not a hardcoded default' -ForEach $script:OptInCmdlets {
+ $path = Join-Path $PSScriptRoot "../module/Public/$Name.ps1"
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
+ $fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
+ $p = $fn.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq 'Show' }
+ $p.DefaultValue.Extent.Text | Should -Match 'Test-InforcerInteractiveHost'
+ }
+
+ It ' opens a browser only inside an if ($Show) block' -ForEach $script:OptInCmdlets {
+ $path = Join-Path $PSScriptRoot "../module/Public/$Name.ps1"
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
+ $launchers = $ast.FindAll({
+ param($n)
+ $n -is [System.Management.Automation.Language.CommandAst] -and
+ $n.GetCommandName() -in @('Start-Process', 'Invoke-Item')
+ }, $true)
+ foreach ($l in $launchers) {
+ # Walk up to the enclosing if-statement and confirm $Show gates it.
+ $guarded = $false
+ $node = $l.Parent
+ while ($node) {
+ if ($node -is [System.Management.Automation.Language.IfStatementAst] -and
+ $node.Clauses[0].Item1.Extent.Text -match '\$Show') { $guarded = $true; break }
+ $node = $node.Parent
+ }
+ $guarded | Should -BeTrue -Because "$($l.GetCommandName()) at line $($l.Extent.StartLineNumber) must be gated by -Show"
+ }
+ }
+}
+
+Describe '0.7.0 bug fixes that only live runs covered' {
+ # These three shipped verified by hand and by a live smoke, with nothing in CI to catch a
+ # regression. Each targets the smallest seam that actually encodes the fix.
+
+ Context 'Invoke-InforcerAssessmentRun sends no Content-Type' {
+ # POST /beta/tenants/{id}/assessments/{id}/runs takes no body and rejects ANY Content-Type
+ # with 400 ValidationFailure. Dropping the header is not enough — Invoke-RestMethod supplies
+ # application/x-www-form-urlencoded on a bodyless POST, which is rejected identically.
+ # The call runs in a separate runspace via AddScript, so a module-scoped Mock cannot reach
+ # it; the script text IS the contract, so that is what gets asserted.
+ BeforeAll {
+ $script:RunnerSrc = Get-Content (Join-Path $PSScriptRoot '../module/Private/Invoke-InforcerAssessmentRun.ps1') -Raw
+ }
+
+ It 'passes -ContentType "" on the run POST' {
+ $script:RunnerSrc | Should -Match 'Invoke-RestMethod[^\r\n]*-ContentType\s*""'
+ }
+
+ It 'never sets a Content-Type header on the runs endpoint' {
+ # A 'Content-Type' = 'application/json' entry anywhere in this file would reintroduce the 400.
+ $script:RunnerSrc | Should -Not -Match "(?i)['\`"]Content-Type['\`"]\s*="
+ }
+ }
+
+ Context '-ExcludeOS matches the category key, not just the product name' {
+ # The OS lives in the category key built from primaryGroup (Windows, macOS, iOS/iPadOS,
+ # Android) and never in the product name (Entra, Intune, Defender). Matching products alone
+ # made every documented example value a silent no-op that removed 0 items and reported success.
+ BeforeAll {
+ function script:New-TestPolicy ([string]$Name, [string]$DefId, [string]$Value) {
+ @{
+ Basics = @{ Name = $Name; Id = $DefId; Description = ''; ProfileType = 'Test'
+ Platform = ''; Created = ''; Modified = ''; ScopeTags = ''; Tags = '' }
+ Settings = @(@{ Name = $Name; SettingPath = $Name; Value = $Value; DefinitionId = $DefId })
+ Assignments = @()
+ PolicyTypeId = 10
+ }
+ }
+ function script:New-TestModel ([string]$TenantName, [string]$Value) {
+ @{
+ TenantName = $TenantName
+ TenantId = 1
+ Products = [ordered]@{
+ 'Intune' = @{
+ Categories = [ordered]@{
+ 'Windows / Configuration Profiles' = @(script:New-TestPolicy 'WinSetting' 'def-win' $Value)
+ 'macOS / Configuration Profiles' = @(script:New-TestPolicy 'MacSetting' 'def-mac' $Value)
+ 'iOS/iPadOS / App Protection Policies' = @(script:New-TestPolicy 'IosSetting' 'def-ios' $Value)
+ }
+ }
+ }
+ }
+ }
+ function script:Get-CategoryKeys ($Result) {
+ $keys = [System.Collections.Generic.List[string]]::new()
+ foreach ($p in $Result.Products.Keys) {
+ foreach ($c in $Result.Products[$p].Categories.Keys) { [void]$keys.Add("$p / $c") }
+ }
+ $keys
+ }
+ }
+
+ It 'removes nothing when -ExcludeOS is not passed' {
+ $r = & (Get-Module InforcerCommunity) {
+ param($s, $d) Compare-InforcerDocModels -SourceModel $s -DestinationModel $d
+ } (script:New-TestModel 'Src' 'A') (script:New-TestModel 'Dst' 'A')
+ (script:Get-CategoryKeys $r).Count | Should -Be 3
+ }
+
+ It 'removes a category whose KEY contains the excluded OS' {
+ $r = & (Get-Module InforcerCommunity) {
+ param($s, $d) Compare-InforcerDocModels -SourceModel $s -DestinationModel $d -ExcludeOS @('macOS')
+ } (script:New-TestModel 'Src' 'A') (script:New-TestModel 'Dst' 'A')
+ $keys = script:Get-CategoryKeys $r
+ $keys | Should -Not -Contain 'Intune / macOS / Configuration Profiles'
+ $keys | Should -Contain 'Intune / Windows / Configuration Profiles' -Because 'only the named OS goes'
+ }
+
+ It 'removes several OSes at once' {
+ $r = & (Get-Module InforcerCommunity) {
+ param($s, $d) Compare-InforcerDocModels -SourceModel $s -DestinationModel $d -ExcludeOS @('macOS', 'iOS')
+ } (script:New-TestModel 'Src' 'A') (script:New-TestModel 'Dst' 'A')
+ $keys = script:Get-CategoryKeys $r
+ @($keys).Count | Should -Be 1
+ $keys | Should -Contain 'Intune / Windows / Configuration Profiles'
+ }
+
+ It 'still matches a PRODUCT name, which was the only thing that ever worked' {
+ $r = & (Get-Module InforcerCommunity) {
+ param($s, $d) Compare-InforcerDocModels -SourceModel $s -DestinationModel $d -ExcludeOS @('Intune')
+ } (script:New-TestModel 'Src' 'A') (script:New-TestModel 'Dst' 'A')
+ (script:Get-CategoryKeys $r).Count | Should -Be 0 -Because 'excluding the product drops all its categories'
+ }
+ }
+
+ Context 'The destination inherits -SourceBaselineId' {
+ # Scoping one side only compared N baseline policies against the destination's whole estate,
+ # so every destination-only policy counted as a deviation: 0.2% where Inforcer says 100%.
+ BeforeAll {
+ $script:MinimalDocData = @{ TenantId = 1; TenantName = 'T'; Policies = @(@{ id = 'p1' }) }
+ }
+
+ It 'scopes the destination to the source baseline when no destination baseline is given' {
+ $calls = & (Get-Module InforcerCommunity) {
+ param($docData)
+ $script:seen = [System.Collections.Generic.List[string]]::new()
+ Mock Get-InforcerDocData { $docData } -ModuleName InforcerCommunity
+ Mock Select-InforcerBaselinePolicies { [void]$script:seen.Add($BaselineId); 'BaselineName' } -ModuleName InforcerCommunity
+ Mock Write-Host {} -ModuleName InforcerCommunity
+ $null = Get-InforcerComparisonData -SourceTenantId 1 -DestinationTenantId 2 -SourceBaselineId 'Tier 0'
+ $script:seen
+ } $script:MinimalDocData
+ @($calls).Count | Should -Be 2 -Because 'both sides must be scoped'
+ $calls[0] | Should -Be 'Tier 0'
+ $calls[1] | Should -Be 'Tier 0' -Because 'the destination inherits the source baseline'
+ }
+
+ It 'does not override an explicit -DestinationBaselineId' {
+ $calls = & (Get-Module InforcerCommunity) {
+ param($docData)
+ $script:seen = [System.Collections.Generic.List[string]]::new()
+ Mock Get-InforcerDocData { $docData } -ModuleName InforcerCommunity
+ Mock Select-InforcerBaselinePolicies { [void]$script:seen.Add($BaselineId); 'BaselineName' } -ModuleName InforcerCommunity
+ Mock Write-Host {} -ModuleName InforcerCommunity
+ $null = Get-InforcerComparisonData -SourceTenantId 1 -DestinationTenantId 2 `
+ -SourceBaselineId 'Tier 0' -DestinationBaselineId 'Tier 2'
+ $script:seen
+ } $script:MinimalDocData
+ $calls[0] | Should -Be 'Tier 0'
+ $calls[1] | Should -Be 'Tier 2'
+ }
+
+ # An INHERITED baseline that will not resolve is legitimate — the destination simply may not
+ # be a member — so it falls back to the full policy set with a warning. An EXPLICIT one that
+ # fails is still an error. The mock fails only the second call, which is the destination.
+ It 'warns and falls back when the destination is not a member of the inherited baseline' {
+ $r = & (Get-Module InforcerCommunity) {
+ param($docData)
+ $script:n = 0
+ Mock Get-InforcerDocData { $docData } -ModuleName InforcerCommunity
+ Mock Select-InforcerBaselinePolicies {
+ $script:n++
+ if ($script:n -eq 1) { 'SourceBaseline' } else { $null }
+ } -ModuleName InforcerCommunity
+ Mock Write-Host {} -ModuleName InforcerCommunity
+ $out = Get-InforcerComparisonData -SourceTenantId 1 -DestinationTenantId 2 -SourceBaselineId 'Tier 0' -WarningVariable w -WarningAction SilentlyContinue -ErrorVariable e -ErrorAction SilentlyContinue
+ @{ Result = $out; Warnings = $w; Errors = $e }
+ } $script:MinimalDocData
+
+ $r.Warnings | Should -Not -BeNullOrEmpty -Because 'the fallback must announce that the score is understated'
+ ($r.Warnings -join ' ') | Should -Match 'full policy set'
+ $r.Errors | Should -BeNullOrEmpty -Because 'a non-member destination is a legitimate comparison, not an error'
+ $r.Result | Should -Not -BeNullOrEmpty -Because 'it must still return data to compare'
+ }
+
+ It 'errors when an EXPLICIT -DestinationBaselineId cannot be resolved' {
+ $r = & (Get-Module InforcerCommunity) {
+ param($docData)
+ $script:n = 0
+ Mock Get-InforcerDocData { $docData } -ModuleName InforcerCommunity
+ Mock Select-InforcerBaselinePolicies {
+ $script:n++
+ if ($script:n -eq 1) { 'SourceBaseline' } else { $null }
+ } -ModuleName InforcerCommunity
+ Mock Write-Host {} -ModuleName InforcerCommunity
+ $out = Get-InforcerComparisonData -SourceTenantId 1 -DestinationTenantId 2 -SourceBaselineId 'Tier 0' -DestinationBaselineId 'Tier 2' -ErrorVariable e -ErrorAction SilentlyContinue
+ @{ Result = $out; Errors = $e }
+ } $script:MinimalDocData
+
+ $r.Errors | Should -Not -BeNullOrEmpty -Because 'an explicitly named baseline that does not resolve is a mistake worth surfacing'
+ "$($r.Errors[0].FullyQualifiedErrorId)" | Should -Match 'DestBaselineFilterFailed'
+ $r.Result | Should -BeNullOrEmpty
+ }
+ }
}
diff --git a/Tests/Renderers.Tests.ps1 b/Tests/Renderers.Tests.ps1
index a9fd301..91f13c1 100644
--- a/Tests/Renderers.Tests.ps1
+++ b/Tests/Renderers.Tests.ps1
@@ -230,7 +230,10 @@ Describe 'ConvertTo-InforcerMarkdown' -Tag 'Markdown' {
TenantName = 'Test Tenant'
TenantId = 12345
GeneratedAt = [datetime]'2026-01-15 10:30:00'
- BaselineName = 'TestBaseline'
+ # BaselineName is the tenant's first attached baseline and must never reach the header;
+ # only FilterBaseline (set when -Baseline is used) may.
+ BaselineName = 'WrongBaseline'
+ FilterBaseline = 'TestBaseline'
Products = [ordered]@{
'Intune' = @{
Categories = [ordered]@{
@@ -304,8 +307,23 @@ Describe 'ConvertTo-InforcerMarkdown' -Tag 'Markdown' {
$script:MarkdownOutput | Should -Match 'Generated: 2026-01-15'
}
- It 'contains baseline name' {
+ It 'contains the filtered baseline name, not the tenant''s first baseline' {
$script:MarkdownOutput | Should -Match 'Baseline: TestBaseline'
+ $script:MarkdownOutput | Should -Not -Match 'WrongBaseline'
+ }
+
+ It 'omits the baseline header when no baseline filter was applied' {
+ $unfiltered = @{
+ TenantName = 'Test Tenant'
+ TenantId = 12345
+ GeneratedAt = [datetime]'2026-01-15 10:30:00'
+ BaselineName = 'WrongBaseline'
+ Products = [ordered]@{}
+ }
+ $result = InModuleScope InforcerCommunity -Parameters @{ M = $unfiltered } {
+ ConvertTo-InforcerMarkdown -DocModel $M
+ }
+ $result | Should -Not -Match 'Baseline:'
}
It 'contains TOC with product anchor links' {
diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md
index 7b8b317..1c297d9 100644
--- a/docs/API-REFERENCE.md
+++ b/docs/API-REFERENCE.md
@@ -740,6 +740,7 @@ Summary of an Entra ID group (returned from list endpoint).
| description | string | No | Description of the group. |
| mail | string | No | Email address of the group. |
| visibility | string | No | Visibility (e.g. Public, Private). |
+| membershipRule | string | No | Dynamic membership rule. **Always `null` on this endpoint**, despite the upstream OpenAPI schema (added by the 2026-09-04 drift sync in #39) describing it as *"only populated for groups whose groupTypes contains DynamicMembership"*. The key is emitted and never filled — verified live on 2026-09-09 against two unrelated tenants, where 0 of 10 and 0 of 7 `DynamicMembership` groups carried a rule on the list while the by-ID endpoint returned one for each. Use the by-ID endpoint to read the rule. See API feedback item 26. |
| groupTypes | array\ | Yes | Types of the group (e.g. Unified, DynamicMembership). |
**PSTypeName:** `InforcerCommunity.GroupSummary`
@@ -760,7 +761,7 @@ Full detail of an Entra ID group (returned from by-ID endpoint).
| groupTypes | array\ | Yes | Types of the group. |
| createdDateTime | string (datetime) | No | When the group was created. |
| mailEnabled | boolean | No | Whether mail is enabled. |
-| onPremisesSyncEnabled | boolean | No | Whether synced from on-premises AD. |
+| onPremisesSyncEnabled | boolean | No | Whether synced from on-premises AD. Three states: `true` = synced, `false` = was synced but no longer, `null` = never synced (cloud-only). Not returned by the list endpoint at all. |
| members | array\