From 194d6831b1967a7a7fb80efbfc31f48257e609a4 Mon Sep 17 00:00:00 2001 From: Jeffrey Stuhr <110697945+fadwen@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:56:49 -0700 Subject: [PATCH] feat(entra): read the tenant's licences once at connect and skip the gated steps with one message Get-EntraCapability reads the subscribed SKUs when the connection is established and records Known, EntraP1 and EntraP2 on it. New-EntraEnvironment skips Conditional Access policies without P1 and role eligibilities without P2, once, with one warning naming the licence and the step, instead of nine policy refusals and three eligibility warnings that each said the same thing in Graph's words; each skipped step carries its Reason. -IncludeUnlicensed attempts them regardless, and a tenant whose SKUs cannot be read gets every step as before. The report and the verifier stop asking for eligibilities a tenant without P2 cannot hold. Teardown deliberately ignores the probe. Verified live against the lab tenant: Known, no P1, no P2; one warning; both steps Skipped with their reasons; the verifier's warning gone. --- CHANGELOG.md | 10 +++ CLAUDE.md | 11 +++ .../Entra/Private/Get-EntraCapability.ps1 | 60 ++++++++++++++ .../Entra/Public/Connect-EntraEnvironment.ps1 | 13 ++- .../Public/Get-EntraEnvironmentReport.ps1 | 17 ++-- .../Entra/Public/New-EntraEnvironment.ps1 | 43 +++++++++- .../Entra/Public/Test-EntraEnvironment.ps1 | 6 ++ Providers/Entra/README.md | 2 +- Tests/README.md | 1 + .../Entra/Get-EntraCapability.Tests.ps1 | 83 +++++++++++++++++++ .../Get-EntraEnvironmentReport.Tests.ps1 | 10 +++ .../Entra/New-EntraEnvironment.Tests.ps1 | 62 ++++++++++++++ .../Entra/Test-EntraEnvironment.Tests.ps1 | 10 +++ 13 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 Providers/Entra/Private/Get-EntraCapability.ps1 create mode 100644 Tests/Unit/Providers/Entra/Get-EntraCapability.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2961d6e..1f2e2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,16 @@ All notable changes to this module are recorded here. Format follows provider it is handed can use the one name. A contract test pins the alias on every provider but Active Directory, which stores nothing. +- **The Entra connect reads the tenant's licences once.** `Get-EntraCapability` reads the subscribed + SKUs at connect time and records on the connection whether the tenant holds Entra ID P1 and P2 + (or Governance). `New-EntraEnvironment` then skips the Conditional Access step without P1 and the + role eligibility step without P2, once, with one message naming the licence and the step, instead + of nine policy refusals and three eligibility warnings that each said the same thing in Graph's + words; each skipped step carries its `Reason` in the result. `-IncludeUnlicensed` attempts them + regardless, and a tenant whose SKUs the app cannot read gets every step as before. The report and + the verifier stop asking for eligibilities a tenant without P2 cannot hold, so the one read Graph + refuses is never made. + ### Changed - **The Entra report takes the shared parameters.** `-Format` and `-Path` are kept as aliases of diff --git a/CLAUDE.md b/CLAUDE.md index 3f17f54..d7098ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,17 @@ native application with no secret) is created without S256 PKCE. Neither has a p `New-PingOnePopulation.Tests.ps1` and `New-PingOneApplication.Tests.ps1` assert both. PingOne's own applications and built-in resources are matched by type, never name, and never touched. +### The Entra connect reads the tenant's licences once, and unknown means attempt everything + +`Get-EntraCapability` runs inside `Connect-EntraEnvironment` and puts `Capabilities` on the +connection: `Known`, `EntraP1`, `EntraP2`. `New-EntraEnvironment` skips Conditional Access +policies without P1 and role eligibilities without P2 with one message, and the report and the +verifier do not ask Graph for eligibilities a tenant without P2 cannot hold. The rule that keeps +this safe: `Known = $false` - the app could not read `/subscribedSkus` - means every step runs, as +it did before the probe existed. The probe may remove noise; it may never remove a step the +tenant would have run. Teardown deliberately ignores it: a tenant whose P2 lapsed may still hold +eligibilities Graph will not show, and deleting the role definitions would orphan them. + ### Every HTTP provider handles text encoding the same way, and none of it is optional Windows PowerShell 5.1 corrupts non-ASCII text in both directions, silently, and PowerShell 7 hides diff --git a/Providers/Entra/Private/Get-EntraCapability.ps1 b/Providers/Entra/Private/Get-EntraCapability.ps1 new file mode 100644 index 0000000..9a40523 --- /dev/null +++ b/Providers/Entra/Private/Get-EntraCapability.ps1 @@ -0,0 +1,60 @@ +function Get-EntraCapability { + <# + .SYNOPSIS + Reads once, at connect time, which licence-gated features the tenant can hold + .DESCRIPTION + Two of the seed's fourteen steps need a licence the tenant may not have: Conditional + Access policies need Entra ID P1, and role eligibilities - Privileged Identity Management - + need Entra ID P2 or Entra ID Governance. Without the probe each of those steps found out + for itself, one refusal per object: nine policy failures and three eligibility warnings, + each saying the same thing in Graph's words. With it, the connection knows before the + first request, and the seed skips the step once with one message. + + The answer comes from the tenant's subscribed SKUs: a plan counts when its SKU is enabled + and the plan itself is provisioned. A tenant that will not let the app read its SKUs - + the read needs Organization.Read.All or Directory.Read.All, which the connect already + requires for /organization - answers Known = $false, and every step is then attempted as + before, so the probe can only ever remove noise, never a step the tenant would have run. + .PARAMETER Connection + The connection being established; the same hashtable Connect-EntraEnvironment builds + .OUTPUTS + PSCustomObject with Known, EntraP1, EntraP2 and Plans, the provisioned plan names + .EXAMPLE + PS> Get-EntraCapability -Connection $candidate + + Known True, EntraP1 False, EntraP2 False for a tenant on free Entra ID. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [hashtable]$Connection + ) + + $plans = New-Object System.Collections.Generic.List[string] + $known = $true + try { + foreach ($sku in @((Invoke-EntraRequest -Method GET -Path '/subscribedSkus' -Connection $Connection).value)) { + if ([string]$sku.capabilityStatus -ne 'Enabled') { continue } + foreach ($plan in @($sku.servicePlans)) { + if ([string]$plan.provisioningStatus -eq 'Success' -and $plan.servicePlanName) { $plans.Add([string]$plan.servicePlanName) } + } + } + } + catch { + $known = $false + Write-Verbose "Could not read the tenant's subscribed SKUs, so its licences are unknown and every step will be attempted: $($_.Exception.Message)" + } + + # P2 and Governance both carry Privileged Identity Management; either includes P1. + $p2 = @($plans | Where-Object { $_ -match '^AAD_PREMIUM_P2$|GOVERNANCE' }).Count -gt 0 + $p1 = $p2 -or @($plans | Where-Object { $_ -eq 'AAD_PREMIUM' }).Count -gt 0 + + return [PSCustomObject]@{ + PSTypeName = 'EntraCapability' + Known = $known + EntraP1 = $p1 + EntraP2 = $p2 + Plans = @($plans | Sort-Object -Unique) + } +} diff --git a/Providers/Entra/Public/Connect-EntraEnvironment.ps1 b/Providers/Entra/Public/Connect-EntraEnvironment.ps1 index 00fbcf9..321bfc1 100644 --- a/Providers/Entra/Public/Connect-EntraEnvironment.ps1 +++ b/Providers/Entra/Public/Connect-EntraEnvironment.ps1 @@ -85,7 +85,8 @@ Returns the connection summary .OUTPUTS - EntraConnection when -PassThru is supplied + EntraConnection when -PassThru is supplied, carrying Capabilities: whether the tenant's + licences could be read, and whether it holds Entra ID P1 and P2 .EXAMPLE PS> Connect-EntraEnvironment -TenantId b818de68-9112-40b3-adc2-6838048cd611 ` @@ -218,6 +219,7 @@ AccessToken = $null TokenExpiresOn = $null TokenRoles = @() + Capabilities = $null } if ($Interactive) { @@ -294,6 +296,14 @@ return } + # Read once what the tenant is licensed for, so the seed can skip a step the tenant cannot + # hold with one message rather than discover it one refusal per object. A tenant that will + # not let the app read its SKUs answers "unknown", and every step is attempted as before. + $candidate.Capabilities = Get-EntraCapability -Connection $candidate + if ($candidate.Capabilities.Known) { + Write-Verbose ("Tenant licences: Entra ID P1 {0}, P2 {1}" -f $candidate.Capabilities.EntraP1, $candidate.Capabilities.EntraP2) + } + $script:EntraConnection = $candidate Write-Verbose "Connected to $($candidate.TenantName) as $ClientId, seeding under $Prefix on $($candidate.UpnSuffix)" @@ -325,6 +335,7 @@ UpnSuffix = $candidate.UpnSuffix VerifiedDomains = $candidate.VerifiedDomains GrantedRoles = $candidate.TokenRoles + Capabilities = $candidate.Capabilities } } } diff --git a/Providers/Entra/Public/Get-EntraEnvironmentReport.ps1 b/Providers/Entra/Public/Get-EntraEnvironmentReport.ps1 index 8658e43..b2ef0df 100644 --- a/Providers/Entra/Public/Get-EntraEnvironmentReport.ps1 +++ b/Providers/Entra/Public/Get-EntraEnvironmentReport.ps1 @@ -93,12 +93,19 @@ # a null count - which prints as blank rather than as a zero that would read as "none". $eligibilities = @() $eligibilityCount = $null - try { - $eligibilities = @(Get-EntraSeededObject -Type RoleEligibilities -Connection $connection -ErrorAction Stop) - $eligibilityCount = $eligibilities.Count + $capability = $connection.Capabilities + if ($capability -and $capability.Known -and -not $capability.EntraP2) { + # Without P2 there can be none, and the read would be refused: zero, not unknown. + $eligibilityCount = 0 } - catch { - Write-Warning "Could not read the role eligibilities, so they are reported as unknown: $($_.Exception.Message)" + else { + try { + $eligibilities = @(Get-EntraSeededObject -Type RoleEligibilities -Connection $connection -ErrorAction Stop) + $eligibilityCount = $eligibilities.Count + } + catch { + Write-Warning "Could not read the role eligibilities, so they are reported as unknown: $($_.Exception.Message)" + } } # Counted three different ways on purpose, because the three disagree and the disagreement diff --git a/Providers/Entra/Public/New-EntraEnvironment.ps1 b/Providers/Entra/Public/New-EntraEnvironment.ps1 index f69dd95..083ce67 100644 --- a/Providers/Entra/Public/New-EntraEnvironment.ps1 +++ b/Providers/Entra/Public/New-EntraEnvironment.ps1 @@ -22,12 +22,19 @@ users, groups and devices, which all exist by now 9. Custom directory roles - definitions only, assigned to nobody 10. Role eligibilities - eligible schedules over those definitions, naming seeded - principals and scoped to seeded units. Needs Entra ID P2; warns and continues without + principals and scoped to seeded units. Needs Entra ID P2 or Entra ID Governance 11. Named locations - referenced by the policies below 12. Authentication strengths - referenced by the policies below - 13. Conditional Access policies - scoped to groups, conditioned on locations + 13. Conditional Access policies - scoped to groups, conditioned on locations. Needs + Entra ID P1 14. Containment - places anything a replication race left outside its unit + The two licence-gated steps are decided once, from the licences the connect read. A + tenant without P1 skips the policies, one without P2 skips the eligibilities, each with + one message naming the licence, rather than nine policy refusals and three eligibility + warnings that each say the same thing in Graph's words. A tenant whose licences could not + be read gets every step, and -IncludeUnlicensed attempts them regardless. + This creates roughly 1,190 objects. Everything that can be sent through Graph's $batch endpoint is, in chunks of twenty, which is the difference between a run of a few minutes and a run of well over an hour. @@ -46,6 +53,9 @@ .PARAMETER Skip Steps to leave out. Takes the same names as Remove-EntraEnvironment's -Keep. + .PARAMETER IncludeUnlicensed + Attempt the Conditional Access and role eligibility steps even when the licences the + connect read say the tenant cannot hold them .PARAMETER SkuPartNumber Which SKU the licensing step should assign. Defaults to an automatically chosen one @@ -104,6 +114,9 @@ [ValidateNotNullOrEmpty()] [string]$SkuPartNumber, + [Parameter()] + [switch]$IncludeUnlicensed, + [Parameter()] [switch]$ShowProgress, @@ -157,14 +170,33 @@ [PSCustomObject]@{ Name = 'Containment'; Action = { Update-EntraContainment -PassThru:$true -ShowProgress:$ShowProgress } } ) + # What the tenant cannot hold, decided once from the licences the connect read. A tenant + # whose licences could not be read gets every step, so this can only remove noise, never a + # step the tenant would have run. + $unlicensed = [ordered]@{} + $capability = $connection.Capabilities + if (-not $IncludeUnlicensed -and $capability -and $capability.Known) { + if (-not $capability.EntraP1) { $unlicensed['ConditionalAccessPolicies'] = 'Conditional Access needs Entra ID P1' } + if (-not $capability.EntraP2) { $unlicensed['RoleEligibilities'] = 'Privileged Identity Management needs Entra ID P2 or Entra ID Governance' } + foreach ($name in @($unlicensed.Keys)) { if ($Skip -contains $name) { $unlicensed.Remove($name) } } + } + if ($unlicensed.Count -gt 0) { + $named = @($unlicensed.GetEnumerator() | ForEach-Object { '{0} ({1})' -f $_.Key, $_.Value }) -join ' and ' + Write-Warning ("Skipping $named`: the tenant '$($connection.TenantName)' is not licensed for that. The rest of the " + + 'environment is seeded regardless; -IncludeUnlicensed attempts the skipped step anyway.') + } + $outcomes = [System.Collections.Generic.List[object]]::new() $stepIndex = 0 foreach ($step in $steps) { $stepIndex++ - if ($Skip -contains $step.Name) { - Write-Verbose "Skipping step $($step.Name)" + $reason = $null + if ($Skip -contains $step.Name) { $reason = 'Skipped by -Skip' } + elseif ($unlicensed.Contains($step.Name)) { $reason = $unlicensed[$step.Name] } + if ($reason) { + Write-Verbose "Skipping step $($step.Name): $reason" $outcomes.Add([PSCustomObject]@{ PSTypeName = 'EntraEnvironmentStep' Step = $step.Name @@ -172,6 +204,7 @@ Count = 0 Items = @() Error = $null + Reason = $reason }) continue } @@ -188,6 +221,7 @@ Count = $items.Count Items = $items Error = $null + Reason = $null }) Write-Verbose "Step $($step.Name) produced $($items.Count) object(s)" } @@ -202,6 +236,7 @@ Count = 0 Items = @() Error = $_.Exception.Message + Reason = $null }) } } diff --git a/Providers/Entra/Public/Test-EntraEnvironment.ps1 b/Providers/Entra/Public/Test-EntraEnvironment.ps1 index 1a8d826..a0b64ce 100644 --- a/Providers/Entra/Public/Test-EntraEnvironment.ps1 +++ b/Providers/Entra/Public/Test-EntraEnvironment.ps1 @@ -154,8 +154,14 @@ function Test-EntraEnvironment { } # --- Everything the licence decides, counted ------------------------------------------- + $capability = $connection.Capabilities foreach ($type in 'ServicePrincipals', 'NamedLocations', 'ConditionalAccessPolicies', 'AdministrativeUnits', 'AuthenticationStrengths', 'DirectoryRoles', 'RoleEligibilities') { + if ($type -eq 'RoleEligibilities' -and $capability -and $capability.Known -and -not $capability.EntraP2) { + # Without P2 there can be none, and the read would be refused. + $checks.Add((New-TestEnvironmentCheck -Name $type -FoundCount 0)) + continue + } try { $count = @(Get-EntraSeededObject -Type $type -Connection $connection).Count $checks.Add((New-TestEnvironmentCheck -Name $type -FoundCount $count)) diff --git a/Providers/Entra/README.md b/Providers/Entra/README.md index 3ef3818..51a8f53 100644 --- a/Providers/Entra/README.md +++ b/Providers/Entra/README.md @@ -203,7 +203,7 @@ the SecretStore is shared, and what `-VaultPassword` is for, is in the | Requirement | Minimum | Notes | |---|---|---| | **PowerShell** | 5.1 | Desktop and Core; developed on pwsh 7.6 | -| **Entra tenant** | Any | Dynamic groups need Entra ID P1; everything else works without | +| **Entra tenant** | Any | Dynamic groups and Conditional Access policies need Entra ID P1; role eligibilities need P2 or Governance. The connect reads the tenant's plans once, and the seed skips a step the tenant cannot hold with one message; everything else works without a licence | | **App registration** | Certificate credential | Created for you by `New-TestServiceApp`, or supply your own | | **Permissions** | Directory writes | See below — the token does not tell you the whole story | | **Modules** | none | Deliberately zero dependencies | diff --git a/Tests/README.md b/Tests/README.md index 5e759cf..f6d9ca3 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -62,6 +62,7 @@ promise the README makes, or a regression for a bug that reached a real director | `Core\Confirm-TestTeardown.Tests.ps1` | That the one teardown question reaches the cmdlet as written and its answer is returned, and that a host which cannot ask is read as a refusal, never a yes | | `Core\ConvertTo-TestBase64Url.Tests.ps1` | Padding, URL-unsafe characters, byte-exact round trips including leading zeros | | `Core\Initialize-TestSecretVault.Tests.ps1` | That a locked store is detected by the error it throws, that an unlock failure is fatal rather than a warning, and that the vault is proven before anything remote is created | +| `Providers\Entra\Get-EntraCapability.Tests.ps1` | That a plan counts only on an enabled SKU and when provisioned, that P2 or Governance implies P1, and that a tenant whose SKUs cannot be read answers unknown rather than unlicensed, so the probe can only remove noise, never a step | | `Providers\Entra\SeedData.Tests.ps1` | The shape and referential integrity of all 1,185 seed rows | | `Providers\Entra\New-EntraClientAssertion.Tests.ps1` | That the JWT verifies against its own public key, that `x5t` is the thumbprint **bytes** not its hex text, and that the audience is the v2.0 tenant endpoint | | `Providers\Entra\Invoke-EntraRequest.Tests.ps1` | UTF-8 both ways, query encoding, pagination and its loop guard, both retry policies, and that the inner exception is set rather than stringified | diff --git a/Tests/Unit/Providers/Entra/Get-EntraCapability.Tests.ps1 b/Tests/Unit/Providers/Entra/Get-EntraCapability.Tests.ps1 new file mode 100644 index 0000000..aa94da7 --- /dev/null +++ b/Tests/Unit/Providers/Entra/Get-EntraCapability.Tests.ps1 @@ -0,0 +1,83 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The licence probe the Entra connect runs once. The seed skips a step on its word, so the word + has to be right in both directions: a plan counts only when its SKU is enabled and the plan is + provisioned, P2 or Governance implies P1, and a tenant whose SKUs the app cannot read answers + "unknown" - which the seed reads as "attempt everything" - rather than "unlicensed". The probe + may remove noise; it may never remove a step the tenant would have run. + + Everything is mocked. The tenant is never reached. +#> + +BeforeAll { + $moduleRoot = (Split-Path -Path (Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent) -Parent) + . (Join-Path $moduleRoot 'Tests\Stubs\Add-ADTestStubPath.ps1') + # Imported once per run, not once per file: a warm forced import costs about 190 ms, and 111 + # files paid it. CI runs the suite shuffled, so state one file leaves behind for another + # fails there rather than hiding in file order. + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $moduleRoot 'TestEnvironment.psd1') } +} + +Describe 'Get-EntraCapability' -Tag 'Unit', 'Private' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:Connection = @{ TenantId = 'tenant-1'; GraphBaseUri = 'https://graph.microsoft.com'; AccessToken = 't' } + $script:Skus = @() + Mock Invoke-EntraRequest { [PSCustomObject]@{ value = $script:Skus } } + } + } + + It 'reads a free tenant as neither P1 nor P2, and known' { + InModuleScope TestEnvironment { + $script:Skus = @([PSCustomObject]@{ skuPartNumber = 'O365_BUSINESS'; capabilityStatus = 'Enabled'; servicePlans = @([PSCustomObject]@{ servicePlanName = 'EXCHANGE_S_STANDARD'; provisioningStatus = 'Success' }) }) + $capability = Get-EntraCapability -Connection $script:Connection + $capability.Known | Should-BeTrue + $capability.EntraP1 | Should-BeFalse + $capability.EntraP2 | Should-BeFalse + @($capability.Plans) | Should-BeCollection @('EXCHANGE_S_STANDARD') + Should-Invoke Invoke-EntraRequest -Times 1 -Exactly -ParameterFilter { $Path -eq '/subscribedSkus' } + } + } + + It 'reads P1 from a provisioned AAD_PREMIUM plan, and P2 or Governance as both' { + InModuleScope TestEnvironment { + $script:Skus = @([PSCustomObject]@{ skuPartNumber = 'EMS'; capabilityStatus = 'Enabled'; servicePlans = @([PSCustomObject]@{ servicePlanName = 'AAD_PREMIUM'; provisioningStatus = 'Success' }) }) + $p1 = Get-EntraCapability -Connection $script:Connection + $p1.EntraP1 | Should-BeTrue + $p1.EntraP2 | Should-BeFalse + + $script:Skus = @([PSCustomObject]@{ skuPartNumber = 'AAD_PREMIUM_P2'; capabilityStatus = 'Enabled'; servicePlans = @([PSCustomObject]@{ servicePlanName = 'AAD_PREMIUM_P2'; provisioningStatus = 'Success' }) }) + $p2 = Get-EntraCapability -Connection $script:Connection + $p2.EntraP1 | Should-BeTrue + $p2.EntraP2 | Should-BeTrue + + $script:Skus = @([PSCustomObject]@{ skuPartNumber = 'Microsoft_Entra_ID_Governance'; capabilityStatus = 'Enabled'; servicePlans = @([PSCustomObject]@{ servicePlanName = 'Entra_Identity_Governance'; provisioningStatus = 'Success' }) }) + (Get-EntraCapability -Connection $script:Connection).EntraP2 | Should-BeTrue + } + } + + It 'ignores a plan on a suspended SKU and a plan that is not provisioned' { + InModuleScope TestEnvironment { + $script:Skus = @( + [PSCustomObject]@{ skuPartNumber = 'AAD_PREMIUM_P2'; capabilityStatus = 'Suspended'; servicePlans = @([PSCustomObject]@{ servicePlanName = 'AAD_PREMIUM_P2'; provisioningStatus = 'Success' }) } + [PSCustomObject]@{ skuPartNumber = 'EMS'; capabilityStatus = 'Enabled'; servicePlans = @([PSCustomObject]@{ servicePlanName = 'AAD_PREMIUM'; provisioningStatus = 'Disabled' }) } + ) + $capability = Get-EntraCapability -Connection $script:Connection + $capability.Known | Should-BeTrue + $capability.EntraP1 | Should-BeFalse + $capability.EntraP2 | Should-BeFalse + } + } + + It 'answers unknown, not unlicensed, when the SKUs cannot be read' { + InModuleScope TestEnvironment { + Mock Invoke-EntraRequest { throw 'Graph GET /subscribedSkus failed with HTTP 403' } + $capability = Get-EntraCapability -Connection $script:Connection + $capability.Known | Should-BeFalse + $capability.EntraP1 | Should-BeFalse + $capability.EntraP2 | Should-BeFalse + } + } +} diff --git a/Tests/Unit/Providers/Entra/Get-EntraEnvironmentReport.Tests.ps1 b/Tests/Unit/Providers/Entra/Get-EntraEnvironmentReport.Tests.ps1 index 594372d..e08a794 100644 --- a/Tests/Unit/Providers/Entra/Get-EntraEnvironmentReport.Tests.ps1 +++ b/Tests/Unit/Providers/Entra/Get-EntraEnvironmentReport.Tests.ps1 @@ -79,6 +79,16 @@ Describe 'Get-EntraEnvironmentReport' -Tag 'Unit', 'Public' { } } + It 'reports zero eligibilities without asking when the connect read that the tenant has no P2' { + InModuleScope TestEnvironment { + Mock Get-EntraConnection { @{ TenantId = 'tenant-1'; TenantName = 'Contoso Lab'; UpnSuffix = 'lab.example.com'; Capabilities = [PSCustomObject]@{ Known = $true; EntraP1 = $false; EntraP2 = $false } } } + $report = Get-EntraEnvironmentReport -PassThru -WarningVariable warnings -WarningAction SilentlyContinue + $report.RoleEligibilityCount | Should-Be 0 + Should-NotInvoke Get-EntraSeededObject -ParameterFilter { $Type -eq 'RoleEligibilities' } + @($warnings) | Should-BeCollection -Count 0 + } + } + It 'still binds the names it had before the surface was unified' { InModuleScope TestEnvironment { $path = Join-Path $TestDrive 'alias.json' diff --git a/Tests/Unit/Providers/Entra/New-EntraEnvironment.Tests.ps1 b/Tests/Unit/Providers/Entra/New-EntraEnvironment.Tests.ps1 index 62bc341..ecc333a 100644 --- a/Tests/Unit/Providers/Entra/New-EntraEnvironment.Tests.ps1 +++ b/Tests/Unit/Providers/Entra/New-EntraEnvironment.Tests.ps1 @@ -159,6 +159,68 @@ Describe 'New-EntraEnvironment' -Tag 'Unit' { } } + Context 'Licence' { + + It 'skips the two licence-gated steps with one message when the connect read that the tenant lacks P1 and P2' { + InModuleScope TestEnvironment { + $script:EntraConnection.Capabilities = [PSCustomObject]@{ Known = $true; EntraP1 = $false; EntraP2 = $false; Plans = @() } + + $result = New-EntraEnvironment -PassThru -WarningVariable warnings -WarningAction SilentlyContinue + + @($script:StepOrder) | Should-NotContainCollection @('ConditionalAccessPolicies', 'RoleEligibilities') + Should-NotInvoke New-EntraConditionalAccessPolicy + Should-NotInvoke New-EntraRoleEligibility + $policies = $result.Steps | Where-Object { $_.Step -eq 'ConditionalAccessPolicies' } + $policies.Status | Should-Be 'Skipped' + $policies.Reason | Should-MatchString 'P1' + ($result.Steps | Where-Object { $_.Step -eq 'RoleEligibilities' }).Reason | Should-MatchString 'P2' + # One message for both, not one per refused object. + @($warnings | Where-Object { $_ -like '*not licensed*' }).Count | Should-Be 1 + [string]@($warnings)[0] | Should-MatchString 'ConditionalAccessPolicies' + [string]@($warnings)[0] | Should-MatchString 'RoleEligibilities' + } + } + + It 'skips only the eligibilities on a P1 tenant, and nothing on a P2 one' { + InModuleScope TestEnvironment { + $script:EntraConnection.Capabilities = [PSCustomObject]@{ Known = $true; EntraP1 = $true; EntraP2 = $false; Plans = @('AAD_PREMIUM') } + New-EntraEnvironment -WarningAction SilentlyContinue | Out-Null + Should-Invoke New-EntraConditionalAccessPolicy -Times 1 -Exactly + Should-NotInvoke New-EntraRoleEligibility + + $script:StepOrder.Clear() + $script:EntraConnection.Capabilities = [PSCustomObject]@{ Known = $true; EntraP1 = $true; EntraP2 = $true; Plans = @('AAD_PREMIUM_P2') } + New-EntraEnvironment -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null + Should-Invoke New-EntraRoleEligibility -Times 1 -Exactly + @($warnings) | Should-BeCollection -Count 0 + } + } + + It 'attempts every step when the licences could not be read, and under -IncludeUnlicensed' { + InModuleScope TestEnvironment { + $script:EntraConnection.Capabilities = [PSCustomObject]@{ Known = $false; EntraP1 = $false; EntraP2 = $false; Plans = @() } + New-EntraEnvironment | Out-Null + Should-Invoke New-EntraConditionalAccessPolicy -Times 1 -Exactly + Should-Invoke New-EntraRoleEligibility -Times 1 -Exactly + + $script:EntraConnection.Capabilities = [PSCustomObject]@{ Known = $true; EntraP1 = $false; EntraP2 = $false; Plans = @() } + New-EntraEnvironment -IncludeUnlicensed -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null + Should-Invoke New-EntraConditionalAccessPolicy -Times 2 -Exactly + Should-Invoke New-EntraRoleEligibility -Times 2 -Exactly + @($warnings) | Should-BeCollection -Count 0 + } + } + + It 'does not name a step in the licence message that -Skip already left out' { + InModuleScope TestEnvironment { + $script:EntraConnection.Capabilities = [PSCustomObject]@{ Known = $true; EntraP1 = $false; EntraP2 = $false; Plans = @() } + $result = New-EntraEnvironment -Skip RoleEligibilities -PassThru -WarningVariable warnings -WarningAction SilentlyContinue + [string]@($warnings)[0] | Should-NotMatchString 'RoleEligibilities' + ($result.Steps | Where-Object { $_.Step -eq 'RoleEligibilities' }).Reason | Should-Be 'Skipped by -Skip' + } + } + } + Context 'Failure isolation' { It 'continues after a failing step rather than abandoning a half-seeded tenant' { diff --git a/Tests/Unit/Providers/Entra/Test-EntraEnvironment.Tests.ps1 b/Tests/Unit/Providers/Entra/Test-EntraEnvironment.Tests.ps1 index acbaafb..3f7ae39 100644 --- a/Tests/Unit/Providers/Entra/Test-EntraEnvironment.Tests.ps1 +++ b/Tests/Unit/Providers/Entra/Test-EntraEnvironment.Tests.ps1 @@ -149,6 +149,16 @@ Describe 'Test-EntraEnvironment' -Tag 'Unit', 'Public' { } } + It 'does not ask for role eligibilities when the connect read that the tenant has no P2, and counts them as none' { + InModuleScope TestEnvironment { + Mock Get-EntraConnection { @{ TenantId = 'tenant-1'; UpnSuffix = 'lab.example.com'; Capabilities = [PSCustomObject]@{ Known = $true; EntraP1 = $false; EntraP2 = $false } } } + $result = Test-EntraEnvironment -SkipMembership -Quiet -WarningVariable warnings -WarningAction SilentlyContinue + Should-NotInvoke Get-EntraSeededObject -ParameterFilter { $Type -eq 'RoleEligibilities' } + ($result.Checks | Where-Object { $_.Name -eq 'RoleEligibilities' }).Found | Should-Be 0 + @($warnings) | Should-BeCollection -Count 0 + } + } + It 'sends no batch under -SkipMembership and prints nothing under -Quiet' { InModuleScope TestEnvironment { $result = Test-EntraEnvironment -SkipMembership -Quiet