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