diff --git a/powershell/Maester.psd1 b/powershell/Maester.psd1 index a8142ff5c..5243a068f 100644 --- a/powershell/Maester.psd1 +++ b/powershell/Maester.psd1 @@ -63,7 +63,7 @@ 'Get-MailAuthenticationRecord', 'Get-MtAdminPortalUrl', 'Get-MtAuthenticationMethodPolicyConfig', 'Get-MtAzureManagementGroup', 'Get-MtConditionalAccessPolicy', 'Get-MtExo', 'Get-MtExoThreatPolicyMalware', 'Get-MtGraphScope', 'Get-MtGroupMember', 'Get-MtHtmlReport', 'Get-MtLicenseInformation', 'Get-MtMaesterApp', 'Get-MtRole', - 'Get-MtRoleMember', 'Get-MtSafeMarkdown', 'Get-MtSession', 'Get-MtTestInventory', 'Get-MtUser', + 'Get-MtRoleMember', 'Get-MtSafeMarkdown', 'Get-MtSession', 'Get-MtSessionLicense', 'Get-MtTestInventory', 'Get-MtUser', 'Get-MtUserAuthenticationMethod', 'Get-MtUserAuthenticationMethodInfoByType', 'Import-MtMaesterResult', 'Install-MaesterTests', 'Invoke-Maester', 'Invoke-MtAzureRequest', 'Invoke-MtAzureResourceGraphRequest', 'Invoke-MtGraphRequest', 'Invoke-MtGraphSecurityQuery', 'Merge-MtMaesterResult', 'New-MtMaesterApp', 'Resolve-SPFRecord', diff --git a/powershell/Maester.psm1 b/powershell/Maester.psm1 index 1cb989e9f..2da05c9f7 100644 --- a/powershell/Maester.psm1 +++ b/powershell/Maester.psm1 @@ -25,6 +25,7 @@ $__MtSession = @{ DataverseApiBase = $null # Resolved Dataverse OData API base URL (e.g. https://org123.api.crm.dynamics.com/api/data/v9.2) DataverseResourceUrl = $null # Dataverse resource URL for token acquisition (e.g. https://org123.crm.dynamics.com) DataverseEnvironmentId = $null # Environment identifier for display (e.g. org123.crm.dynamics.com) + Licenses = @{} # Pre-fetched license map keyed by product name, populated by Initialize-MtSession } New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force diff --git a/powershell/internal/Clear-ModuleVariable.ps1 b/powershell/internal/Clear-ModuleVariable.ps1 index 17879e660..c383e753a 100644 --- a/powershell/internal/Clear-ModuleVariable.ps1 +++ b/powershell/internal/Clear-ModuleVariable.ps1 @@ -17,6 +17,7 @@ $__MtSession.TestResultDetail = @{} $__MtSession.MaesterConfig = $null $__MtSession.AdminPortalUrl = @{} + $__MtSession.Licenses = @{} Clear-MtDnsCache Clear-MtExoCache $__MtSession.AIAgentInfo = $null diff --git a/powershell/internal/Get-MtRoleInfo.ps1 b/powershell/internal/Get-MtRoleInfo.ps1 index 56318da54..13b1f7b42 100644 --- a/powershell/internal/Get-MtRoleInfo.ps1 +++ b/powershell/internal/Get-MtRoleInfo.ps1 @@ -193,6 +193,9 @@ function Get-MtRoleInfo { if ([string]::IsNullOrWhiteSpace($RoleName)) { return $null } + # Guard against the role definitions not being loaded (e.g., if module scope was lost). + if ($null -eq $script:MtRoles) { return $null } + if ($script:MtRoles.ContainsKey($RoleName)) { return $script:MtRoles[$RoleName] } diff --git a/powershell/internal/Initialize-MtSession.ps1 b/powershell/internal/Initialize-MtSession.ps1 index 2a75a8736..dfd2fa082 100644 --- a/powershell/internal/Initialize-MtSession.ps1 +++ b/powershell/internal/Initialize-MtSession.ps1 @@ -19,4 +19,19 @@ } $__MtSession.AdminPortalUrl = Get-MtAdminPortalUrl -Environment $environment + + # Pre-fetch all license products into the session cache so that BeforeDiscovery blocks + # in test files pay zero Graph API cost when calling Get-MtLicenseInformation. + # ExoLicenseCount is intentionally excluded here as it returns a number (seat count) + # rather than a product string and is not used for skip-gating. + $licenseProducts = @('EntraID', 'EntraWorkloadID', 'Eop', 'ExoDlp', 'Mdo', 'MdoV2', + 'AdvAudit', 'DefenderXDR', 'CustomerLockbox', 'Intune') + try { + foreach ($product in $licenseProducts) { + $__MtSession.Licenses[$product] = Get-MtLicenseInformation -Product $product + } + Write-Verbose "License information pre-fetched for $($licenseProducts.Count) products." + } catch { + Write-Verbose "License pre-fetch skipped (tenant not reachable or not connected to Graph): $_" + } } diff --git a/powershell/public/Get-MtLicenseInformation.ps1 b/powershell/public/Get-MtLicenseInformation.ps1 index fc618c58b..750465fc2 100644 --- a/powershell/public/Get-MtLicenseInformation.ps1 +++ b/powershell/public/Get-MtLicenseInformation.ps1 @@ -27,11 +27,26 @@ function Get-MtLicenseInformation { begin { # Get all subscribed SKUs once to optimize performance. - $SKUs = Invoke-MtGraphRequest -RelativeUri 'subscribedSkus' | Where-Object { $_.capabilityStatus -eq 'Enabled' } - $ServicePlans = $SKUs | Select-Object -ExpandProperty servicePlans | Select-Object -ExpandProperty servicePlanId | Sort-Object -Unique + # Skip the Graph call entirely if the session cache already has this product. + # (Initialize-MtSession pre-populates $__MtSession.Licenses for all products.) + $SKUs = $null + $ServicePlans = $null } process { + # Return from session cache when available (populated by Initialize-MtSession). + if ($__MtSession.Licenses.ContainsKey($Product)) { + Write-Verbose "Returning cached license information for $Product" + return $__MtSession.Licenses[$Product] + } + + # Lazy-load SKU data only when a cache miss requires it. + if ($null -eq $SKUs) { + $SKUs = Invoke-MtGraphRequest -RelativeUri 'subscribedSkus' | Where-Object { $_.capabilityStatus -eq 'Enabled' } + $ServicePlans = $SKUs | Select-Object -ExpandProperty servicePlans | Select-Object -ExpandProperty servicePlanId | Sort-Object -Unique + } + + $LicenseType = $null switch ($Product) { 'EntraID' { Write-Verbose 'Retrieving license information for Entra ID' @@ -45,7 +60,6 @@ function Get-MtLicenseInformation { $LicenseType = 'Free' } Write-Information "The license type for Entra ID is $LicenseType" - return $LicenseType break } 'EntraWorkloadID' { @@ -58,7 +72,6 @@ function Get-MtLicenseInformation { $LicenseType = $null } Write-Information "The license type for Entra ID is $LicenseType" - return $LicenseType break } 'Eop' { @@ -76,7 +89,6 @@ function Get-MtLicenseInformation { } } Write-Information "The license type for Exchange Online Protection is $LicenseType" - return $LicenseType break } 'ExoDlp' { @@ -99,7 +111,6 @@ function Get-MtLicenseInformation { } } Write-Information "The license type for Exchange Online DLP is $LicenseType" - return $LicenseType break } 'Mdo' { @@ -118,7 +129,6 @@ function Get-MtLicenseInformation { } } Write-Information "The license type for Defender for Office is $LicenseType" - return $LicenseType break } 'MdoV2' { @@ -135,7 +145,6 @@ function Get-MtLicenseInformation { $LicenseType = 'EOP' # Exchange Online Protection / EOP_ENTERPRISE (326e2b78-9d27-42c9-8509-46c827743a17) } Write-Information "The license type for Defender for Office is $LicenseType" - return $LicenseType break } 'AdvAudit' { @@ -153,7 +162,6 @@ function Get-MtLicenseInformation { } } Write-Information "The license type for Advanced Audit is $LicenseType" - return $LicenseType break } 'ExoLicenseCount' { @@ -189,7 +197,7 @@ function Get-MtLicenseInformation { } Write-Information "Total Exchange Online licenses: $TotalLicenses" - return $TotalLicenses + $LicenseType = $TotalLicenses break } 'DefenderXDR' { @@ -208,7 +216,6 @@ function Get-MtLicenseInformation { } } Write-Information 'The tenant is licensed for Defender XDR' - return $LicenseType break } 'CustomerLockbox' { @@ -224,13 +231,10 @@ function Get-MtLicenseInformation { $LicenseType = $null } Write-Information "The license type for Customer Lockbox is $LicenseType" - return $LicenseType break } "Intune" { Write-Verbose "Retrieving license SKUs for Intune" - $subscribedSkus = Invoke-MtGraphRequest -RelativeUri "subscribedSkus" | Where-Object {$_.capabilityStatus -eq 'Enabled'} - $uniqueServicePlans = $subscribedSkus.servicePlans.servicePlanId | Sort-Object -Unique $requiredServicePlans = @( # https://learn.microsoft.com/en-us/entra/identity/users/licensing-service-plan-reference "c1ec4a95-1f05-45b3-a911-aa3fa01094f5", # INTUNE_A (Microsoft Intune Plan 1) @@ -244,16 +248,19 @@ function Get-MtLicenseInformation { ) $LicenseType = $null - foreach($servicePlan in $requiredServicePlans){ - if($servicePlan -in $uniqueServicePlans){ + foreach ($servicePlan in $requiredServicePlans) { + if ($servicePlan -in $ServicePlans) { $LicenseType = "Intune" } } Write-Information "The tenant is licensed for Intune" - return $LicenseType - Break + break } Default {} } + + # Store in session cache so subsequent calls (including BeforeDiscovery blocks) pay no API cost. + $__MtSession.Licenses[$Product] = $LicenseType + return $LicenseType } } diff --git a/powershell/public/Get-MtSessionLicense.ps1 b/powershell/public/Get-MtSessionLicense.ps1 new file mode 100644 index 000000000..9d198069b --- /dev/null +++ b/powershell/public/Get-MtSessionLicense.ps1 @@ -0,0 +1,42 @@ +function Get-MtSessionLicense { + <# + .SYNOPSIS + Returns the pre-fetched license map for the current tenant session. + + .DESCRIPTION + Returns a hashtable of all license products evaluated for the current tenant. + The map is populated once by Initialize-MtSession (called at the start of Invoke-Maester) + so that BeforeDiscovery blocks in test files can gate tests on license availability + without making additional Graph API calls. + + Keys match the -Product parameter of Get-MtLicenseInformation. + Use this function in BeforeDiscovery blocks instead of calling Get-MtLicenseInformation + per-product. + + .EXAMPLE + BeforeDiscovery { + $Licenses = Get-MtSessionLicense + } + + Describe "Maester/Entra" -Tag "Maester", "Entra" { + It "MT.XXXX: ..." -Tag "MT.XXXX" -Skip:($Licenses.EntraID -eq "Free") { + Test-MtSomeP1Feature | Should -Be $true + } + It "MT.XXXY: ..." -Tag "MT.XXXY" -Skip:($Licenses.EntraID -ne "P2") { + Test-MtSomeP2Feature | Should -Be $true + } + It "MT.XXXZ: ..." -Tag "MT.XXXZ" -Skip:($null -eq $Licenses.Intune) { + Test-MtSomeIntuneFeature | Should -Be $true + } + } + + .LINK + https://maester.dev/docs/commands/Get-MtSessionLicense + #> + [OutputType([hashtable])] + [CmdletBinding()] + param() + + Write-Verbose "Returning session license cache with $($__MtSession.Licenses.Count) products." + return $__MtSession.Licenses +} diff --git a/powershell/public/Invoke-Maester.ps1 b/powershell/public/Invoke-Maester.ps1 index 59d866025..321225258 100644 --- a/powershell/public/Invoke-Maester.ps1 +++ b/powershell/public/Invoke-Maester.ps1 @@ -97,6 +97,13 @@ Connect to all tested services and run all tests, including the long-running and preview tests. + .EXAMPLE + Invoke-Maester -AutoFilterLicense + + Runs tests and automatically skips any test that requires a license the tenant does not have. + For example, on a tenant with only a Business Premium license, tests requiring Entra ID P2, + Defender XDR, or Advanced Audit will be excluded from the run. + .LINK https://maester.dev/docs/commands/Invoke-Maester #> @@ -206,7 +213,14 @@ # The root directory for configuration drift tracking. [Parameter(HelpMessage = 'Specify drift root directory, see https://maester.dev/docs/tests/MT.1060')] - [string] $DriftRoot + [string] $DriftRoot, + + # Automatically exclude tests that require licenses not present in the tenant. + # When set, Maester queries the tenant license information at startup and adds the + # appropriate License-* tags to ExcludeTag so unlicensed tests are skipped cleanly. + # This requires a Graph connection and is silently ignored when not connected. + [Parameter(HelpMessage = 'Skip tests that require licenses the tenant does not have.')] + [switch] $AutoFilterLicense ) function GetDefaultFileName() { @@ -324,9 +338,51 @@ Test-MtContext -SendMail:$isMail -SendTeamsMessage:$isTeamsChannelMessage | Out-Null } - # Initialize MtSession after Graph connected. + # Initialize MtSession after Graph connected (also pre-fetches license information). Initialize-MtSession + # If -AutoFilterLicense is set, exclude tests whose required license is absent in the tenant. + if ($AutoFilterLicense.IsPresent) { + $tenantLicenses = Get-MtSessionLicense + if ($tenantLicenses.Count -gt 0) { + $licenseExclusions = [System.Collections.Generic.List[string]]::new() + + # Entra ID tiers: Free excludes P1 + P2 + Governance; P1 excludes P2 + Governance + switch ($tenantLicenses['EntraID']) { + 'Free' { + $licenseExclusions.AddRange([string[]]@('License-EntraP1', 'License-EntraP2', 'License-EntraGovernance')) + } + 'P1' { + $licenseExclusions.AddRange([string[]]@('License-EntraP2', 'License-EntraGovernance')) + } + } + + # Binary (licensed / not licensed) products + $binaryProducts = @{ + 'EntraWorkloadID' = 'License-EntraWorkloadID' + 'Eop' = 'License-Eop' + 'ExoDlp' = 'License-ExoDlp' + 'Mdo' = 'License-Mdo' + 'AdvAudit' = 'License-AdvAudit' + 'DefenderXDR' = 'License-DefenderXDR' + 'CustomerLockbox' = 'License-CustomerLockbox' + 'Intune' = 'License-Intune' + } + foreach ($entry in $binaryProducts.GetEnumerator()) { + if ($null -eq $tenantLicenses[$entry.Key]) { + $licenseExclusions.Add($entry.Value) + } + } + + if ($licenseExclusions.Count -gt 0) { + $ExcludeTag = @($ExcludeTag | Where-Object { $_ }) + $licenseExclusions + Write-Verbose "AutoFilterLicense: excluding tags $($licenseExclusions -join ', ')" + } + } else { + Write-Verbose 'AutoFilterLicense: license data not available, skipping auto-filter.' + } + } + if ($isWebUri) { # Check if TeamChannelWebhookUri is a valid URL. $urlPattern = '^(https)://[^\s/$.?#].[^\s]*$' diff --git a/powershell/public/maester/entra/Test-MtCaExclusionForDirectorySyncAccount.ps1 b/powershell/public/maester/entra/Test-MtCaExclusionForDirectorySyncAccount.ps1 index cf9a5e577..b0b3ffacf 100644 --- a/powershell/public/maester/entra/Test-MtCaExclusionForDirectorySyncAccount.ps1 +++ b/powershell/public/maester/entra/Test-MtCaExclusionForDirectorySyncAccount.ps1 @@ -28,12 +28,19 @@ $testResult = "The following conditional access policies are scoped to all users but don't exclude the directory/OnPremises synchronization accounts:`n`n" try { - $DirectorySynchronizationAccountsRole = Get-MtRoleInfo -RoleName 'DirectorySynchronizationAccounts' - $OnPremisesDirectorySyncAccountRole = Get-MtRoleInfo -RoleName 'OnPremisesDirectorySyncAccount' + # Role template IDs are stable Microsoft-defined GUIDs; use them as fallback + # when Get-MtRoleInfo cannot resolve via the module role definitions cache. + # Variables kept as strings so they work correctly in -in comparisons against + # CA policy conditions (includeRoles/excludeRoles contain GUID strings). + $dirSyncInfo = Get-MtRoleInfo -RoleName 'DirectorySynchronizationAccounts' + $DirectorySynchronizationAccountsRole = if ($null -ne $dirSyncInfo) { $dirSyncInfo.ToString() } else { 'd29b2b05-8046-44ba-8758-1e26182fcf32' } + + $onPremInfo = Get-MtRoleInfo -RoleName 'OnPremisesDirectorySyncAccount' + $OnPremisesDirectorySyncAccountRole = if ($null -ne $onPremInfo) { $onPremInfo.ToString() } else { 'a92aed5d-d78a-4d16-b381-09adb37eb3b0' } $Members = @() - $Members += Get-MtRoleMember -RoleId $DirectorySynchronizationAccountsRole - $Members += Get-MtRoleMember -RoleId $OnPremisesDirectorySyncAccountRole + $Members += Get-MtRoleMember -RoleId ([guid]$DirectorySynchronizationAccountsRole) + $Members += Get-MtRoleMember -RoleId ([guid]$OnPremisesDirectorySyncAccountRole) $Members = @($Members | Where-Object { $null -ne $_ }) if ( $Members.Count -eq 0 ) { diff --git a/powershell/public/maester/entra/Test-MtCaMfaForAllUsers.ps1 b/powershell/public/maester/entra/Test-MtCaMfaForAllUsers.ps1 index b466d698b..b2ef19043 100644 --- a/powershell/public/maester/entra/Test-MtCaMfaForAllUsers.ps1 +++ b/powershell/public/maester/entra/Test-MtCaMfaForAllUsers.ps1 @@ -61,7 +61,7 @@ return $result } catch { - Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess return $false } } diff --git a/powershell/public/maester/entra/Test-MtCaMfaForGuest.ps1 b/powershell/public/maester/entra/Test-MtCaMfaForGuest.ps1 index 41938ebf7..70661e42a 100644 --- a/powershell/public/maester/entra/Test-MtCaMfaForGuest.ps1 +++ b/powershell/public/maester/entra/Test-MtCaMfaForGuest.ps1 @@ -69,7 +69,7 @@ Add-MtTestResultDetail -Result $testResult -GraphObjects $policiesResult -GraphObjectType ConditionalAccess return $result } catch { - Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess return $false } } diff --git a/powershell/public/maester/entra/Test-MtCaMfaForRiskySignIn.ps1 b/powershell/public/maester/entra/Test-MtCaMfaForRiskySignIn.ps1 index 9a0847f72..e811d0a33 100644 --- a/powershell/public/maester/entra/Test-MtCaMfaForRiskySignIn.ps1 +++ b/powershell/public/maester/entra/Test-MtCaMfaForRiskySignIn.ps1 @@ -19,7 +19,7 @@ [OutputType([bool])] param () - if ( ( Get-MtLicenseInformation EntraID ) -ne "P2" ) { + if ( ( Get-MtLicenseInformation EntraID ) -notin 'P2', 'Governance') { Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 return $null } diff --git a/powershell/public/maester/entra/Test-MtCaMisconfiguredIDProtection.ps1 b/powershell/public/maester/entra/Test-MtCaMisconfiguredIDProtection.ps1 index 2b64a5ae8..1ab4b318f 100644 --- a/powershell/public/maester/entra/Test-MtCaMisconfiguredIDProtection.ps1 +++ b/powershell/public/maester/entra/Test-MtCaMisconfiguredIDProtection.ps1 @@ -19,17 +19,18 @@ [OutputType([bool])] param () - if ( ( Get-MtLicenseInformation EntraID ) -ne 'P2' ) { + if ( ( Get-MtLicenseInformation EntraID ) -notin 'P2', 'Governance') { Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 return $null } + $result = $false + $hasRiskCAPolicy = $false # flag to check if there is any policy with risk controls, we skip the test if there is none + $policiesResult = New-Object System.Collections.ArrayList + $testResult = $null + try { $policies = Get-MtConditionalAccessPolicy | Where-Object { $_.state -eq 'enabled' } - $policiesResult = New-Object System.Collections.ArrayList - - $result = $false - $hasRiskCAPolicy = $false # flag to check if there is any policy with risk controls, we skip the test if there is none foreach ($policy in $policies) { if ($policy.conditions.userRiskLevels -or $policy.conditions.signInRiskLevels) { @@ -45,21 +46,23 @@ Write-Verbose "$($policy.displayName) - $CurrentResult" } - if ( -not $hasRiskCAPolicy ) { - Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason 'There are no Conditional Access policies with risk controls configured.' - return $null - } - if ( $result ) { $testResult = "The following conditional access policies have both sign-in risk and user risk controls configured:`n`n%TestResult%" } else { $testResult = 'Well done! No conditional access policies detected where sign-in risk and user risk are combined.' } - - Add-MtTestResultDetail -Result $testResult -GraphObjects $policiesResult -GraphObjectType ConditionalAccess - return $result } catch { - Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess return $false } + + # Skip check must happen outside the try-catch block to prevent Pester's internal + # flow-control exception from being caught and re-thrown as an error result. + if ( -not $hasRiskCAPolicy ) { + Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason 'There are no Conditional Access policies with risk controls configured.' + return $null + } + + Add-MtTestResultDetail -Result $testResult -GraphObjects $policiesResult -GraphObjectType ConditionalAccess + return $result } diff --git a/powershell/public/maester/entra/Test-MtCaReferencedGroupsExist.ps1 b/powershell/public/maester/entra/Test-MtCaReferencedGroupsExist.ps1 index f19f00ef5..a54cdcab4 100644 --- a/powershell/public/maester/entra/Test-MtCaReferencedGroupsExist.ps1 +++ b/powershell/public/maester/entra/Test-MtCaReferencedGroupsExist.ps1 @@ -1,5 +1,5 @@ function Test-MtCaReferencedGroupsExist { - <# + <# .Synopsis Checks if any conditional access policies include or exclude groups that have been deleted. @@ -79,7 +79,7 @@ return $result } catch { - Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess return $null } } diff --git a/powershell/public/maester/entra/Test-MtCaRequirePasswordChangeForHighUserRisk.ps1 b/powershell/public/maester/entra/Test-MtCaRequirePasswordChangeForHighUserRisk.ps1 index 20a781f37..304f020e7 100644 --- a/powershell/public/maester/entra/Test-MtCaRequirePasswordChangeForHighUserRisk.ps1 +++ b/powershell/public/maester/entra/Test-MtCaRequirePasswordChangeForHighUserRisk.ps1 @@ -19,7 +19,7 @@ [OutputType([bool])] param () - if ( ( Get-MtLicenseInformation EntraID ) -ne 'P2' ) { + if ( ( Get-MtLicenseInformation EntraID ) -notin 'P2', 'Governance') { Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 return $null } @@ -56,7 +56,7 @@ return $result } catch { - Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess return $false } } diff --git a/powershell/public/maester/entra/Test-MtCaWIFBlockLegacyAuthentication.ps1 b/powershell/public/maester/entra/Test-MtCaWIFBlockLegacyAuthentication.ps1 index 64ee1c145..24c32f602 100644 --- a/powershell/public/maester/entra/Test-MtCaWIFBlockLegacyAuthentication.ps1 +++ b/powershell/public/maester/entra/Test-MtCaWIFBlockLegacyAuthentication.ps1 @@ -42,7 +42,7 @@ Write-Verbose "Checking if the user $UserId is blocked from using legacy authentication" return $Result } catch { - Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess Write-Verbose "An error occurred while checking if the user $UserId is blocked from using legacy authentication" return $false } diff --git a/tests/Maester/Defender/Test-MtMdeAntivirusPolicy.Tests.ps1 b/tests/Maester/Defender/Test-MtMdeAntivirusPolicy.Tests.ps1 index ae63cbb5f..9cea61452 100644 --- a/tests/Maester/Defender/Test-MtMdeAntivirusPolicy.Tests.ps1 +++ b/tests/Maester/Defender/Test-MtMdeAntivirusPolicy.Tests.ps1 @@ -1,4 +1,8 @@ -Describe "Maester/Defender" -Tag "Maester", "Defender" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} + +Describe "Maester/Defender" -Tag "Maester", "Defender", "License-Intune" -Skip:($null -eq $Licenses.Intune) { It "MT.1148: Archive Scanning should be enabled. See https://maester.dev/docs/tests/MT.1148" -Tag "MT.1148" { $result = Test-MtMdeArchiveScanning if ($null -ne $result) { diff --git a/tests/Maester/Entra/Test-ConditionalAccessBaseline.Tests.ps1 b/tests/Maester/Entra/Test-ConditionalAccessBaseline.Tests.ps1 index 98b253af1..1440aad02 100644 --- a/tests/Maester/Entra/Test-ConditionalAccessBaseline.Tests.ps1 +++ b/tests/Maester/Entra/Test-ConditionalAccessBaseline.Tests.ps1 @@ -1,4 +1,8 @@ -Describe "Maester/Entra" -Tag "Maester", "CA" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense + $EntraIDPlan = $Licenses.EntraID +} +Describe "Maester/Entra" -Tag "Maester", "CA" { It "MT.1001: At least one Conditional Access policy is configured with device compliance. See https://maester.dev/docs/tests/MT.1001" -Tag "MT.1001" { Test-MtCaDeviceComplianceExists | Should -Be $true -Because "there is no policy which requires device compliances" } @@ -29,10 +33,10 @@ It "MT.1011: At least one Conditional Access policy is configured to secure security info registration only from a trusted location. See https://maester.dev/docs/tests/MT.1011" -Tag "MT.1011" { Test-MtCaSecureSecurityInfoRegistration | Should -Be $true -Because "there is no policy that secures security info registration" } - It "MT.1012: At least one Conditional Access policy is configured to require MFA for risky sign-ins. See https://maester.dev/docs/tests/MT.1012" -Skip:( $EntraIDPlan -eq "P1" ) -Tag "MT.1012" { + It "MT.1012: At least one Conditional Access policy is configured to require MFA for risky sign-ins. See https://maester.dev/docs/tests/MT.1012" -Skip:($EntraIDPlan -notin 'P2', 'Governance') -Tag "MT.1012", "License-EntraP2" { Test-MtCaMfaForRiskySignIn | Should -Be $true -Because "there is no policy that requires MFA for risky sign-ins" } - It "MT.1013: At least one Conditional Access policy is configured to require new password when user risk is high. See https://maester.dev/docs/tests/MT.1013" -Skip:( $EntraIDPlan -eq "P1" ) -Tag "MT.1013" { + It "MT.1013: At least one Conditional Access policy is configured to require new password when user risk is high. See https://maester.dev/docs/tests/MT.1013" -Skip:($EntraIDPlan -notin 'P2', 'Governance') -Tag "MT.1013", "License-EntraP2" { Test-MtCaRequirePasswordChangeForHighUserRisk | Should -Be $true -Because "there is no policy that requires new password when user risk is high" } It "MT.1014: At least one Conditional Access policy is configured to require compliant or Entra hybrid joined devices for admins. See https://maester.dev/docs/tests/MT.1014" -Tag "MT.1014" { @@ -65,7 +69,7 @@ It "MT.1038: Conditional Access policies should not include or exclude deleted groups. See https://maester.dev/docs/tests/MT.1038" -Tag "MT.1038" { Test-MtCaReferencedGroupsExist | Should -Be $true -Because "there are one or more policies relying on deleted groups." } - It "MT.1049: Conditional Access policies for User Risk and Sign-in Risk should be configured separately. See https://maester.dev/docs/tests/MT.1049" -Tag "MT.1049" { + It "MT.1049: Conditional Access policies for User Risk and Sign-in Risk should be configured separately. See https://maester.dev/docs/tests/MT.1049" -Tag "MT.1049", "License-EntraP2" -Skip:($EntraIDPlan -notin 'P2', 'Governance') { Test-MtCaMisconfiguredIDProtection | Should -Be $false -Because "there is one or more policy with common misconfiguration for ID Protection " } It "MT.1052: At least one Conditional Access policy is targeting the Device Code authentication flow. See https://maester.dev/docs/tests/MT.1052" -Tag "MT.1052" { diff --git a/tests/Maester/Entra/Test-ConditionalAccessWhatIf.Tests.ps1 b/tests/Maester/Entra/Test-ConditionalAccessWhatIf.Tests.ps1 index 8cf14863b..a2ed4274d 100644 --- a/tests/Maester/Entra/Test-ConditionalAccessWhatIf.Tests.ps1 +++ b/tests/Maester/Entra/Test-ConditionalAccessWhatIf.Tests.ps1 @@ -1,6 +1,7 @@ BeforeDiscovery { try { - $EntraIDPlan = Get-MtLicenseInformation -Product 'EntraID' + $Licenses = Get-MtSessionLicense + $EntraIDPlan = $Licenses.EntraID $RegularUsers = Get-MtUser -Count 5 -UserType 'Member' $AdminUsers = Get-MtUser -Count 5 -UserType 'Admin' $EmergencyAccessUsers = Get-MtUser -Count 5 -UserType 'EmergencyAccess' @@ -21,7 +22,7 @@ Describe 'Maester/Entra' -Tag 'CA', 'CAWhatIf', 'LongRunning', 'Maester' { Context 'Maester/Entra' -ForEach @( $RegularUsers ) { # Regular users - It "MT.1033.$($RegularUsers.IndexOf($_)): User should be blocked from using legacy authentication ($($_.userPrincipalName))" -Tag 'MT.1033', 'CA', 'CAWhatIf', 'LongRunning', 'Maester' -Skip:( $EntraIDPlan -eq 'Free' ) { + It "MT.1033.$([array]::IndexOf(@($RegularUsers), $_)): User should be blocked from using legacy authentication ($($_.userPrincipalName))" -Tag 'MT.1033', 'CA', 'CAWhatIf', 'LongRunning', 'Maester' -Skip:( $EntraIDPlan -eq 'Free' ) { Test-MtCaWIFBlockLegacyAuthentication -UserId $id | Should -Be $true } @@ -29,12 +30,8 @@ Describe 'Maester/Entra' -Tag 'CA', 'CAWhatIf', 'LongRunning', 'Maester' { Context 'Maester/Entra' -ForEach @( $EmergencyAccessUsers ) { # Emergency access users - It "MT.1034.$($EmergencyAccessUsers.IndexOf($_)): Emergency access users should not be blocked ($($_.userPrincipalName))" -Tag 'MT.1034' { - if ( ( Get-MtLicenseInformation EntraID ) -eq 'Free' ) { - Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP1 - } else { - Test-MtConditionalAccessWhatIf -UserId $id -IncludeApplications '00000002-0000-0ff1-ce00-000000000000' -ClientAppType exchangeActiveSync | Should -BeNullOrEmpty - } + It "MT.1034.$([array]::IndexOf(@($EmergencyAccessUsers), $_)): Emergency access users should not be blocked ($($_.userPrincipalName))" -Tag 'MT.1034' -Skip:($EntraIDPlan -eq 'Free') { + Test-MtConditionalAccessWhatIf -UserId $id -IncludeApplications '00000002-0000-0ff1-ce00-000000000000' -ClientAppType exchangeActiveSync | Should -BeNullOrEmpty } } diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementDeletedGroups.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementDeletedGroups.Tests.ps1 index e4038d1d8..1e18daeaa 100644 --- a/tests/Maester/Entra/Test-MtEntitlementManagementDeletedGroups.Tests.ps1 +++ b/tests/Maester/Entra/Test-MtEntitlementManagementDeletedGroups.Tests.ps1 @@ -1,4 +1,7 @@ -Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} +Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages", "License-EntraGovernance" -Skip:($Licenses.EntraID -notin 'P2', 'Governance') { It "MT.1107: Access packages and catalogs should not reference deleted groups. See https://maester.dev/docs/tests/MT.1107" -Tag "MT.1107" { $result = Test-MtEntitlementManagementDeletedGroups $result | Should -Be $true -Because "Access packages and catalogs should not reference deleted groups to prevent access provisioning failures and configuration inconsistencies." diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementInactivePolicies.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementInactivePolicies.Tests.ps1 index 6982320d5..c8439acae 100644 --- a/tests/Maester/Entra/Test-MtEntitlementManagementInactivePolicies.Tests.ps1 +++ b/tests/Maester/Entra/Test-MtEntitlementManagementInactivePolicies.Tests.ps1 @@ -1,4 +1,7 @@ -Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} +Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages", "License-EntraGovernance" -Skip:($Licenses.EntraID -notin 'P2', 'Governance') { It "MT.1108: Access packages should not reference inactive or orphaned assignment policies. See https://maester.dev/docs/tests/MT.1108" -Tag "MT.1108" { $result = Test-MtEntitlementManagementInactivePolicies $result | Should -Be $true -Because "Access packages should not have inactive or misconfigured assignment policies that block access requests or break approval workflows." diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementOrphanedResources.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementOrphanedResources.Tests.ps1 index e06ad4f27..4872771f9 100644 --- a/tests/Maester/Entra/Test-MtEntitlementManagementOrphanedResources.Tests.ps1 +++ b/tests/Maester/Entra/Test-MtEntitlementManagementOrphanedResources.Tests.ps1 @@ -1,4 +1,7 @@ -Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} +Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages", "License-EntraGovernance" -Skip:($Licenses.EntraID -notin 'P2', 'Governance') { It "MT.1110: No catalog should contain resources without any associated access packages. See https://maester.dev/docs/tests/MT.1110" -Tag "MT.1110" { $result = Test-MtEntitlementManagementOrphanedResources $result | Should -Be $true -Because "Catalog resources without associated access packages indicate configuration drift and should be removed to maintain clean governance setup." diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementValidApprovers.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementValidApprovers.Tests.ps1 index c52cd8209..8ff25dca7 100644 --- a/tests/Maester/Entra/Test-MtEntitlementManagementValidApprovers.Tests.ps1 +++ b/tests/Maester/Entra/Test-MtEntitlementManagementValidApprovers.Tests.ps1 @@ -1,4 +1,7 @@ -Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} +Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages", "License-EntraGovernance" -Skip:($Licenses.EntraID -notin 'P2', 'Governance') { It "MT.1109: Access package approval workflows must have valid approvers. See https://maester.dev/docs/tests/MT.1109" -Tag "MT.1109" { $result = Test-MtEntitlementManagementValidApprovers $result | Should -Be $true -Because "Access package approval workflows must have valid approvers to prevent workflow failures and blocked access requests." diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementValidResourceRoles.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementValidResourceRoles.Tests.ps1 index 6f0bfeb0e..989478540 100644 --- a/tests/Maester/Entra/Test-MtEntitlementManagementValidResourceRoles.Tests.ps1 +++ b/tests/Maester/Entra/Test-MtEntitlementManagementValidResourceRoles.Tests.ps1 @@ -1,4 +1,7 @@ -Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} +Describe "Maester/Entra" -Tag "Governance", "Entra", "AccessPackages", "License-EntraGovernance" -Skip:($Licenses.EntraID -notin 'P2', 'Governance') { It "MT.1106: Catalog resources must have valid roles (no stale / removed app roles or SPNs). See https://maester.dev/docs/tests/MT.1106" -Tag "MT.1106" { $result = Test-MtEntitlementManagementValidResourceRoles $result | Should -Be $true -Because "Catalog resources must have valid roles to ensure proper access provisioning. Stale or removed app roles and service principals can cause assignment failures." diff --git a/tests/Maester/Entra/Test-PrivilegedAssignments.Tests.ps1 b/tests/Maester/Entra/Test-PrivilegedAssignments.Tests.ps1 index 9a45d24d6..4bed3b251 100644 --- a/tests/Maester/Entra/Test-PrivilegedAssignments.Tests.ps1 +++ b/tests/Maester/Entra/Test-PrivilegedAssignments.Tests.ps1 @@ -1,4 +1,8 @@ -Describe "Maester/Entra" -Tag "Maester", "Privileged" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} + +Describe "Maester/Entra" -Tag "Maester", "Privileged" { It "MT.1025: No external user with permanent role assignment on Control Plane. See https://maester.dev/docs/tests/MT.1025" -Tag "MT.1025" { $Check = Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel "ControlPlane" -FilterPrincipal "ExternalUser" $Check | Should -Be $false -Because "External user shouldn't have high-privileged roles" @@ -17,37 +21,21 @@ } } -Describe "Maester/Entra" -Tag "Privileged", "PIM" { +Describe "Maester/Entra" -Tag "Privileged", "PIM", "License-EntraP2" -Skip:($Licenses.EntraID -notin 'P2', 'Governance') { It "MT.1029: Stale accounts are not assigned to privileged roles. See https://maester.dev/docs/tests/MT.1029" -Tag "MT.1029" { - if ( ( Get-MtLicenseInformation EntraID ) -ne "P2" ) { - Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 - } else { - $Check = Test-MtPimAlertsExists -AlertId "StaleSignInAlert" - $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact - } + $Check = Test-MtPimAlertsExists -AlertId "StaleSignInAlert" + $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact } It "MT.1030: Eligible role assignments on Control Plane are in use by administrators. See https://maester.dev/docs/tests/MT.1030" -Tag "MT.1030" { - if ( ( Get-MtLicenseInformation EntraID ) -ne "P2" ) { - Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 - } else { - $Check = Test-MtPimAlertsExists -AlertId "RedundantAssignmentAlert" -FilteredAccessLevel "ControlPlane" - $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact - } + $Check = Test-MtPimAlertsExists -AlertId "RedundantAssignmentAlert" -FilteredAccessLevel "ControlPlane" + $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact } It "MT.1031: Privileged role on Control Plane are managed by PIM only. See https://maester.dev/docs/tests/MT.1031" -Tag "MT.1031" { - if ( ( Get-MtLicenseInformation EntraID ) -ne "P2" ) { - Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 - } else { - $Check = Test-MtPimAlertsExists -AlertId "RolesAssignedOutsidePimAlert" -FilteredAccessLevel "ControlPlane" - $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact - } + $Check = Test-MtPimAlertsExists -AlertId "RolesAssignedOutsidePimAlert" -FilteredAccessLevel "ControlPlane" + $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact } It "MT.1032: Limited number of Global Admins are assigned. See https://maester.dev/docs/tests/MT.1032" -Tag "MT.1032" { - if ( ( Get-MtLicenseInformation EntraID ) -ne "P2" ) { - Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 - } else { - $Check = Test-MtPimAlertsExists -AlertId "TooManyGlobalAdminsAssignedToTenantAlert" - $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact - } + $Check = Test-MtPimAlertsExists -AlertId "TooManyGlobalAdminsAssignedToTenantAlert" + $check.isActive -eq $false -or $check.numberOfAffectedItems -eq "0" | Should -Be $true -Because $check.securityImpact } } diff --git a/tests/Maester/Intune/Test-MtIntunePlatform.Tests.ps1 b/tests/Maester/Intune/Test-MtIntunePlatform.Tests.ps1 index 0fa9fb966..4d74eaa8f 100644 --- a/tests/Maester/Intune/Test-MtIntunePlatform.Tests.ps1 +++ b/tests/Maester/Intune/Test-MtIntunePlatform.Tests.ps1 @@ -1,4 +1,8 @@ -Describe "Maester/Intune" -Tag "Maester", "Intune" { +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} + +Describe "Maester/Intune" -Tag "Maester", "Intune", "License-Intune" -Skip:($null -eq $Licenses.Intune) { It "MT.1053: Ensure intune device clean-up rule is configured" -Tag "MT.1053" { $result = Test-MtManagedDeviceCleanupSettings if ($null -ne $result) { diff --git a/website/docs/commands/Import-SingleResultFile.mdx b/website/docs/commands/Import-SingleResultFile.mdx index 2ca2ab17f..1681f96dd 100644 --- a/website/docs/commands/Import-SingleResultFile.mdx +++ b/website/docs/commands/Import-SingleResultFile.mdx @@ -1,6 +1,6 @@ --- sidebar_class_name: hidden -description: Helper: loads a single JSON file and adds valid results to the list. +description: "Helper: loads a single JSON file and adds valid results to the list." id: Import-SingleResultFile title: Import-SingleResultFile hide_title: false diff --git a/website/docs/commands/Test-MaesterResultValid.mdx b/website/docs/commands/Test-MaesterResultValid.mdx index b407f8539..807a34914 100644 --- a/website/docs/commands/Test-MaesterResultValid.mdx +++ b/website/docs/commands/Test-MaesterResultValid.mdx @@ -1,6 +1,6 @@ --- sidebar_class_name: hidden -description: Helper: validates that a result object has the required properties. +description: "Helper: validates that a result object has the required properties." id: Test-MaesterResultValid title: Test-MaesterResultValid hide_title: false diff --git a/website/docs/writing-tests/advanced-concepts.md b/website/docs/writing-tests/advanced-concepts.md index a02ac6901..77eefb607 100644 --- a/website/docs/writing-tests/advanced-concepts.md +++ b/website/docs/writing-tests/advanced-concepts.md @@ -27,7 +27,7 @@ The cache is reset when you run Invoke-Maester to ensure you always have the lat If your tests use Graph cmdlets like `Get-MgUser`, they will not benefit from this caching mechanism and will make a call to the Graph API every time they are run. -### Other key features of `Invoke-MtGraphRequest`: +### Other key features of `Invoke-MtGraphRequest` In addition to caching, `Invoke-MtGraphRequest` has other key features that make it very easy to write tests that query data. @@ -70,6 +70,69 @@ $policy = Invoke-MtGraphRequest @policySplat To learn more see [Invoke-MtGraphRequest](https://github.com/maester365/maester/blob/main/powershell/public/Invoke-MtGraphRequest.ps1). +## Gating tests on license availability + +Some tests only make sense when the tenant has a specific license. Rather than letting those tests fail or produce misleading results on unlicensed tenants, you can skip them cleanly at Pester discovery time using `Get-MtSessionLicense` and a `BeforeDiscovery` block. + +### Get-MtSessionLicense + +`Get-MtSessionLicense` returns a hashtable of all license products evaluated for the current tenant. The map is populated once by `Initialize-MtSession` when `Invoke-Maester` starts, so calling it inside a `BeforeDiscovery` block costs zero additional Graph API calls. + +The keys match the `-Product` parameter of `Get-MtLicenseInformation`: + +| Key | Possible values | +| ----------------- | ------------------------------------------ | +| `EntraID` | `'Free'`, `'P1'`, `'P2'`, `'Governance'` | +| `EntraWorkloadID` | `'P1'`, `'P2'`, or `$null` if not licensed | +| `Eop` | `'Eop'` or `$null` | +| `Mdo` | plan string or `$null` | +| `Intune` | `'Intune'` or `$null` | +| `DefenderXDR` | plan string or `$null` | +| `CustomerLockbox` | plan string or `$null` | +| *(others)* | plan string or `$null` | + +### BeforeDiscovery skip pattern + +Place `Get-MtSessionLicense` inside a `BeforeDiscovery` block at the top of the test file. Variables set there are available to `-Skip:()` expressions on `Describe` and `It` blocks. + +```powershell +BeforeDiscovery { + $Licenses = Get-MtSessionLicense +} + +Describe "Contoso" -Tag "Entra", "License-EntraP2" -Skip:($Licenses.EntraID -notin 'P2', 'Governance') { + It "CTS.2001: Some P2-only check" -Tag "CTS.2001" { + Test-ContosoSomeP2Feature | Should -Be $true + } +} +``` + +Use this pattern for each license tier: + +| Requirement | `-Skip:()` expression | +| ------------------------- | ----------------------------------------------------- | +| Entra ID P1 or above | `-Skip:($Licenses.EntraID -eq 'Free')` | +| Entra ID P2 or Governance | `-Skip:($Licenses.EntraID -notin 'P2', 'Governance')` | +| Intune | `-Skip:($null -eq $Licenses.Intune)` | +| Defender XDR | `-Skip:($null -eq $Licenses.DefenderXDR)` | + +### License tags + +Add the corresponding `License-*` tag to every `Describe` (or `It`) block that uses a license skip. This enables `Invoke-Maester -AutoFilterLicense` to exclude the block entirely via tag filtering before discovery — a faster path on unlicensed tenants. + +| License requirement | Tag to add | +| ----------------------------- | ------------------------- | +| Entra ID P1 | `License-EntraP1` | +| Entra ID P2 | `License-EntraP2` | +| Entra ID Governance | `License-EntraGovernance` | +| Entra Workload ID | `License-EntraWorkloadID` | +| Exchange Online Protection | `License-Eop` | +| Microsoft Defender for Office | `License-Mdo` | +| Advanced Audit | `License-AdvAudit` | +| Defender XDR | `License-DefenderXDR` | +| Customer Lockbox | `License-CustomerLockbox` | +| Intune | `License-Intune` | + ## Splitting tests into multiple files As you write more tests you might find it helpful to split out the markdown part of the tests into a separate file. This helps reduce clutter in the test code and also allows content writers to independently edit the markdown files. Almost all the out of the box Maester tests use this approach of splitting out the markdown content for the test.